From 7b94785a30a0019ce703377feb4712f886053503 Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Fri, 8 Aug 2025 18:10:04 -0500 Subject: [PATCH] refactoring Filter Handling --- .../app/components/header/header.component.ts | 245 ++++--- .../search-modal-commercial.component.ts | 383 ++++++----- .../search-modal/search-modal.component.ts | 599 +++++++++++------- .../src/app/pages/home/home.component.html | 12 +- bizmatch/src/app/pages/home/home.component.ts | 223 +++---- .../broker-listings.component.ts | 12 +- .../business-listings.component.ts | 189 +++--- .../commercial-property-listings.component.ts | 176 +++-- .../src/app/services/filter-state.service.ts | 245 +++++++ bizmatch/src/app/services/search.service.ts | 27 +- 10 files changed, 1353 insertions(+), 758 deletions(-) create mode 100644 bizmatch/src/app/services/filter-state.service.ts diff --git a/bizmatch/src/app/components/header/header.component.ts b/bizmatch/src/app/components/header/header.component.ts index 285a795..b81601a 100644 --- a/bizmatch/src/app/components/header/header.component.ts +++ b/bizmatch/src/app/components/header/header.component.ts @@ -1,25 +1,26 @@ -import { BreakpointObserver } from '@angular/cdk/layout'; import { CommonModule } from '@angular/common'; -import { Component, HostListener } from '@angular/core'; +import { Component, HostListener, OnDestroy, OnInit } 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 { Collapse, Dropdown, initFlowbite } from 'flowbite'; -import { debounceTime, filter, Observable, Subject, Subscription } from 'rxjs'; +import { filter, Observable, Subject, takeUntil } from 'rxjs'; + import { SortByOptions, User } from '../../../../../bizmatch-server/src/models/db.model'; -import { BusinessListingCriteria, CommercialPropertyListingCriteria, emailToDirName, KeycloakUser, KeyValueAsSortBy, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model'; +import { emailToDirName, KeycloakUser, KeyValueAsSortBy, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model'; import { environment } from '../../../environments/environment'; import { AuthService } from '../../services/auth.service'; -import { CriteriaChangeService } from '../../services/criteria-change.service'; +import { FilterStateService } from '../../services/filter-state.service'; import { ListingsService } from '../../services/listings.service'; import { SearchService } from '../../services/search.service'; import { SelectOptionsService } from '../../services/select-options.service'; import { SharedService } from '../../services/shared.service'; import { UserService } from '../../services/user.service'; -import { assignProperties, createEmptyUserListingCriteria, getCriteriaProxy, map2User } from '../../utils/utils'; +import { map2User } from '../../utils/utils'; import { DropdownComponent } from '../dropdown/dropdown.component'; import { ModalService } from '../search-modal/modal.service'; + @UntilDestroy() @Component({ selector: 'header', @@ -28,7 +29,7 @@ import { ModalService } from '../search-modal/modal.service'; templateUrl: './header.component.html', styleUrl: './header.component.scss', }) -export class HeaderComponent { +export class HeaderComponent implements OnInit, OnDestroy { public buildVersion = environment.buildVersion; user$: Observable; keycloakUser: KeycloakUser; @@ -41,27 +42,31 @@ export class HeaderComponent { isMobile: boolean = false; private destroy$ = new Subject(); prompt: string; - private subscription: Subscription; - criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria; - private routerSubscription: Subscription | undefined; - baseRoute: string; - sortDropdownVisible: boolean; + + // Aktueller Listing-Typ basierend auf Route + currentListingType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings' | null = null; + + // Sortierung + sortDropdownVisible: boolean = false; sortByOptions: KeyValueAsSortBy[] = []; + sortBy: SortByOptions = null; + + // Observable für Anzahl der Listings numberOfBroker$: Observable; numberOfCommercial$: Observable; - sortBy: SortByOptions = null; // Neu: Separate Property + constructor( private router: Router, private userService: UserService, private sharedService: SharedService, - private breakpointObserver: BreakpointObserver, private modalService: ModalService, private searchService: SearchService, - private criteriaChangeService: CriteriaChangeService, + private filterStateService: FilterStateService, public selectOptions: SelectOptionsService, public authService: AuthService, private listingService: ListingsService, ) {} + @HostListener('document:click', ['$event']) handleGlobalClick(event: Event) { const target = event.target as HTMLElement; @@ -69,91 +74,125 @@ export class HeaderComponent { this.sortDropdownVisible = false; } } + async ngOnInit() { + // User Setup 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.numberOfBroker$ = this.userService.getNumberOfBroker(createEmptyUserListingCriteria()); + + // Lade Anzahl der Listings + this.numberOfBroker$ = this.userService.getNumberOfBroker(this.createEmptyUserListingCriteria()); this.numberOfCommercial$ = this.listingService.getNumberOfListings('commercialProperty'); + + // Flowbite initialisieren setTimeout(() => { initFlowbite(); }, 10); - this.sharedService.currentProfilePhoto.subscribe(photoUrl => { + // Profile Photo Updates + this.sharedService.currentProfilePhoto.pipe(untilDestroyed(this)).subscribe(photoUrl => { this.profileUrl = photoUrl; }); - this.checkCurrentRoute(this.router.url); - this.setupSortByOptions(); - this.loadSortBy(); // Neu - 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); - }); + // User Updates this.userService.currentUser.pipe(untilDestroyed(this)).subscribe(u => { this.user = u; }); - } - private loadSortBy() { - const storedSortBy = sessionStorage.getItem(this.getSortByKey()); - this.sortBy = storedSortBy ? (storedSortBy as SortByOptions) : null; + + // Router Events + this.router.events + .pipe( + filter(event => event instanceof NavigationEnd), + untilDestroyed(this), + ) + .subscribe((event: NavigationEnd) => { + this.checkCurrentRoute(event.urlAfterRedirects); + }); + + // Initial Route Check + this.checkCurrentRoute(this.router.url); } - private saveSortBy() { - sessionStorage.setItem(this.getSortByKey(), this.sortBy); - } - private getSortByKey(): string { - // Basierend auf Route (für Business/Commercial unterscheiden) - if (this.isBusinessListing()) return 'businessSortBy'; - if (this.isCommercialPropertyListing()) return 'commercialSortBy'; - if (this.isProfessionalListing()) return 'professionalsSortBy'; - return 'defaultSortBy'; // Fallback - } - sortByFct(selectedSortBy: SortByOptions) { - this.sortBy = selectedSortBy; - this.saveSortBy(); // Speichere separat - this.sortDropdownVisible = false; - this.searchService.search(this.criteria.criteriaType); // Neu: Übergebe sortBy separat - } 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); + const baseRoute = url.split('/')[1]; + + // Bestimme den aktuellen Listing-Typ + if (baseRoute === 'businessListings') { + this.currentListingType = 'businessListings'; + } else if (baseRoute === 'commercialPropertyListings') { + this.currentListingType = 'commercialPropertyListings'; + } else if (baseRoute === 'brokerListings') { + this.currentListingType = 'brokerListings'; + } else { + this.currentListingType = null; + return; // Keine relevante Route für Filter/Sort + } + + // Setup für diese Route + this.setupSortByOptions(); + this.subscribeToStateChanges(); } - setupSortByOptions() { + + private subscribeToStateChanges(): void { + if (!this.currentListingType) return; + + // Abonniere State-Änderungen für den aktuellen Listing-Typ + this.filterStateService + .getState$(this.currentListingType) + .pipe(takeUntil(this.destroy$)) + .subscribe(state => { + this.sortBy = state.sortBy; + }); + } + + private setupSortByOptions(): void { this.sortByOptions = []; - let storedSortBy = null; - if (this.isProfessionalListing()) { - this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => s.type === 'professional')]; - storedSortBy = sessionStorage.getItem('professionalsSortBy'); - } - if (this.isBusinessListing()) { - this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => s.type === 'business' || s.type === 'listing')]; - storedSortBy = sessionStorage.getItem('businessSortBy'); - } - if (this.isCommercialPropertyListing()) { - this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => s.type === 'commercial' || s.type === 'listing')]; - storedSortBy = sessionStorage.getItem('commercialSortBy'); + + if (!this.currentListingType) return; + + switch (this.currentListingType) { + case 'brokerListings': + this.sortByOptions = [...this.selectOptions.sortByOptions.filter(s => s.type === 'professional')]; + break; + case 'businessListings': + this.sortByOptions = [...this.selectOptions.sortByOptions.filter(s => s.type === 'business' || s.type === 'listing')]; + break; + case 'commercialPropertyListings': + this.sortByOptions = [...this.selectOptions.sortByOptions.filter(s => s.type === 'commercial' || s.type === 'listing')]; + break; } + + // Füge generische Optionen hinzu (ohne type) this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => !s.type)]; - this.sortBy = storedSortBy && storedSortBy !== 'null' ? (storedSortBy as SortByOptions) : null; } - ngAfterViewInit() {} + + sortByFct(selectedSortBy: SortByOptions): void { + if (!this.currentListingType) return; + + this.sortDropdownVisible = false; + + // Update sortBy im State + this.filterStateService.updateSortBy(this.currentListingType, selectedSortBy); + + // Trigger search + this.searchService.search(this.currentListingType); + } async openModal() { - const modalResult = await this.modalService.showModal(this.criteria); + if (!this.currentListingType) return; + + const criteria = this.filterStateService.getCriteria(this.currentListingType); + const modalResult = await this.modalService.showModal(criteria); + if (modalResult.accepted) { - this.searchService.search(this.criteria.criteriaType); - } else { - this.criteria = assignProperties(this.criteria, modalResult.criteria); + this.searchService.search(this.currentListingType); } } + navigateWithState(dest: string, state: any) { this.router.navigate([dest], { state: state }); } @@ -161,17 +200,21 @@ export class HeaderComponent { isActive(route: string): boolean { return this.router.url === route; } + isFilterUrl(): boolean { return ['/businessListings', '/commercialPropertyListings', '/brokerListings'].includes(this.router.url); } + isBusinessListing(): boolean { - return ['/businessListings'].includes(this.router.url); + return this.router.url === '/businessListings'; } + isCommercialPropertyListing(): boolean { - return ['/commercialPropertyListings'].includes(this.router.url); + return this.router.url === '/commercialPropertyListings'; } + isProfessionalListing(): boolean { - return ['/brokerListings'].includes(this.router.url); + return this.router.url === '/brokerListings'; } closeDropdown() { @@ -183,6 +226,7 @@ export class HeaderComponent { dropdown.hide(); } } + closeMobileMenu() { const targetElement = document.getElementById('navbar-user'); const triggerElement = document.querySelector('[data-collapse-toggle="navbar-user"]'); @@ -192,23 +236,60 @@ export class HeaderComponent { collapse.collapse(); } } + closeMenusAndSetCriteria(path: string) { this.closeDropdown(); this.closeMobileMenu(); - const criteria = getCriteriaProxy(path, this); - criteria.page = 1; - criteria.start = 0; + + // Bestimme Listing-Typ aus dem Pfad + let listingType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings' | null = null; + + if (path === 'businessListings') { + listingType = 'businessListings'; + } else if (path === 'commercialPropertyListings') { + listingType = 'commercialPropertyListings'; + } else if (path === 'brokerListings') { + listingType = 'brokerListings'; + } + + if (listingType) { + // Reset Pagination beim Wechsel zwischen Views + this.filterStateService.updateCriteria(listingType, { + page: 1, + start: 0, + }); + } + } + + toggleSortDropdown() { + this.sortDropdownVisible = !this.sortDropdownVisible; + } + + get isProfessional() { + return this.user?.customerType === 'professional'; + } + + // Helper method für leere UserListingCriteria + private createEmptyUserListingCriteria(): UserListingCriteria { + return { + criteriaType: 'brokerListings', + types: [], + state: null, + city: null, + radius: null, + searchType: 'exact' as const, + brokerName: null, + companyName: null, + counties: [], + prompt: null, + page: 1, + start: 0, + length: 12, + }; } ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); } - - toggleSortDropdown() { - this.sortDropdownVisible = !this.sortDropdownVisible; - } - get isProfessional() { - return this.user?.customerType === 'professional'; - } } diff --git a/bizmatch/src/app/components/search-modal/search-modal-commercial.component.ts b/bizmatch/src/app/components/search-modal/search-modal-commercial.component.ts index 2a5c1f3..ca7e706 100644 --- a/bizmatch/src/app/components/search-modal/search-modal-commercial.component.ts +++ b/bizmatch/src/app/components/search-modal/search-modal-commercial.component.ts @@ -1,17 +1,15 @@ import { CommonModule } from '@angular/common'; -import { Component, Input } from '@angular/core'; +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { NgSelectModule } from '@ng-select/ng-select'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { catchError, concat, debounceTime, distinctUntilChanged, map, Observable, of, Subject, Subscription, switchMap, tap } from 'rxjs'; +import { catchError, concat, debounceTime, distinctUntilChanged, map, Observable, of, Subject, switchMap, takeUntil, tap } from 'rxjs'; import { CommercialPropertyListingCriteria, CountyResult, GeoResult } from '../../../../../bizmatch-server/src/models/main.model'; -import { CriteriaChangeService } from '../../services/criteria-change.service'; +import { FilterStateService } from '../../services/filter-state.service'; import { GeoService } from '../../services/geo.service'; import { ListingsService } from '../../services/listings.service'; import { SearchService } from '../../services/search.service'; import { SelectOptionsService } from '../../services/select-options.service'; -import { UserService } from '../../services/user.service'; -import { getCriteriaStateObject, resetCommercialPropertyListingCriteria } from '../../utils/utils'; import { ValidatedCityComponent } from '../validated-city/validated-city.component'; import { ValidatedPriceComponent } from '../validated-price/validated-price.component'; import { ModalService } from './modal.service'; @@ -24,101 +22,85 @@ import { ModalService } from './modal.service'; templateUrl: './search-modal-commercial.component.html', styleUrls: ['./search-modal.component.scss'], }) -export class SearchModalCommercialComponent { - @Input() - isModal: boolean = true; - // cities$: Observable; +export class SearchModalCommercialComponent implements OnInit, OnDestroy { + @Input() isModal: boolean = true; + + private destroy$ = new Subject(); + private searchDebounce$ = new Subject(); + + // State + criteria: CommercialPropertyListingCriteria; + backupCriteria: any; + + // Geo search counties$: Observable; - // cityLoading = false; countyLoading = false; - // cityInput$ = new Subject(); countyInput$ = new Subject(); - private criteriaChangeSubscription: Subscription; - public criteria: CommercialPropertyListingCriteria; - private debounceTimeout: any; - public backupCriteria: CommercialPropertyListingCriteria = getCriteriaStateObject('businessListings'); + + // Results count numberOfResults$: Observable; cancelDisable = false; + constructor( public selectOptions: SelectOptionsService, public modalService: ModalService, private geoService: GeoService, - private criteriaChangeService: CriteriaChangeService, + private filterStateService: FilterStateService, private listingService: ListingsService, - private userService: UserService, private searchService: SearchService, ) {} - // Define property type options - selectedPropertyType: string | null = null; - selectedPropertyTypeName: string | null = null; - ngOnInit() { - this.setupCriteriaChangeListener(); - - this.modalService.message$.pipe(untilDestroyed(this)).subscribe(msg => { - this.criteria = msg as CommercialPropertyListingCriteria; - this.backupCriteria = JSON.parse(JSON.stringify(msg)); - this.setTotalNumberOfResults(); - }); - this.modalService.modalVisible$.pipe(untilDestroyed(this)).subscribe(val => { - if (val.visible) { - this.criteria.page = 1; - this.criteria.start = 0; - } - }); - // this.loadCities(); + ngOnInit(): void { + // Load counties this.loadCounties(); - this.modalService.sendCriteria(this.criteria); - } - hasActiveFilters(): boolean { - return !!(this.criteria.state || this.criteria.city || this.criteria.minPrice || this.criteria.maxPrice || this.criteria.types.length || this.criteria.title); - } - removeFilter(filterType: string) { - switch (filterType) { - case 'state': - this.criteria.state = null; - this.setCity(null); - break; - case 'city': - this.criteria.city = null; - this.criteria.radius = null; - this.criteria.searchType = 'exact'; - break; - case 'price': - this.criteria.minPrice = null; - this.criteria.maxPrice = null; - break; - case 'types': - this.criteria.types = []; - break; - case 'title': - this.criteria.title = null; - break; + + if (this.isModal) { + // Modal mode: Wait for messages from ModalService + this.modalService.message$.pipe(untilDestroyed(this)).subscribe(criteria => { + if (criteria?.criteriaType === 'commercialPropertyListings') { + this.initializeWithCriteria(criteria); + } + }); + + this.modalService.modalVisible$.pipe(untilDestroyed(this)).subscribe(val => { + if (val.visible && val.type === 'commercialPropertyListings') { + // Reset pagination when modal opens + if (this.criteria) { + this.criteria.page = 1; + this.criteria.start = 0; + } + } + }); + } else { + // Embedded mode: Subscribe to state changes + this.subscribeToStateChanges(); } - this.searchService.search(this.criteria.criteriaType); - } - clearFilter() { - resetCommercialPropertyListingCriteria(this.criteria); - this.searchService.search(this.criteria.criteriaType); - } - // Handle category change - onCategoryChange(event: any[]) { - this.criteria.types = event; - this.onCriteriaChange(); + + // Setup debounced search + this.searchDebounce$.pipe(debounceTime(400), distinctUntilChanged(), takeUntil(this.destroy$)).subscribe(() => { + this.triggerSearch(); + }); } - 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); - } - } - this.searchService.search(this.criteria.criteriaType); + private initializeWithCriteria(criteria: CommercialPropertyListingCriteria): void { + this.criteria = criteria; + this.backupCriteria = JSON.parse(JSON.stringify(criteria)); + this.setTotalNumberOfResults(); } - private loadCounties() { + + private subscribeToStateChanges(): void { + if (!this.isModal) { + this.filterStateService + .getState$('commercialPropertyListings') + .pipe(takeUntil(this.destroy$)) + .subscribe(state => { + this.criteria = { ...state.criteria }; + this.setTotalNumberOfResults(); + }); + } + } + + private loadCounties(): void { this.counties$ = concat( of([]), // default items this.countyInput$.pipe( @@ -126,83 +108,194 @@ export class SearchModalCommercialComponent { 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 + catchError(() => of([])), + map(counties => counties.map(county => county.name)), tap(() => (this.countyLoading = false)), ), ), ), ); } - onCriteriaChange() { - this.searchService.search(this.criteria.criteriaType); - } - 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'; + + // Filter removal methods + removeFilter(filterType: string): void { + const updates: any = {}; + + switch (filterType) { + case 'state': + updates.state = null; + updates.city = null; + updates.radius = null; + updates.searchType = 'exact'; + break; + case 'city': + updates.city = null; + updates.radius = null; + updates.searchType = 'exact'; + break; + case 'price': + updates.minPrice = null; + updates.maxPrice = null; + break; + case 'types': + updates.types = []; + break; + case 'title': + updates.title = null; + break; } - this.searchService.search(this.criteria.criteriaType); + + this.updateCriteria(updates); } - setState(state: string) { - if (state) { - this.criteria.state = state; + + // Category handling + onCategoryChange(selectedCategories: string[]): void { + this.updateCriteria({ types: selectedCategories }); + } + + categoryClicked(checked: boolean, value: string): void { + const types = [...(this.criteria.types || [])]; + if (checked) { + if (!types.includes(value)) { + types.push(value); + } } else { - this.criteria.state = null; - this.setCity(null); - } - this.searchService.search(this.criteria.criteriaType); - } - setRadius(radius: number) { - this.criteria.radius = radius; - this.searchService.search(this.criteria.criteriaType); - } - 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'); - } - closeAndSearch() { - this.modalService.accept(); - this.searchService.search(this.criteria.criteriaType); - this.close(); - } - setTotalNumberOfResults() { - if (this.criteria) { - console.log(`Getting total number of results for ${this.criteria.criteriaType}`); - if (this.criteria.criteriaType === 'commercialPropertyListings') { - this.numberOfResults$ = this.listingService.getNumberOfListings('commercialProperty'); - } else { - this.numberOfResults$ = of(); + const index = types.indexOf(value); + if (index > -1) { + types.splice(index, 1); } } + this.updateCriteria({ types }); } - close() { - this.modalService.reject(this.backupCriteria); + // Location handling + setState(state: string): void { + const updates: any = { state }; + if (!state) { + updates.city = null; + updates.radius = null; + updates.searchType = 'exact'; + } + this.updateCriteria(updates); } - debouncedSearch() { - clearTimeout(this.debounceTimeout); - this.debounceTimeout = setTimeout(() => { - this.searchService.search(this.criteria.criteriaType); - }, 1000); + setCity(city: any): void { + const updates: any = {}; + if (city) { + updates.city = city; + updates.state = city.state; + } else { + updates.city = null; + updates.radius = null; + updates.searchType = 'exact'; + } + this.updateCriteria(updates); + } + + setRadius(radius: number): void { + this.updateCriteria({ radius }); + } + + onCriteriaChange(): void { + this.triggerSearch(); + } + + // Debounced search for text inputs + debouncedSearch(): void { + this.searchDebounce$.next(); + } + + // Clear all filters + clearFilter(): void { + if (this.isModal) { + // In modal: Reset locally + const defaultCriteria = this.getDefaultCriteria(); + this.criteria = defaultCriteria; + this.setTotalNumberOfResults(); + } else { + // Embedded: Use state service + this.filterStateService.clearFilters('commercialPropertyListings'); + } + } + + // Modal-specific methods + closeAndSearch(): void { + if (this.isModal) { + // Save changes to state + this.filterStateService.setCriteria('commercialPropertyListings', this.criteria); + this.modalService.accept(); + this.searchService.search('commercialPropertyListings'); + } + } + + close(): void { + if (this.isModal) { + // Discard changes + this.modalService.reject(this.backupCriteria); + } + } + + // Helper methods + private updateCriteria(updates: any): void { + if (this.isModal) { + // In modal: Update locally only + this.criteria = { ...this.criteria, ...updates }; + this.setTotalNumberOfResults(); + } else { + // Embedded: Update through state service + this.filterStateService.updateCriteria('commercialPropertyListings', updates); + } + + // Trigger search after update + this.debouncedSearch(); + } + + private triggerSearch(): void { + if (this.isModal) { + // In modal: Only update count + this.setTotalNumberOfResults(); + this.cancelDisable = true; + } else { + // Embedded: Full search + this.searchService.search('commercialPropertyListings'); + } + } + + private setTotalNumberOfResults(): void { + this.numberOfResults$ = this.listingService.getNumberOfListings('commercialProperty'); + } + + private getDefaultCriteria(): CommercialPropertyListingCriteria { + // Access the private method through a workaround or create it here + return { + criteriaType: 'commercialPropertyListings', + types: [], + state: null, + city: null, + radius: null, + searchType: 'exact' as const, + minPrice: null, + maxPrice: null, + title: null, + prompt: null, + page: 1, + start: 0, + length: 12, + }; + } + + hasActiveFilters(): boolean { + if (!this.criteria) return false; + + return !!(this.criteria.state || this.criteria.city || this.criteria.minPrice || this.criteria.maxPrice || this.criteria.types?.length || this.criteria.title); + } + + trackByFn(item: GeoResult): any { + return item.id; + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); } } diff --git a/bizmatch/src/app/components/search-modal/search-modal.component.ts b/bizmatch/src/app/components/search-modal/search-modal.component.ts index 3ef04f5..3831ea0 100644 --- a/bizmatch/src/app/components/search-modal/search-modal.component.ts +++ b/bizmatch/src/app/components/search-modal/search-modal.component.ts @@ -1,20 +1,20 @@ import { AsyncPipe, NgIf } from '@angular/common'; -import { Component, Input, Output } from '@angular/core'; +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { NgSelectModule } from '@ng-select/ng-select'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { catchError, concat, debounceTime, distinctUntilChanged, map, Observable, of, Subject, Subscription, switchMap, tap } from 'rxjs'; -import { BusinessListingCriteria, CommercialPropertyListingCriteria, CountyResult, GeoResult, KeyValue, KeyValueStyle, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model'; -import { CriteriaChangeService } from '../../services/criteria-change.service'; +import { catchError, concat, debounceTime, distinctUntilChanged, map, Observable, of, Subject, switchMap, takeUntil, tap } from 'rxjs'; +import { BusinessListingCriteria, CountyResult, GeoResult, KeyValue, KeyValueStyle } from '../../../../../bizmatch-server/src/models/main.model'; +import { FilterStateService } from '../../services/filter-state.service'; import { GeoService } from '../../services/geo.service'; import { ListingsService } from '../../services/listings.service'; import { SearchService } from '../../services/search.service'; import { SelectOptionsService } from '../../services/select-options.service'; import { UserService } from '../../services/user.service'; import { SharedModule } from '../../shared/shared/shared.module'; -import { getCriteriaStateObject, resetBusinessListingCriteria } from '../../utils/utils'; import { ValidatedCityComponent } from '../validated-city/validated-city.component'; import { ValidatedPriceComponent } from '../validated-price/validated-price.component'; import { ModalService } from './modal.service'; + @UntilDestroy() @Component({ selector: 'app-search-modal', @@ -23,167 +23,107 @@ import { ModalService } from './modal.service'; templateUrl: './search-modal.component.html', styleUrl: './search-modal.component.scss', }) -export class SearchModalComponent { - @Output() - @Input() - isModal: boolean = true; - // cities$: Observable; +export class SearchModalComponent implements OnInit, OnDestroy { + @Input() isModal: boolean = true; + + private destroy$ = new Subject(); + private searchDebounce$ = new Subject(); + + // State + criteria: BusinessListingCriteria; + backupCriteria: any; + currentListingType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings'; + + // Geo search counties$: Observable; - // cityLoading = false; countyLoading = false; - // cityInput$ = new Subject(); countyInput$ = new Subject(); - private criteriaChangeSubscription: Subscription; - public criteria: BusinessListingCriteria; - private debounceTimeout: any; - public backupCriteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria = getCriteriaStateObject('businessListings'); - numberOfResults$: Observable; - cancelDisable = false; - constructor( - public selectOptions: SelectOptionsService, - public modalService: ModalService, - private geoService: GeoService, - private criteriaChangeService: CriteriaChangeService, - private listingService: ListingsService, - private userService: UserService, - private searchService: SearchService, - ) {} - // Define property type options - public propertyTypeOptions = [ + + // Property type for business listings + selectedPropertyType: string | null = null; + propertyTypeOptions = [ { name: 'Real Estate', value: 'realEstateChecked' }, { name: 'Leased Location', value: 'leasedLocation' }, { name: 'Franchise', value: 'franchiseResale' }, ]; - selectedPropertyType: string | null = null; - selectedPropertyTypeName: string | null = null; - 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.visible) { - this.criteria.page = 1; - this.criteria.start = 0; - } - }); - // this.loadCities(); + // Results count + numberOfResults$: Observable; + + constructor( + public selectOptions: SelectOptionsService, + public modalService: ModalService, + private geoService: GeoService, + private filterStateService: FilterStateService, + private listingService: ListingsService, + private userService: UserService, + private searchService: SearchService, + ) {} + + ngOnInit(): void { + // Load counties this.loadCounties(); - this.updateSelectedPropertyType(); - this.modalService.sendCriteria(this.criteria); - } - hasActiveFilters(): boolean { - return !!( - this.criteria.state || - this.criteria.city || - this.criteria.minPrice || - this.criteria.maxPrice || - this.criteria.minRevenue || - this.criteria.maxRevenue || - this.criteria.minCashFlow || - this.criteria.maxCashFlow || - this.criteria.types.length || - this.selectedPropertyType || - this.criteria.minNumberEmployees || - this.criteria.maxNumberEmployees || - this.criteria.establishedMin || - this.criteria.brokerName || - this.criteria.title - ); - } - removeFilter(filterType: string) { - switch (filterType) { - case 'state': - this.criteria.state = null; - this.setCity(null); - break; - case 'city': - this.criteria.city = null; - this.criteria.radius = null; - this.criteria.searchType = 'exact'; - break; - case 'price': - this.criteria.minPrice = null; - this.criteria.maxPrice = null; - break; - case 'revenue': - this.criteria.minRevenue = null; - this.criteria.maxRevenue = null; - break; - case 'cashflow': - this.criteria.minCashFlow = null; - this.criteria.maxCashFlow = null; - break; - case 'types': - this.criteria.types = []; - break; - case 'propertyType': - this.criteria.realEstateChecked = false; - this.criteria.leasedLocation = false; - this.criteria.franchiseResale = false; - this.selectedPropertyType = null; - break; - case 'employees': - this.criteria.minNumberEmployees = null; - this.criteria.maxNumberEmployees = null; - break; - case 'established': - this.criteria.establishedMin = null; - break; - case 'brokerName': - this.criteria.brokerName = null; - break; - case 'title': - this.criteria.title = null; - break; - } - this.searchService.search(this.criteria.criteriaType); - } - // Handle category change - onCategoryChange(selectedCategories: string[]) { - this.criteria.types = selectedCategories; - this.searchService.search(this.criteria.criteriaType); - } - // Handle property type change - onPropertyTypeChange(value: string) { - // Reset all property type flags - (this.criteria).realEstateChecked = false; - (this.criteria).leasedLocation = false; - (this.criteria).franchiseResale = false; - // Set the selected property type - if (value) { - this.criteria[value] = true; - } - this.selectedPropertyType = value; - this.searchService.search(this.criteria.criteriaType); - } + if (this.isModal) { + // Modal mode: Wait for messages from ModalService + this.modalService.message$.pipe(untilDestroyed(this)).subscribe(criteria => { + this.initializeWithCriteria(criteria); + }); - // Update selected property type based on current criteria - updateSelectedPropertyType() { - if ((this.criteria).realEstateChecked) this.selectedPropertyType = 'realEstateChecked'; - else if ((this.criteria).leasedLocation) this.selectedPropertyType = 'leasedLocation'; - else if ((this.criteria).franchiseResale) this.selectedPropertyType = 'franchiseResale'; - else this.selectedPropertyType = null; - } - getSelectedPropertyTypeName() { - return this.selectedPropertyType ? this.propertyTypeOptions.find(opt => opt.value === this.selectedPropertyType)?.name : null; - } - categoryClicked(checked: boolean, value: string) { - if (checked) { - this.criteria.types.push(value); + this.modalService.modalVisible$.pipe(untilDestroyed(this)).subscribe(val => { + if (val.visible) { + // Reset pagination when modal opens + if (this.criteria) { + this.criteria.page = 1; + this.criteria.start = 0; + } + } + }); } else { - const index = this.criteria.types.findIndex(t => t === value); - if (index > -1) { - this.criteria.types.splice(index, 1); - } + // Embedded mode: Determine type from route and subscribe to state + this.determineListingType(); + this.subscribeToStateChanges(); } - this.searchService.search(this.criteria.criteriaType); + + // Setup debounced search + this.searchDebounce$.pipe(debounceTime(400), distinctUntilChanged(), takeUntil(this.destroy$)).subscribe(() => { + this.triggerSearch(); + }); } - private loadCounties() { + + private initializeWithCriteria(criteria: any): void { + this.criteria = criteria; + this.currentListingType = criteria.criteriaType; + this.backupCriteria = JSON.parse(JSON.stringify(criteria)); + this.updateSelectedPropertyType(); + this.setTotalNumberOfResults(); + } + + private determineListingType(): void { + const url = window.location.pathname; + if (url.includes('businessListings')) { + this.currentListingType = 'businessListings'; + } else if (url.includes('commercialPropertyListings')) { + this.currentListingType = 'commercialPropertyListings'; + } else if (url.includes('brokerListings')) { + this.currentListingType = 'brokerListings'; + } + } + + private subscribeToStateChanges(): void { + if (!this.isModal && this.currentListingType) { + this.filterStateService + .getState$(this.currentListingType) + .pipe(takeUntil(this.destroy$)) + .subscribe(state => { + this.criteria = { ...state.criteria }; + this.updateSelectedPropertyType(); + this.setTotalNumberOfResults(); + }); + } + } + + private loadCounties(): void { this.counties$ = concat( of([]), // default items this.countyInput$.pipe( @@ -191,103 +131,314 @@ export class SearchModalComponent { 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 + catchError(() => of([])), + map(counties => counties.map(county => county.name)), tap(() => (this.countyLoading = false)), ), ), ), ); } - onCriteriaChange() { - this.searchService.search(this.criteria.criteriaType); + + // Filter removal methods + removeFilter(filterType: string): void { + const updates: any = {}; + + switch (filterType) { + case 'state': + updates.state = null; + updates.city = null; + updates.radius = null; + updates.searchType = 'exact'; + break; + case 'city': + updates.city = null; + updates.radius = null; + updates.searchType = 'exact'; + break; + case 'price': + updates.minPrice = null; + updates.maxPrice = null; + break; + case 'revenue': + updates.minRevenue = null; + updates.maxRevenue = null; + break; + case 'cashflow': + updates.minCashFlow = null; + updates.maxCashFlow = null; + break; + case 'types': + updates.types = []; + break; + case 'propertyType': + updates.realEstateChecked = false; + updates.leasedLocation = false; + updates.franchiseResale = false; + this.selectedPropertyType = null; + break; + case 'employees': + updates.minNumberEmployees = null; + updates.maxNumberEmployees = null; + break; + case 'established': + updates.establishedMin = null; + break; + case 'brokerName': + updates.brokerName = null; + break; + case 'title': + updates.title = null; + break; + } + + this.updateCriteria(updates); } - setCity(city) { + + // Category handling + onCategoryChange(selectedCategories: string[]): void { + this.updateCriteria({ types: selectedCategories }); + } + + categoryClicked(checked: boolean, value: string): void { + const types = [...(this.criteria.types || [])]; + if (checked) { + if (!types.includes(value)) { + types.push(value); + } + } else { + const index = types.indexOf(value); + if (index > -1) { + types.splice(index, 1); + } + } + this.updateCriteria({ types }); + } + + // Property type handling (Business listings only) + onPropertyTypeChange(value: string): void { + const updates: any = { + realEstateChecked: false, + leasedLocation: false, + franchiseResale: false, + }; + + if (value) { + updates[value] = true; + } + + this.selectedPropertyType = value; + this.updateCriteria(updates); + } + + onCheckboxChange(checkbox: string, value: boolean): void { + const updates: any = { + realEstateChecked: false, + leasedLocation: false, + franchiseResale: false, + }; + + updates[checkbox] = value; + this.selectedPropertyType = value ? checkbox : null; + this.updateCriteria(updates); + } + + // Location handling + setState(state: string): void { + const updates: any = { state }; + if (!state) { + updates.city = null; + updates.radius = null; + updates.searchType = 'exact'; + } + this.updateCriteria(updates); + } + + setCity(city: any): void { + const updates: any = {}; if (city) { - this.criteria.city = city; - this.criteria.state = city.state; + updates.city = city; + updates.state = city.state; } else { - this.criteria.city = null; - this.criteria.radius = null; - this.criteria.searchType = 'exact'; + updates.city = null; + updates.radius = null; + updates.searchType = 'exact'; } - this.searchService.search(this.criteria.criteriaType); + this.updateCriteria(updates); } - setState(state: string) { - if (state) { - this.criteria.state = state; - } else { - this.criteria.state = null; - this.setCity(null); - } - this.searchService.search(this.criteria.criteriaType); + + setRadius(radius: number): void { + this.updateCriteria({ radius }); } - setRadius(radius: number) { - this.criteria.radius = radius; - this.searchService.search(this.criteria.criteriaType); + + onCriteriaChange(): void { + this.triggerSearch(); } - private setupCriteriaChangeListener() { - this.criteriaChangeSubscription = this.criteriaChangeService.criteriaChange$.pipe(debounceTime(400)).subscribe(() => { + + // Debounced search for text inputs + debouncedSearch(): void { + this.searchDebounce$.next(); + } + + // Clear all filters + clearFilter(): void { + if (this.isModal) { + // In modal: Reset locally + const defaultCriteria = this.getDefaultCriteria(); + this.criteria = defaultCriteria; + this.updateSelectedPropertyType(); this.setTotalNumberOfResults(); - this.cancelDisable = true; - }); + } else { + // Embedded: Use state service + this.filterStateService.clearFilters(this.currentListingType); + } } - trackByFn(item: GeoResult) { - return item.id; + + // Modal-specific methods + closeAndSearch(): void { + if (this.isModal) { + // Save changes to state + this.filterStateService.setCriteria(this.currentListingType, this.criteria); + this.modalService.accept(); + this.searchService.search(this.currentListingType); + } } - search() { - console.log('Search criteria:', this.criteria); + + close(): void { + if (this.isModal) { + // Discard changes + this.modalService.reject(this.backupCriteria); + } } - getCounties() { - this.geoService.findCountiesStartingWith(''); + + // Helper methods + private updateCriteria(updates: any): void { + if (this.isModal) { + // In modal: Update locally only + this.criteria = { ...this.criteria, ...updates }; + this.setTotalNumberOfResults(); + } else { + // Embedded: Update through state service + this.filterStateService.updateCriteria(this.currentListingType, updates); + } + + // Trigger search after update + this.debouncedSearch(); } - closeModal() { - console.log('Closing modal'); + + private triggerSearch(): void { + if (this.isModal) { + // In modal: Only update count + this.setTotalNumberOfResults(); + } else { + // Embedded: Full search + this.searchService.search(this.currentListingType); + } } - closeAndSearch() { - this.modalService.accept(); - this.searchService.search(this.criteria.criteriaType); - this.close(); - } - 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.criteriaType === 'businessListings' ? 'business' : 'commercialProperty'); - } else if (this.criteria.criteriaType === 'brokerListings') { - //this.numberOfResults$ = this.userService.getNumberOfBroker(this.criteria); + + private updateSelectedPropertyType(): void { + if (this.currentListingType === 'businessListings') { + const businessCriteria = this.criteria as BusinessListingCriteria; + if (businessCriteria.realEstateChecked) { + this.selectedPropertyType = 'realEstateChecked'; + } else if (businessCriteria.leasedLocation) { + this.selectedPropertyType = 'leasedLocation'; + } else if (businessCriteria.franchiseResale) { + this.selectedPropertyType = 'franchiseResale'; } else { - this.numberOfResults$ = of(); + this.selectedPropertyType = null; } } } - clearFilter() { - resetBusinessListingCriteria(this.criteria); - this.searchService.search(this.criteria.criteriaType); - } - close() { - this.modalService.reject(this.backupCriteria); - } - onCheckboxChange(checkbox: string, value: boolean) { - (this.criteria).realEstateChecked = false; - (this.criteria).leasedLocation = false; - (this.criteria).franchiseResale = false; + private setTotalNumberOfResults(): void { + if (!this.criteria) return; - // Aktivieren Sie nur die aktuell ausgewählte Checkbox - this.criteria[checkbox] = value; - this.searchService.search(this.criteria.criteriaType); + switch (this.currentListingType) { + case 'businessListings': + this.numberOfResults$ = this.listingService.getNumberOfListings('business'); + break; + case 'commercialPropertyListings': + this.numberOfResults$ = this.listingService.getNumberOfListings('commercialProperty'); + break; + case 'brokerListings': + this.numberOfResults$ = this.userService.getNumberOfBroker(); + break; + } } - debouncedSearch() { - clearTimeout(this.debounceTimeout); - this.debounceTimeout = setTimeout(() => { - this.searchService.search(this.criteria.criteriaType); - }, 1000); + + private getDefaultCriteria(): any { + switch (this.currentListingType) { + case 'businessListings': + return this.filterStateService['createEmptyBusinessListingCriteria'](); + case 'commercialPropertyListings': + return this.filterStateService['createEmptyCommercialPropertyListingCriteria'](); + case 'brokerListings': + return this.filterStateService['createEmptyUserListingCriteria'](); + } + } + + hasActiveFilters(): boolean { + if (!this.criteria) return false; + + // Check all possible filter properties + const hasBasicFilters = !!(this.criteria.state || this.criteria.city || this.criteria.types?.length); + + // Check business-specific filters + if (this.currentListingType === 'businessListings') { + const bc = this.criteria as BusinessListingCriteria; + return ( + hasBasicFilters || + !!( + bc.minPrice || + bc.maxPrice || + bc.minRevenue || + bc.maxRevenue || + bc.minCashFlow || + bc.maxCashFlow || + bc.minNumberEmployees || + bc.maxNumberEmployees || + bc.establishedMin || + bc.brokerName || + bc.title || + this.selectedPropertyType + ) + ); + } + + // Check commercial property filters + // if (this.currentListingType === 'commercialPropertyListings') { + // const cc = this.criteria as CommercialPropertyListingCriteria; + // return hasBasicFilters || !!(cc.minPrice || cc.maxPrice || cc.title); + // } + + // Check user/broker filters + // if (this.currentListingType === 'brokerListings') { + // const uc = this.criteria as UserListingCriteria; + // return hasBasicFilters || !!(uc.brokerName || uc.companyName || uc.counties?.length); + // } + + return hasBasicFilters; + } + + getSelectedPropertyTypeName(): string | null { + return this.selectedPropertyType ? this.propertyTypeOptions.find(opt => opt.value === this.selectedPropertyType)?.name || null : null; + } + + isTypeOfBusinessClicked(v: KeyValueStyle): boolean { + return !!this.criteria.types?.find(t => t === v.value); + } + + isTypeOfProfessionalClicked(v: KeyValue): boolean { + return !!this.criteria.types?.find(t => t === v.value); + } + + trackByFn(item: GeoResult): any { + return item.id; + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); } } diff --git a/bizmatch/src/app/pages/home/home.component.html b/bizmatch/src/app/pages/home/home.component.html index f3a1204..1b40a98 100644 --- a/bizmatch/src/app/pages/home/home.component.html +++ b/bizmatch/src/app/pages/home/home.component.html @@ -155,7 +155,7 @@ }
- @if(getNumberOfFiltersSet()>0 && numberOfResults$){ + @if( numberOfResults$){ @@ -165,16 +165,6 @@
} - diff --git a/bizmatch/src/app/pages/home/home.component.ts b/bizmatch/src/app/pages/home/home.component.ts index dbd5da4..5a7e933 100644 --- a/bizmatch/src/app/pages/home/home.component.ts +++ b/bizmatch/src/app/pages/home/home.component.ts @@ -3,30 +3,22 @@ import { ChangeDetectorRef, Component, ElementRef, ViewChild } from '@angular/co import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { NgSelectModule } from '@ng-select/ng-select'; -import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { UntilDestroy } from '@ngneat/until-destroy'; import { initFlowbite } from 'flowbite'; -import { catchError, concat, debounceTime, distinctUntilChanged, Observable, of, Subject, Subscription, switchMap, tap } from 'rxjs'; +import { catchError, concat, distinctUntilChanged, Observable, of, Subject, switchMap, tap } from 'rxjs'; import { BusinessListingCriteria, CityAndStateResult, CommercialPropertyListingCriteria, GeoResult, KeycloakUser, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model'; import { ModalService } from '../../components/search-modal/modal.service'; import { TooltipComponent } from '../../components/tooltip/tooltip.component'; import { AiService } from '../../services/ai.service'; import { AuthService } from '../../services/auth.service'; -import { CriteriaChangeService } from '../../services/criteria-change.service'; +import { FilterStateService } from '../../services/filter-state.service'; import { GeoService } from '../../services/geo.service'; import { ListingsService } from '../../services/listings.service'; import { SearchService } from '../../services/search.service'; import { SelectOptionsService } from '../../services/select-options.service'; import { UserService } from '../../services/user.service'; -import { - compareObjects, - createEmptyBusinessListingCriteria, - createEmptyCommercialPropertyListingCriteria, - createEmptyUserListingCriteria, - createEnhancedProxy, - getCriteriaStateObject, - map2User, - removeSortByStorage, -} from '../../utils/utils'; +import { map2User } from '../../utils/utils'; + @UntilDestroy() @Component({ selector: 'app-home', @@ -50,7 +42,6 @@ export class HomeComponent { cityLoading = false; cityInput$ = new Subject(); cityOrState = undefined; - private criteriaChangeSubscription: Subscription; numberOfResults$: Observable; numberOfBroker$: Observable; numberOfCommercial$: Observable; @@ -59,127 +50,156 @@ export class HomeComponent { aiSearchFailed = false; loadingAi = false; @ViewChild('aiSearchInput', { static: false }) searchInput!: ElementRef; - typingSpeed: number = 100; // Geschwindigkeit des Tippens (ms) - pauseTime: number = 2000; // Pausezeit, bevor der Text verschwindet (ms) + typingSpeed: number = 100; + pauseTime: number = 2000; index: number = 0; charIndex: number = 0; typingInterval: any; - showInput: boolean = true; // Steuerung der Anzeige des Eingabefelds + showInput: boolean = true; tooltipTargetBeta = 'tooltipTargetBeta'; - public constructor( + + constructor( private router: Router, private modalService: ModalService, private searchService: SearchService, private activatedRoute: ActivatedRoute, public selectOptions: SelectOptionsService, - - private criteriaChangeService: CriteriaChangeService, private geoService: GeoService, public cdRef: ChangeDetectorRef, private listingService: ListingsService, private userService: UserService, private aiService: AiService, private authService: AuthService, + private filterStateService: FilterStateService, ) {} + async ngOnInit() { setTimeout(() => { initFlowbite(); }, 0); - this.numberOfBroker$ = this.userService.getNumberOfBroker(createEmptyUserListingCriteria()); + + // Clear all filters and sort options on initial load + this.filterStateService.resetCriteria('businessListings'); + this.filterStateService.resetCriteria('commercialPropertyListings'); + this.filterStateService.resetCriteria('brokerListings'); + this.filterStateService.updateSortBy('businessListings', null); + this.filterStateService.updateSortBy('commercialPropertyListings', null); + this.filterStateService.updateSortBy('brokerListings', null); + + // Initialize criteria for the default tab + this.criteria = this.filterStateService.getCriteria('businessListings'); + + this.numberOfBroker$ = this.userService.getNumberOfBroker(this.filterStateService.getCriteria('brokerListings') as UserListingCriteria); this.numberOfCommercial$ = this.listingService.getNumberOfListings('commercialProperty'); const token = await this.authService.getToken(); - sessionStorage.removeItem('businessListings'); - sessionStorage.removeItem('commercialPropertyListings'); - sessionStorage.removeItem('brokerListings'); - this.criteria = createEnhancedProxy(getCriteriaStateObject('businessListings'), this); - removeSortByStorage(); this.user = map2User(token); this.loadCities(); - this.setupCriteriaChangeListener(); + this.setTotalNumberOfResults(); } - async changeTab(tabname: 'business' | 'commercialProperty' | 'broker') { + + changeTab(tabname: 'business' | 'commercialProperty' | 'broker') { this.activeTabAction = tabname; this.cityOrState = null; - if ('business' === tabname) { - this.criteria = createEnhancedProxy(getCriteriaStateObject('businessListings'), this); - } else if ('commercialProperty' === tabname) { - this.criteria = createEnhancedProxy(getCriteriaStateObject('commercialPropertyListings'), this); - } else if ('broker' === tabname) { - this.criteria = createEnhancedProxy(getCriteriaStateObject('brokerListings'), this); - } else { - this.criteria = undefined; - } + const tabToListingType = { + business: 'businessListings', + commercialProperty: 'commercialPropertyListings', + broker: 'brokerListings', + }; + this.criteria = this.filterStateService.getCriteria(tabToListingType[tabname] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings'); + this.setTotalNumberOfResults(); } search() { this.router.navigate([`${this.activeTabAction}Listings`]); } - private setupCriteriaChangeListener() { - this.criteriaChangeSubscription = this.criteriaChangeService.criteriaChange$.pipe(untilDestroyed(this), debounceTime(400)).subscribe(() => this.setTotalNumberOfResults()); - } toggleMenu() { this.isMenuOpen = !this.isMenuOpen; } + onTypesChange(value) { - if (value === '') { - // Wenn keine Option ausgewählt ist, setzen Sie types zurück auf ein leeres Array - this.criteria.types = []; - } else { - this.criteria.types = [value]; - } + const tabToListingType = { + business: 'businessListings', + commercialProperty: 'commercialPropertyListings', + broker: 'brokerListings', + }; + const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings'; + this.filterStateService.updateCriteria(listingType, { types: value === '' ? [] : [value] }); + this.criteria = this.filterStateService.getCriteria(listingType); + this.setTotalNumberOfResults(); } + onRadiusChange(value) { - if (value === 'null') { - // Wenn keine Option ausgewählt ist, setzen Sie types zurück auf ein leeres Array - this.criteria.radius = null; - } else { - this.criteria.radius = parseInt(value); - } + const tabToListingType = { + business: 'businessListings', + commercialProperty: 'commercialPropertyListings', + broker: 'brokerListings', + }; + const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings'; + this.filterStateService.updateCriteria(listingType, { radius: value === 'null' ? null : parseInt(value) }); + this.criteria = this.filterStateService.getCriteria(listingType); + this.setTotalNumberOfResults(); } + async openModal() { + const tabToListingType = { + business: 'businessListings', + commercialProperty: 'commercialPropertyListings', + broker: 'brokerListings', + }; + const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings'; const accepted = await this.modalService.showModal(this.criteria); if (accepted) { this.router.navigate([`${this.activeTabAction}Listings`]); } } + private loadCities() { this.cities$ = concat( - of([]), // default items + of([]), this.cityInput$.pipe( distinctUntilChanged(), tap(() => (this.cityLoading = true)), switchMap(term => - //this.geoService.findCitiesStartingWith(term).pipe( this.geoService.findCitiesAndStatesStartingWith(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 + catchError(() => of([])), tap(() => (this.cityLoading = false)), ), ), ), ); } + trackByFn(item: GeoResult) { return item.id; } + setCityOrState(cityOrState: CityAndStateResult) { + const tabToListingType = { + business: 'businessListings', + commercialProperty: 'commercialPropertyListings', + broker: 'brokerListings', + }; + const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings'; + if (cityOrState) { if (cityOrState.type === 'state') { - this.criteria.state = cityOrState.content.state_code; + this.filterStateService.updateCriteria(listingType, { state: cityOrState.content.state_code, city: null, radius: null, searchType: 'exact' }); } else { - this.criteria.city = cityOrState.content as GeoResult; - this.criteria.state = cityOrState.content.state; - this.criteria.searchType = 'radius'; - this.criteria.radius = 20; + this.filterStateService.updateCriteria(listingType, { + city: cityOrState.content as GeoResult, + state: cityOrState.content.state, + searchType: 'radius', + radius: 20, + }); } } else { - this.criteria.state = null; - this.criteria.city = null; - this.criteria.radius = null; - this.criteria.searchType = 'exact'; + this.filterStateService.updateCriteria(listingType, { state: null, city: null, radius: null, searchType: 'exact' }); } + this.criteria = this.filterStateService.getCriteria(listingType); + this.setTotalNumberOfResults(); } + getTypes() { if (this.criteria.criteriaType === 'businessListings') { return this.selectOptions.typesOfBusiness; @@ -189,6 +209,7 @@ export class HomeComponent { return this.selectOptions.customerSubTypes; } } + getPlaceholderLabel() { if (this.criteria.criteriaType === 'businessListings') { return 'Business Type'; @@ -198,80 +219,28 @@ export class HomeComponent { return 'Professional Type'; } } + setTotalNumberOfResults() { if (this.criteria) { console.log(`Getting total number of results for ${this.criteria.criteriaType}`); + const tabToListingType = { + business: 'businessListings', + commercialProperty: 'commercialPropertyListings', + broker: 'brokerListings', + }; + const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings'; + if (this.criteria.criteriaType === 'businessListings' || this.criteria.criteriaType === 'commercialPropertyListings') { this.numberOfResults$ = this.listingService.getNumberOfListings(this.criteria.criteriaType === 'businessListings' ? 'business' : 'commercialProperty'); } else if (this.criteria.criteriaType === 'brokerListings') { - this.numberOfResults$ = this.userService.getNumberOfBroker(this.criteria); + this.numberOfResults$ = this.userService.getNumberOfBroker(this.filterStateService.getCriteria('brokerListings') as UserListingCriteria); } else { this.numberOfResults$ = of(); } } } - getNumberOfFiltersSet() { - if (this.criteria?.criteriaType === 'brokerListings') { - return compareObjects(createEmptyUserListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius']); - } else if (this.criteria?.criteriaType === 'businessListings') { - return compareObjects(createEmptyBusinessListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius']); - } else if (this.criteria?.criteriaType === 'commercialPropertyListings') { - return compareObjects(createEmptyCommercialPropertyListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius']); - } else { - return 0; - } - } - toggleAiSearch() { - this.aiSearch = !this.aiSearch; - this.aiSearchFailed = false; - if (!this.aiSearch) { - this.aiSearchText = ''; - this.stopTypingEffect(); - } else { - setTimeout(() => this.startTypingEffect(), 0); - } - } + ngOnDestroy(): void { - clearTimeout(this.typingInterval); // Stelle sicher, dass das Intervall gestoppt wird, wenn die Komponente zerstört wird - } - - startTypingEffect(): void { - if (!this.aiSearchText) { - this.typePlaceholder(); - } - } - - stopTypingEffect(): void { clearTimeout(this.typingInterval); } - typePlaceholder(): void { - if (!this.searchInput || !this.searchInput.nativeElement) { - return; // Falls das Eingabefeld nicht verfügbar ist (z.B. durch ngIf) - } - - if (this.aiSearchText) { - return; // Stoppe, wenn der Benutzer Text eingegeben hat - } - - const inputField = this.searchInput.nativeElement as HTMLInputElement; - if (document.activeElement === inputField) { - this.stopTypingEffect(); - return; - } - - inputField.placeholder = this.placeholders[this.index].substring(0, this.charIndex); - - if (this.charIndex < this.placeholders[this.index].length) { - this.charIndex++; - this.typingInterval = setTimeout(() => this.typePlaceholder(), this.typingSpeed); - } else { - // Nach dem vollständigen Tippen eine Pause einlegen - this.typingInterval = setTimeout(() => { - inputField.placeholder = ''; // Schlagartiges Löschen des Platzhalters - this.charIndex = 0; - this.index = (this.index + 1) % this.placeholders.length; - this.typingInterval = setTimeout(() => this.typePlaceholder(), this.typingSpeed); - }, this.pauseTime); - } - } } diff --git a/bizmatch/src/app/pages/listings/broker-listings/broker-listings.component.ts b/bizmatch/src/app/pages/listings/broker-listings/broker-listings.component.ts index 6734c28..6714397 100644 --- a/bizmatch/src/app/pages/listings/broker-listings/broker-listings.component.ts +++ b/bizmatch/src/app/pages/listings/broker-listings/broker-listings.component.ts @@ -2,7 +2,7 @@ import { CommonModule, NgOptimizedImage } from '@angular/common'; import { ChangeDetectorRef, Component } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { UntilDestroy } from '@ngneat/until-destroy'; import { BusinessListing, SortByOptions, User } from '../../../../../../bizmatch-server/src/models/db.model'; import { LISTINGS_PER_PAGE, ListingType, UserListingCriteria, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model'; import { environment } from '../../../../environments/environment'; @@ -62,11 +62,11 @@ export class BrokerListingsComponent { this.criteria = getCriteriaProxy('brokerListings', this) as UserListingCriteria; this.init(); this.loadSortBy(); - this.searchService.currentCriteria.pipe(untilDestroyed(this)).subscribe(({ criteria }) => { - if (criteria.criteriaType === 'brokerListings') { - this.search(); - } - }); + // this.searchService.currentCriteria.pipe(untilDestroyed(this)).subscribe(({ criteria }) => { + // if (criteria.criteriaType === 'brokerListings') { + // this.search(); + // } + // }); } private loadSortBy() { const storedSortBy = sessionStorage.getItem('professionalsSortBy'); diff --git a/bizmatch/src/app/pages/listings/business-listings/business-listings.component.ts b/bizmatch/src/app/pages/listings/business-listings/business-listings.component.ts index ec0d83d..2b76ca3 100644 --- a/bizmatch/src/app/pages/listings/business-listings/business-listings.component.ts +++ b/bizmatch/src/app/pages/listings/business-listings/business-listings.component.ts @@ -1,8 +1,10 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectorRef, Component } from '@angular/core'; +import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { UntilDestroy } from '@ngneat/until-destroy'; +import { Subject, takeUntil } from 'rxjs'; + import dayjs from 'dayjs'; import { BusinessListing, SortByOptions } from '../../../../../../bizmatch-server/src/models/db.model'; import { BusinessListingCriteria, LISTINGS_PER_PAGE, ListingType, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model'; @@ -10,12 +12,12 @@ import { environment } from '../../../../environments/environment'; import { PaginatorComponent } from '../../../components/paginator/paginator.component'; import { ModalService } from '../../../components/search-modal/modal.service'; import { SearchModalComponent } from '../../../components/search-modal/search-modal.component'; -import { CriteriaChangeService } from '../../../services/criteria-change.service'; +import { FilterStateService } from '../../../services/filter-state.service'; import { ImageService } from '../../../services/image.service'; import { ListingsService } from '../../../services/listings.service'; import { SearchService } from '../../../services/search.service'; import { SelectOptionsService } from '../../../services/select-options.service'; -import { assignProperties, getCriteriaProxy, resetBusinessListingCriteria } from '../../../utils/utils'; + @UntilDestroy() @Component({ selector: 'app-business-listings', @@ -24,102 +26,137 @@ import { assignProperties, getCriteriaProxy, resetBusinessListingCriteria } from templateUrl: './business-listings.component.html', styleUrls: ['./business-listings.component.scss', '../../pages.scss'], }) -export class BusinessListingsComponent { +export class BusinessListingsComponent implements OnInit, OnDestroy { + private destroy$ = new Subject(); + + // Component properties environment = environment; - listings: Array; - filteredListings: Array; - criteria: BusinessListingCriteria; - realEstateChecked: boolean; - maxPrice: string; - minPrice: string; - type: string; - state: string; - totalRecords: number = 0; - ts = new Date().getTime(); - first: number = 0; - rows: number = 12; env = environment; - public category: 'business' | 'commercialProperty' | 'professionals_brokers' | undefined; + listings: Array = []; + filteredListings: Array = []; + criteria: BusinessListingCriteria; + sortBy: SortByOptions | null = null; + + // Pagination + totalRecords = 0; page = 1; pageCount = 1; + first = 0; + rows = LISTINGS_PER_PAGE; + + // UI state + ts = new Date().getTime(); emailToDirName = emailToDirName; - sortBy: SortByOptions = null; // Neu: Separate Property + constructor( public selectOptions: SelectOptionsService, private listingsService: ListingsService, - private activatedRoute: ActivatedRoute, private router: Router, private cdRef: ChangeDetectorRef, private imageService: ImageService, - private route: ActivatedRoute, private searchService: SearchService, private modalService: ModalService, - private criteriaChangeService: CriteriaChangeService, - ) { - this.criteria = getCriteriaProxy('businessListings', this) as BusinessListingCriteria; - this.modalService.sendCriteria(this.criteria); - this.init(); - this.loadSortBy(); - this.searchService.currentCriteria.pipe(untilDestroyed(this)).subscribe(({ criteria }) => { - if (criteria.criteriaType === 'businessListings') { + private filterStateService: FilterStateService, + private route: ActivatedRoute, + ) {} + + ngOnInit(): void { + // Subscribe to state changes + this.filterStateService + .getState$('businessListings') + .pipe(takeUntil(this.destroy$)) + .subscribe(state => { + this.criteria = state.criteria; + this.sortBy = state.sortBy; + // Automatically search when state changes + this.search(); + }); + + // Subscribe to search triggers (if triggered from other components) + this.searchService.searchTrigger$.pipe(takeUntil(this.destroy$)).subscribe(type => { + if (type === 'businessListings') { this.search(); } }); } - private loadSortBy() { - const storedSortBy = sessionStorage.getItem('businessSortBy'); - this.sortBy = storedSortBy && storedSortBy !== 'null' ? (storedSortBy as SortByOptions) : null; - } - async ngOnInit() { - this.search(); - } - async init() { - this.reset(); + + async search(): Promise { + try { + // Get current criteria from service + this.criteria = this.filterStateService.getCriteria('businessListings') as BusinessListingCriteria; + + // Add sortBy if available + const searchCriteria = { + ...this.criteria, + sortBy: this.sortBy, + }; + + // Perform search + const listingsResponse = await this.listingsService.getListings('business'); + this.listings = listingsResponse.results; + this.totalRecords = listingsResponse.totalCount; + this.pageCount = Math.ceil(this.totalRecords / LISTINGS_PER_PAGE); + this.page = this.criteria.page || 1; + + // Update view + this.cdRef.markForCheck(); + this.cdRef.detectChanges(); + } catch (error) { + console.error('Search error:', error); + // Handle error appropriately + this.listings = []; + this.totalRecords = 0; + this.cdRef.markForCheck(); + } } - async search() { - const listingReponse = await this.listingsService.getListings('business'); - this.listings = listingReponse.results; - this.totalRecords = listingReponse.totalCount; - this.pageCount = this.totalRecords % LISTINGS_PER_PAGE === 0 ? this.totalRecords / LISTINGS_PER_PAGE : Math.floor(this.totalRecords / LISTINGS_PER_PAGE) + 1; - this.page = this.criteria.page ? this.criteria.page : 1; - this.cdRef.markForCheck(); - this.cdRef.detectChanges(); + onPageChange(page: number): void { + // Update only pagination properties + this.filterStateService.updateCriteria('businessListings', { + page: page, + start: (page - 1) * LISTINGS_PER_PAGE, + length: LISTINGS_PER_PAGE, + }); + // Search will be triggered automatically through state subscription } - onPageChange(page: any) { - this.criteria.start = (page - 1) * LISTINGS_PER_PAGE; - this.criteria.length = LISTINGS_PER_PAGE; - this.criteria.page = page; - this.search(); + + clearAllFilters(): void { + // Reset criteria but keep sortBy + this.filterStateService.clearFilters('businessListings'); + // Search will be triggered automatically through state subscription } - imageErrorHandler(listing: ListingType) {} - reset() { - this.criteria.title = null; + + async openFilterModal(): Promise { + // Open modal with current criteria + const currentCriteria = this.filterStateService.getCriteria('businessListings'); + const modalResult = await this.modalService.showModal(currentCriteria); + + if (modalResult.accepted) { + // Modal accepted changes - state is updated by modal + // Search will be triggered automatically through state subscription + } else { + // Modal was cancelled - no action needed + } + } + + getListingPrice(listing: BusinessListing): string { + if (!listing.price) return 'Price on Request'; + return `$${listing.price.toLocaleString()}`; + } + + getListingLocation(listing: BusinessListing): string { + if (!listing.location) return 'Location not specified'; + return `${listing.location.name}, ${listing.location.state}`; + } + + navigateToDetails(listingId: string): void { + this.router.navigate(['/details-business', listingId]); } getDaysListed(listing: BusinessListing) { return dayjs().diff(listing.created, 'day'); } - // New methods for filter actions - clearAllFilters() { - // Reset criteria to default values - resetBusinessListingCriteria(this.criteria); - - // Reset pagination - this.criteria.page = 1; - this.criteria.start = 0; - - this.criteriaChangeService.notifyCriteriaChange(); - - // Search with cleared filters - this.searchService.search('businessListings'); - } - - async openFilterModal() { - // Open the search modal with current criteria - const modalResult = await this.modalService.showModal(this.criteria); - if (modalResult.accepted) { - this.criteria = assignProperties(this.criteria, modalResult.criteria); // Update criteria with modal result - this.searchService.search('businessListings'); // Trigger search with updated criteria - } + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); } } diff --git a/bizmatch/src/app/pages/listings/commercial-property-listings/commercial-property-listings.component.ts b/bizmatch/src/app/pages/listings/commercial-property-listings/commercial-property-listings.component.ts index 6cd210c..a874c13 100644 --- a/bizmatch/src/app/pages/listings/commercial-property-listings/commercial-property-listings.component.ts +++ b/bizmatch/src/app/pages/listings/commercial-property-listings/commercial-property-listings.component.ts @@ -1,21 +1,21 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectorRef, Component } from '@angular/core'; +import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; -import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { UntilDestroy } from '@ngneat/until-destroy'; import dayjs from 'dayjs'; +import { Subject, takeUntil } from 'rxjs'; import { CommercialPropertyListing, SortByOptions } from '../../../../../../bizmatch-server/src/models/db.model'; import { CommercialPropertyListingCriteria, LISTINGS_PER_PAGE, ResponseCommercialPropertyListingArray } from '../../../../../../bizmatch-server/src/models/main.model'; import { environment } from '../../../../environments/environment'; import { PaginatorComponent } from '../../../components/paginator/paginator.component'; import { ModalService } from '../../../components/search-modal/modal.service'; import { SearchModalCommercialComponent } from '../../../components/search-modal/search-modal-commercial.component'; -import { CriteriaChangeService } from '../../../services/criteria-change.service'; +import { FilterStateService } from '../../../services/filter-state.service'; import { ImageService } from '../../../services/image.service'; import { ListingsService } from '../../../services/listings.service'; import { SearchService } from '../../../services/search.service'; import { SelectOptionsService } from '../../../services/select-options.service'; -import { assignProperties, getCriteriaProxy, resetCommercialPropertyListingCriteria } from '../../../utils/utils'; @UntilDestroy() @Component({ @@ -25,103 +25,141 @@ import { assignProperties, getCriteriaProxy, resetCommercialPropertyListingCrite templateUrl: './commercial-property-listings.component.html', styleUrls: ['./commercial-property-listings.component.scss', '../../pages.scss'], }) -export class CommercialPropertyListingsComponent { +export class CommercialPropertyListingsComponent implements OnInit, OnDestroy { + private destroy$ = new Subject(); + + // Component properties environment = environment; - listings: Array; - filteredListings: Array; - criteria: CommercialPropertyListingCriteria; - realEstateChecked: boolean; - first: number = 0; - rows: number = 12; - maxPrice: string; - minPrice: string; - type: string; - statesSet = new Set(); - state: string; - totalRecords: number = 0; env = environment; + listings: Array = []; + filteredListings: Array = []; + criteria: CommercialPropertyListingCriteria; + sortBy: SortByOptions | null = null; + + // Pagination + totalRecords = 0; page = 1; pageCount = 1; + first = 0; + rows = LISTINGS_PER_PAGE; + + // UI state ts = new Date().getTime(); - sortBy: SortByOptions = null; // Neu: Separate Property + constructor( public selectOptions: SelectOptionsService, private listingsService: ListingsService, - private activatedRoute: ActivatedRoute, private router: Router, private cdRef: ChangeDetectorRef, private imageService: ImageService, - private route: ActivatedRoute, private searchService: SearchService, private modalService: ModalService, - private criteriaChangeService: CriteriaChangeService, - ) { - this.criteria = getCriteriaProxy('commercialPropertyListings', this) as CommercialPropertyListingCriteria; - this.modalService.sendCriteria(this.criteria); - this.loadSortBy(); - this.searchService.currentCriteria.pipe(untilDestroyed(this)).subscribe(({ criteria }) => { - if (criteria.criteriaType === 'commercialPropertyListings') { + private filterStateService: FilterStateService, + private route: ActivatedRoute, + ) {} + + ngOnInit(): void { + // Subscribe to state changes + this.filterStateService + .getState$('commercialPropertyListings') + .pipe(takeUntil(this.destroy$)) + .subscribe(state => { + this.criteria = state.criteria; + this.sortBy = state.sortBy; + // Automatically search when state changes + this.search(); + }); + + // Subscribe to search triggers (if triggered from other components) + this.searchService.searchTrigger$.pipe(takeUntil(this.destroy$)).subscribe(type => { + if (type === 'commercialPropertyListings') { this.search(); } }); } - private loadSortBy() { - const storedSortBy = sessionStorage.getItem('commercialSortBy'); - this.sortBy = storedSortBy && storedSortBy !== 'null' ? (storedSortBy as SortByOptions) : null; - } - async ngOnInit() { - this.search(); + + async search(): Promise { + try { + // Perform search + const listingResponse = await this.listingsService.getListings('commercialProperty'); + this.listings = (listingResponse as ResponseCommercialPropertyListingArray).results; + this.totalRecords = (listingResponse as ResponseCommercialPropertyListingArray).totalCount; + this.pageCount = Math.ceil(this.totalRecords / LISTINGS_PER_PAGE); + this.page = this.criteria.page || 1; + + // Update view + this.cdRef.markForCheck(); + this.cdRef.detectChanges(); + } catch (error) { + console.error('Search error:', error); + // Handle error appropriately + this.listings = []; + this.totalRecords = 0; + this.cdRef.markForCheck(); + } } - async search() { - const listingReponse = await this.listingsService.getListings('commercialProperty'); - this.listings = (listingReponse).results; - this.totalRecords = (listingReponse).totalCount; - this.pageCount = this.totalRecords % LISTINGS_PER_PAGE === 0 ? this.totalRecords / LISTINGS_PER_PAGE : Math.floor(this.totalRecords / LISTINGS_PER_PAGE) + 1; - this.page = this.criteria.page ? this.criteria.page : 1; - this.cdRef.markForCheck(); - this.cdRef.detectChanges(); + onPageChange(page: number): void { + // Update only pagination properties + this.filterStateService.updateCriteria('commercialPropertyListings', { + page: page, + start: (page - 1) * LISTINGS_PER_PAGE, + length: LISTINGS_PER_PAGE, + }); + // Search will be triggered automatically through state subscription } - onPageChange(page: any) { - this.criteria.start = (page - 1) * LISTINGS_PER_PAGE; - this.criteria.length = LISTINGS_PER_PAGE; - this.criteria.page = page; - this.search(); + clearAllFilters(): void { + // Reset criteria but keep sortBy + this.filterStateService.clearFilters('commercialPropertyListings'); + // Search will be triggered automatically through state subscription } - reset() { - this.criteria.title = null; + async openFilterModal(): Promise { + // Open modal with current criteria + const currentCriteria = this.filterStateService.getCriteria('commercialPropertyListings'); + const modalResult = await this.modalService.showModal(currentCriteria); + + if (modalResult.accepted) { + // Modal accepted changes - state is updated by modal + // Search will be triggered automatically through state subscription + } else { + // Modal was cancelled - no action needed + } } - getTS() { + // Helper methods for template + getTS(): number { return new Date().getTime(); } - getDaysListed(listing: CommercialPropertyListing) { + getDaysListed(listing: CommercialPropertyListing): number { return dayjs().diff(listing.created, 'day'); } - // New methods for filter actions - clearAllFilters() { - // Reset criteria to default values - resetCommercialPropertyListingCriteria(this.criteria); - - // Reset pagination - this.criteria.page = 1; - this.criteria.start = 0; - - this.criteriaChangeService.notifyCriteriaChange(); - - // Search with cleared filters - this.searchService.search('commercialPropertyListings'); + getListingImage(listing: CommercialPropertyListing): string { + if (listing.imageOrder?.length > 0) { + return `${this.env.imageBaseUrl}/pictures/property/${listing.imagePath}/${listing.serialId}/${listing.imageOrder[0]}`; + } + return 'assets/images/placeholder_properties.jpg'; } - async openFilterModal() { - const modalResult = await this.modalService.showModal(this.criteria); - if (modalResult.accepted) { - this.criteria = assignProperties(this.criteria, modalResult.criteria); // Update criteria with modal result - this.searchService.search('commercialPropertyListings'); // Trigger search with updated criteria - } + getListingPrice(listing: CommercialPropertyListing): string { + if (!listing.price) return 'Price on Request'; + return `$${listing.price.toLocaleString()}`; + } + + getListingLocation(listing: CommercialPropertyListing): string { + if (!listing.location) return 'Location not specified'; + return listing.location.name || listing.location.county || 'Location not specified'; + } + + navigateToDetails(listingId: string): void { + this.router.navigate(['/details-commercial-property-listing', listingId]); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); } } diff --git a/bizmatch/src/app/services/filter-state.service.ts b/bizmatch/src/app/services/filter-state.service.ts new file mode 100644 index 0000000..70f9b9f --- /dev/null +++ b/bizmatch/src/app/services/filter-state.service.ts @@ -0,0 +1,245 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { SortByOptions } from '../../../../bizmatch-server/src/models/db.model'; +import { BusinessListingCriteria, CommercialPropertyListingCriteria, UserListingCriteria } from '../../../../bizmatch-server/src/models/main.model'; + +type CriteriaType = BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria; +type ListingType = 'businessListings' | 'commercialPropertyListings' | 'brokerListings'; + +interface FilterState { + businessListings: { + criteria: BusinessListingCriteria; + sortBy: SortByOptions | null; + }; + commercialPropertyListings: { + criteria: CommercialPropertyListingCriteria; + sortBy: SortByOptions | null; + }; + brokerListings: { + criteria: UserListingCriteria; + sortBy: SortByOptions | null; + }; +} + +@Injectable({ + providedIn: 'root', +}) +export class FilterStateService { + private state: FilterState; + private stateSubjects: Map> = new Map(); + + constructor() { + // Initialize state from sessionStorage or with defaults + this.state = this.loadStateFromStorage(); + + // Create BehaviorSubjects for each listing type + this.stateSubjects.set('businessListings', new BehaviorSubject(this.state.businessListings)); + this.stateSubjects.set('commercialPropertyListings', new BehaviorSubject(this.state.commercialPropertyListings)); + this.stateSubjects.set('brokerListings', new BehaviorSubject(this.state.brokerListings)); + } + + // Get observable for specific listing type + getState$(type: ListingType): Observable { + return this.stateSubjects.get(type)!.asObservable(); + } + + // Get current criteria + getCriteria(type: ListingType): CriteriaType { + return { ...this.state[type].criteria }; + } + + // Update criteria + updateCriteria(type: ListingType, criteria: Partial): void { + // Type-safe update basierend auf dem Listing-Typ + if (type === 'businessListings') { + this.state.businessListings.criteria = { + ...this.state.businessListings.criteria, + ...criteria, + } as BusinessListingCriteria; + } else if (type === 'commercialPropertyListings') { + this.state.commercialPropertyListings.criteria = { + ...this.state.commercialPropertyListings.criteria, + ...criteria, + } as CommercialPropertyListingCriteria; + } else if (type === 'brokerListings') { + this.state.brokerListings.criteria = { + ...this.state.brokerListings.criteria, + ...criteria, + } as UserListingCriteria; + } + + this.saveToStorage(type); + this.emitState(type); + } + + // Set complete criteria (for reset operations) + setCriteria(type: ListingType, criteria: CriteriaType): void { + if (type === 'businessListings') { + this.state.businessListings.criteria = criteria as BusinessListingCriteria; + } else if (type === 'commercialPropertyListings') { + this.state.commercialPropertyListings.criteria = criteria as CommercialPropertyListingCriteria; + } else if (type === 'brokerListings') { + this.state.brokerListings.criteria = criteria as UserListingCriteria; + } + + this.saveToStorage(type); + this.emitState(type); + } + + // Get current sortBy + getSortBy(type: ListingType): SortByOptions | null { + return this.state[type].sortBy; + } + + // Update sortBy + updateSortBy(type: ListingType, sortBy: SortByOptions | null): void { + this.state[type].sortBy = sortBy; + this.saveSortByToStorage(type, sortBy); + this.emitState(type); + } + + // Reset criteria to defaults + resetCriteria(type: ListingType): void { + if (type === 'businessListings') { + this.state.businessListings.criteria = this.createEmptyBusinessListingCriteria(); + } else if (type === 'commercialPropertyListings') { + this.state.commercialPropertyListings.criteria = this.createEmptyCommercialPropertyListingCriteria(); + } else if (type === 'brokerListings') { + this.state.brokerListings.criteria = this.createEmptyUserListingCriteria(); + } + + this.saveToStorage(type); + this.emitState(type); + } + + // Clear all filters but keep sortBy + clearFilters(type: ListingType): void { + const sortBy = this.state[type].sortBy; + this.resetCriteria(type); + this.state[type].sortBy = sortBy; + this.emitState(type); + } + + private emitState(type: ListingType): void { + this.stateSubjects.get(type)?.next({ ...this.state[type] }); + } + + private saveToStorage(type: ListingType): void { + sessionStorage.setItem(type, JSON.stringify(this.state[type].criteria)); + } + + private saveSortByToStorage(type: ListingType, sortBy: SortByOptions | null): void { + const sortByKey = type === 'businessListings' ? 'businessSortBy' : type === 'commercialPropertyListings' ? 'commercialSortBy' : 'professionalsSortBy'; + + if (sortBy) { + sessionStorage.setItem(sortByKey, sortBy); + } else { + sessionStorage.removeItem(sortByKey); + } + } + + private loadStateFromStorage(): FilterState { + return { + businessListings: { + criteria: this.loadCriteriaFromStorage('businessListings') as BusinessListingCriteria, + sortBy: this.loadSortByFromStorage('businessSortBy'), + }, + commercialPropertyListings: { + criteria: this.loadCriteriaFromStorage('commercialPropertyListings') as CommercialPropertyListingCriteria, + sortBy: this.loadSortByFromStorage('commercialSortBy'), + }, + brokerListings: { + criteria: this.loadCriteriaFromStorage('brokerListings') as UserListingCriteria, + sortBy: this.loadSortByFromStorage('professionalsSortBy'), + }, + }; + } + + private loadCriteriaFromStorage(key: ListingType): CriteriaType { + const stored = sessionStorage.getItem(key); + if (stored) { + return JSON.parse(stored); + } + + switch (key) { + case 'businessListings': + return this.createEmptyBusinessListingCriteria(); + case 'commercialPropertyListings': + return this.createEmptyCommercialPropertyListingCriteria(); + case 'brokerListings': + return this.createEmptyUserListingCriteria(); + } + } + + private loadSortByFromStorage(key: string): SortByOptions | null { + const stored = sessionStorage.getItem(key); + return stored && stored !== 'null' ? (stored as SortByOptions) : null; + } + + // Helper methods to create empty criteria + private createEmptyBusinessListingCriteria(): BusinessListingCriteria { + return { + criteriaType: 'businessListings', + types: [], + state: null, + city: null, + radius: null, + searchType: 'exact' as const, + minPrice: null, + maxPrice: null, + minRevenue: null, + maxRevenue: null, + minCashFlow: null, + maxCashFlow: null, + minNumberEmployees: null, + maxNumberEmployees: null, + establishedMin: null, + brokerName: null, + title: null, + realEstateChecked: false, + leasedLocation: false, + franchiseResale: false, + email: null, + prompt: null, + page: 1, + start: 0, + length: 12, + }; + } + + private createEmptyCommercialPropertyListingCriteria(): CommercialPropertyListingCriteria { + return { + criteriaType: 'commercialPropertyListings', + types: [], + state: null, + city: null, + radius: null, + searchType: 'exact' as const, + minPrice: null, + maxPrice: null, + title: null, + prompt: null, + page: 1, + start: 0, + length: 12, + }; + } + + private createEmptyUserListingCriteria(): UserListingCriteria { + return { + criteriaType: 'brokerListings', + types: [], + state: null, + city: null, + radius: null, + searchType: 'exact' as const, + brokerName: null, + companyName: null, + counties: [], + prompt: null, + page: 1, + start: 0, + length: 12, + }; + } +} diff --git a/bizmatch/src/app/services/search.service.ts b/bizmatch/src/app/services/search.service.ts index 1d6e1e0..980166d 100644 --- a/bizmatch/src/app/services/search.service.ts +++ b/bizmatch/src/app/services/search.service.ts @@ -1,30 +1,21 @@ +// Vereinfachter search.service.ts import { Injectable } from '@angular/core'; import { Subject } from 'rxjs'; -import { SortByOptions } from '../../../../bizmatch-server/src/models/db.model'; -import { BusinessListingCriteria, CommercialPropertyListingCriteria, UserListingCriteria } from '../../../../bizmatch-server/src/models/main.model'; -import { getCriteriaProxy } from '../utils/utils'; @Injectable({ providedIn: 'root', }) export class SearchService { - private criteriaSource = new Subject<{ - criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria; - sortBy?: SortByOptions; - }>(); - currentCriteria = this.criteriaSource.asObservable(); + private searchTriggerSubject = new Subject(); + + // Observable für Komponenten zum Abonnieren + searchTrigger$ = this.searchTriggerSubject.asObservable(); constructor() {} - search(criteriaType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings'): void { - const criteria = getCriteriaProxy(criteriaType, this); - const storedSortBy = - criteriaType === 'businessListings' - ? sessionStorage.getItem('businessSortBy') - : criteriaType === 'commercialPropertyListings' - ? sessionStorage.getItem('commercialSortBy') - : sessionStorage.getItem('professionalsSortBy'); - const sortBy = storedSortBy && storedSortBy !== 'null' ? (storedSortBy as SortByOptions) : null; - this.criteriaSource.next({ criteria, sortBy }); + // Trigger eine Suche für einen bestimmten Listing-Typ + search(listingType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings'): void { + console.log(`Triggering search for: ${listingType}`); + this.searchTriggerSubject.next(listingType); } }