counties, pagination, filter count, show total results

This commit is contained in:
Andreas Knuth 2024-07-19 18:06:56 +02:00
parent abcde3991d
commit 9db23c2177
25 changed files with 67207 additions and 509 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,23 @@
import { Controller, Get, Param } from '@nestjs/common'; import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { CountyRequest } from 'src/models/server.model.js';
import { GeoService } from './geo.service.js'; import { GeoService } from './geo.service.js';
@Controller('geo') @Controller('geo')
export class GeoController { export class GeoController {
constructor(private geoService:GeoService){} constructor(private geoService: GeoService) {}
@Get(':prefix') @Get(':prefix')
findByPrefix(@Param('prefix') prefix:string): any { findByPrefix(@Param('prefix') prefix: string): any {
return this.geoService.findCitiesStartingWith(prefix); return this.geoService.findCitiesStartingWith(prefix);
} }
@Get(':prefix/:state') @Get(':prefix/:state')
findByPrefixAndState(@Param('prefix') prefix:string,@Param('state') state:string): any { findByPrefixAndState(@Param('prefix') prefix: string, @Param('state') state: string): any {
return this.geoService.findCitiesStartingWith(prefix,state); return this.geoService.findCitiesStartingWith(prefix, state);
} }
@Post('counties')
findByPrefixAndStates(@Body() countyRequest: CountyRequest): any {
return this.geoService.findCountiesStartingWith(countyRequest.prefix, countyRequest.states);
}
} }

View File

@ -1,9 +1,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import path, { join } from 'path'; import path, { join } from 'path';
import { GeoResult } from 'src/models/main.model.js'; import { CountyResult, GeoResult } from 'src/models/main.model.js';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { City, Geo, State } from '../models/server.model.js'; import { City, CountyData, Geo, State } from '../models/server.model.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@ -11,6 +11,7 @@ const __dirname = path.dirname(__filename);
@Injectable() @Injectable()
export class GeoService { export class GeoService {
geo: Geo; geo: Geo;
counties: CountyData[];
constructor() { constructor() {
this.loadGeo(); this.loadGeo();
} }
@ -18,9 +19,32 @@ export class GeoService {
const filePath = join(__dirname, '../..', 'assets', 'geo.json'); const filePath = join(__dirname, '../..', 'assets', 'geo.json');
const rawData = readFileSync(filePath, 'utf8'); const rawData = readFileSync(filePath, 'utf8');
this.geo = JSON.parse(rawData); this.geo = JSON.parse(rawData);
const countiesFilePath = join(__dirname, '../..', 'assets', 'counties.json');
const rawCountiesData = readFileSync(countiesFilePath, 'utf8');
this.counties = JSON.parse(rawCountiesData);
} }
findCountiesStartingWith(prefix: string, states?: string[]) {
let results: CountyResult[] = [];
let idCounter = 1;
findCitiesStartingWith(prefix: string, state?: string): { city: string; state: string; state_code: string }[] { this.counties.forEach(stateData => {
if (!states || states.includes(stateData.state)) {
stateData.counties.forEach(county => {
if (county.startsWith(prefix.toUpperCase())) {
results.push({
id: idCounter++,
name: county,
state: stateData.state_full,
state_code: stateData.state,
});
}
});
}
});
return results;
}
findCitiesStartingWith(prefix: string, state?: string): GeoResult[] {
const result: GeoResult[] = []; const result: GeoResult[] = [];
this.geo.states.forEach((state: State) => { this.geo.states.forEach((state: State) => {

View File

@ -29,7 +29,10 @@ export class BusinessListingsController {
find(@Request() req, @Body() criteria: BusinessListingCriteria): any { find(@Request() req, @Body() criteria: BusinessListingCriteria): any {
return this.listingsService.searchBusinessListings(criteria, req.user as JwtUser); return this.listingsService.searchBusinessListings(criteria, req.user as JwtUser);
} }
@Post('findTotal')
findTotal(@Body() criteria: BusinessListingCriteria): Promise<number> {
return this.listingsService.getBusinessListingsCount(criteria);
}
// @UseGuards(OptionalJwtAuthGuard) // @UseGuards(OptionalJwtAuthGuard)
// @Post('search') // @Post('search')
// search(@Request() req, @Body() criteria: BusinessListingCriteria): any { // search(@Request() req, @Body() criteria: BusinessListingCriteria): any {

View File

@ -31,6 +31,10 @@ export class CommercialPropertyListingsController {
async find(@Request() req, @Body() criteria: CommercialPropertyListingCriteria): Promise<any> { async find(@Request() req, @Body() criteria: CommercialPropertyListingCriteria): Promise<any> {
return await this.listingsService.searchCommercialProperties(criteria, req.user as JwtUser); return await this.listingsService.searchCommercialProperties(criteria, req.user as JwtUser);
} }
@Post('findTotal')
findTotal(@Body() criteria: CommercialPropertyListingCriteria): Promise<number> {
return this.listingsService.getCommercialPropertiesCount(criteria);
}
@Get('states/all') @Get('states/all')
getStates(): any { getStates(): any {
return this.listingsService.getStates(); return this.listingsService.getStates();

View File

@ -57,7 +57,6 @@ export interface ListCriteria {
start: number; start: number;
length: number; length: number;
page: number; page: number;
pageCount: number;
types: string[]; types: string[];
city: string; city: string;
prompt: string; prompt: string;
@ -230,6 +229,12 @@ export interface GeoResult {
state: string; state: string;
state_code: string; state_code: string;
} }
export interface CountyResult {
id: number;
name: string;
state: string;
state_code: string;
}
export function isEmpty(value: any): boolean { export function isEmpty(value: any): boolean {
// Check for undefined or null // Check for undefined or null
if (value === undefined || value === null) { if (value === undefined || value === null) {

View File

@ -61,3 +61,12 @@ export interface Timezone {
abbreviation: string; abbreviation: string;
tzName: string; tzName: string;
} }
export interface CountyData {
state: string;
state_full: string;
counties: string[];
}
export interface CountyRequest {
prefix: string;
states: string[];
}

View File

@ -45,6 +45,10 @@ export class UserController {
this.logger.info(`Found users: ${JSON.stringify(foundUsers)}`); this.logger.info(`Found users: ${JSON.stringify(foundUsers)}`);
return foundUsers; return foundUsers;
} }
@Post('findTotal')
findTotal(@Body() criteria: UserListingCriteria): Promise<number> {
return this.userService.getUserListingsCount(criteria);
}
@Get('states/all') @Get('states/all')
async getStates(): Promise<any[]> { async getStates(): Promise<any[]> {
this.logger.info(`Getting all states for users`); this.logger.info(`Getting all states for users`);

View File

@ -0,0 +1,68 @@
import * as fs from 'fs';
import * as readline from 'readline';
interface CityData {
city: string;
stateShort: string;
stateFull: string;
county: string;
cityAlias: string;
}
interface StateCountyData {
state: string;
state_full: string;
counties: string[];
}
async function parseData(filePath: string): Promise<CityData[]> {
const fileStream = fs.createReadStream(filePath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity,
});
const data: CityData[] = [];
let isFirstLine = true;
for await (const line of rl) {
if (isFirstLine) {
isFirstLine = false;
continue; // Skip the first line
}
const [city, stateShort, stateFull, county, cityAlias] = line.split('|');
data.push({ city, stateShort, stateFull, county, cityAlias });
}
return data;
}
function transformData(data: CityData[]): StateCountyData[] {
const stateMap: { [key: string]: { stateFull: string; counties: Set<string> } } = {};
data.forEach(item => {
if (!stateMap[item.stateShort]) {
stateMap[item.stateShort] = {
stateFull: item.stateFull,
counties: new Set(),
};
}
stateMap[item.stateShort].counties.add(item.county);
});
return Object.entries(stateMap).map(([state, value]) => ({
state,
state_full: value.stateFull,
counties: Array.from(value.counties).sort(),
}));
}
async function main() {
const filePath = './src/assets/counties_raw.csv'; // Ersetze diesen Pfad mit dem Pfad zu deiner Datei
const cityData = await parseData(filePath);
const stateCountyData = transformData(cityData);
console.log(JSON.stringify(stateCountyData, null, 2));
}
main().catch(err => console.error(err));

View File

@ -125,7 +125,7 @@
id="filterDropdownButton" 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 md:me-2" 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 md:me-2"
> >
<i class="fas fa-filter mr-2"></i>Filter (1) <i class="fas fa-filter mr-2"></i>Filter ({{ getNumberOfFiltersSet() }})
</button> </button>
} }
<button <button
@ -242,7 +242,7 @@
id="filterDropdownMobileButton" 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" 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 (1) <i class="fas fa-filter mr-2"></i>Filter ({{ getNumberOfFiltersSet() }})
</button> </button>
</div> </div>
} }

View File

@ -11,10 +11,11 @@ import { filter, Observable, Subject, Subscription } from 'rxjs';
import { User } from '../../../../../bizmatch-server/src/models/db.model'; import { User } from '../../../../../bizmatch-server/src/models/db.model';
import { BusinessListingCriteria, CommercialPropertyListingCriteria, emailToDirName, KeycloakUser, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model'; import { BusinessListingCriteria, CommercialPropertyListingCriteria, emailToDirName, KeycloakUser, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { CriteriaChangeService } from '../../services/criteria-change.service';
import { SearchService } from '../../services/search.service'; import { SearchService } from '../../services/search.service';
import { SharedService } from '../../services/shared.service'; import { SharedService } from '../../services/shared.service';
import { UserService } from '../../services/user.service'; import { UserService } from '../../services/user.service';
import { getCriteriaStateObject, getSessionStorageHandlerWrapper, map2User } from '../../utils/utils'; import { compareObjects, createEmptyBusinessListingCriteria, createEmptyCommercialPropertyListingCriteria, createEmptyUserListingCriteria, getCriteriaStateObject, map2User } from '../../utils/utils';
import { DropdownComponent } from '../dropdown/dropdown.component'; import { DropdownComponent } from '../dropdown/dropdown.component';
import { ModalService } from '../search-modal/modal.service'; import { ModalService } from '../search-modal/modal.service';
@Component({ @Component({
@ -40,6 +41,7 @@ export class HeaderComponent {
private subscription: Subscription; private subscription: Subscription;
criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria; criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
private routerSubscription: Subscription | undefined; private routerSubscription: Subscription | undefined;
baseRoute: string;
constructor( constructor(
public keycloakService: KeycloakService, public keycloakService: KeycloakService,
private router: Router, private router: Router,
@ -48,6 +50,7 @@ export class HeaderComponent {
private breakpointObserver: BreakpointObserver, private breakpointObserver: BreakpointObserver,
private modalService: ModalService, private modalService: ModalService,
private searchService: SearchService, private searchService: SearchService,
private criteriaChangeService: CriteriaChangeService,
) {} ) {}
async ngOnInit() { async ngOnInit() {
@ -75,18 +78,46 @@ export class HeaderComponent {
}); });
} }
private checkCurrentRoute(url: string): void { private checkCurrentRoute(url: string): void {
const baseRoute = url.split('/')[1]; // Nimmt den ersten Teil der Route nach dem ersten '/' this.baseRoute = url.split('/')[1]; // Nimmt den ersten Teil der Route nach dem ersten '/'
const specialRoutes = [, '', '']; const specialRoutes = [, '', ''];
if ('businessListings' === baseRoute) { if ('businessListings' === this.baseRoute) {
this.criteria = onChange(getCriteriaStateObject('business'), getSessionStorageHandlerWrapper('business')); //this.criteria = onChange(getCriteriaStateObject('business'), getSessionStorageHandlerWrapper('business'));
} else if ('commercialPropertyListings' === baseRoute) { //this.criteria = onChange(getCriteriaStateObject('business'), this.getSessionStorageHandler);
this.criteria = onChange(getCriteriaStateObject('commercialProperty'), getSessionStorageHandlerWrapper('commercialProperty')); this.criteria = this.createEnhancedProxy(getCriteriaStateObject('business'));
} else if ('brokerListings' === baseRoute) { } else if ('commercialPropertyListings' === this.baseRoute) {
this.criteria = onChange(getCriteriaStateObject('broker'), getSessionStorageHandlerWrapper('broker')); // this.criteria = onChange(getCriteriaStateObject('commercialProperty'), getSessionStorageHandlerWrapper('commercialProperty'));
this.criteria = this.createEnhancedProxy(getCriteriaStateObject('commercialProperty'));
} else if ('brokerListings' === this.baseRoute) {
// this.criteria = onChange(getCriteriaStateObject('broker'), getSessionStorageHandlerWrapper('broker'));
this.criteria = this.createEnhancedProxy(getCriteriaStateObject('broker'));
} else { } else {
this.criteria = undefined; this.criteria = undefined;
} }
} }
private createEnhancedProxy(obj: any) {
const component = this;
const sessionStorageHandler = function (path, value, previous, applyData) {
let criteriaType = '';
if ('/businessListings' === window.location.pathname) {
criteriaType = 'business';
} else if ('/commercialPropertyListings' === window.location.pathname) {
criteriaType = 'commercialProperty';
} else if ('/brokerListings' === window.location.pathname) {
criteriaType = 'broker';
}
sessionStorage.setItem(`${criteriaType}_criteria`, JSON.stringify(this));
};
return onChange(obj, function (path, value, previous, applyData) {
// Call the original sessionStorageHandler
sessionStorageHandler.call(this, path, value, previous, applyData);
// Notify about the criteria change using the component's context
component.criteriaChangeService.notifyCriteriaChange();
});
}
ngAfterViewInit() {} ngAfterViewInit() {}
async openModal() { async openModal() {
@ -94,13 +125,6 @@ export class HeaderComponent {
if (accepted) { if (accepted) {
this.searchService.search(this.criteria); this.searchService.search(this.criteria);
} }
// if (this.isActive('/businessListings')) {
// this.modalService.showModal(createEmptyBusinessListingCriteria());
// } else if (this.isActive('/commercialPropertyListings')) {
// this.modalService.showModal(createEmptyCommercialPropertyListingCriteria());
// } else if (this.isActive('/brokerListings')) {
// this.modalService.showModal(createEmptyUserListingCriteria());
// }
} }
navigateWithState(dest: string, state: any) { navigateWithState(dest: string, state: any) {
this.router.navigate([dest], { state: state }); this.router.navigate([dest], { state: state });
@ -146,4 +170,15 @@ export class HeaderComponent {
this.destroy$.next(); this.destroy$.next();
this.destroy$.complete(); this.destroy$.complete();
} }
getNumberOfFiltersSet() {
if (this.criteria?.criteriaType === 'broker') {
return compareObjects(createEmptyUserListingCriteria(), this.criteria, ['start', 'length', 'page']);
} else if (this.criteria?.criteriaType === 'business') {
return compareObjects(createEmptyBusinessListingCriteria(), this.criteria, ['start', 'length', 'page']);
} else if (this.criteria?.criteriaType === 'commercialProperty') {
return compareObjects(createEmptyCommercialPropertyListingCriteria(), this.criteria, ['start', 'length', 'page']);
} else {
return 0;
}
}
} }

View File

@ -1,4 +1,3 @@
<!-- <div class="fixed top-0 left-0 right-0 z-50 hidden w-full p-4 overflow-x-hidden overflow-y-auto md:inset-0 h-[calc(100%-1rem)] max-h-full"> -->
<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 *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 w-full max-w-4xl max-h-full">
<div class="relative bg-white rounded-lg shadow"> <div class="relative bg-white rounded-lg shadow">
@ -21,20 +20,12 @@
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label for="state" class="block mb-2 text-sm font-medium text-gray-900">Location - State</label> <label for="state" class="block mb-2 text-sm font-medium text-gray-900">Location - State</label>
<!-- <select id="state" [(ngModel)]="criteria.state" 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">
<option selected>Arkansas</option>
</select> -->
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [(ngModel)]="criteria.state" name="state"> </ng-select> <ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [(ngModel)]="criteria.state" name="state"> </ng-select>
</div> </div>
<div> <div>
<label for="city" class="block mb-2 text-sm font-medium text-gray-900">Location - City</label> <label for="city" class="block mb-2 text-sm font-medium text-gray-900">Location - City</label>
<!-- <input
type="text"
id="city"
[(ngModel)]="criteria.city"
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. Houston"
/> -->
<ng-select <ng-select
class="custom" class="custom"
[multiple]="false" [multiple]="false"
@ -220,13 +211,6 @@
</div> </div>
<div> <div>
<label for="city" class="block mb-2 text-sm font-medium text-gray-900">Location - City</label> <label for="city" class="block mb-2 text-sm font-medium text-gray-900">Location - City</label>
<!-- <input
type="text"
id="city"
[(ngModel)]="criteria.city"
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. Houston"
/> -->
<ng-select <ng-select
class="custom" class="custom"
[multiple]="false" [multiple]="false"
@ -304,19 +288,26 @@
</div> </div>
<div> <div>
<label for="counties" class="block mb-2 text-sm font-medium text-gray-900">Locations served - Counties</label> <label for="counties" class="block mb-2 text-sm font-medium text-gray-900">Locations served - Counties</label>
<select id="counties" [(ngModel)]="criteria.counties" 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"> <ng-select
<option selected>Arkansas</option> [items]="counties$ | async"
</select> bindLabel="name"
class="custom"
[multiple]="true"
[hideSelected]="true"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="countyLoading"
typeToSearchText="Please enter 2 or more characters"
[typeahead]="countyInput$"
[(ngModel)]="criteria.counties"
>
<!-- @for (county of counties$ | async; track county.id) {
<ng-option [value]="city.city">{{ city.city }} - {{ selectOptions.getStateInitials(city.state) }}</ng-option>
} -->
</ng-select>
</div> </div>
<div> <div>
<label for="city" class="block mb-2 text-sm font-medium text-gray-900">Location - City</label> <label for="city" class="block mb-2 text-sm font-medium text-gray-900">Location - City</label>
<!-- <input
type="text"
id="city"
[(ngModel)]="criteria.city"
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. Houston"
/> -->
<ng-select <ng-select
class="custom" class="custom"
[multiple]="false" [multiple]="false"
@ -359,7 +350,7 @@
</div> </div>
<div class="flex items-center p-6 space-x-2 border-t border-gray-200 rounded-b"> <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"> <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 Search ({{ numberOfResults$ | async }})
</button> </button>
<button <button
type="button" type="button"

View File

@ -1,10 +1,13 @@
import { AsyncPipe, NgIf } from '@angular/common'; import { AsyncPipe, NgIf } from '@angular/common';
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { NgSelectModule } from '@ng-select/ng-select'; import { NgSelectModule } from '@ng-select/ng-select';
import { catchError, concat, distinctUntilChanged, Observable, of, Subject, switchMap, tap } from 'rxjs'; import { catchError, concat, debounceTime, distinctUntilChanged, map, Observable, of, Subject, Subscription, switchMap, tap } from 'rxjs';
import { BusinessListingCriteria, CommercialPropertyListingCriteria, GeoResult, KeyValue, KeyValueStyle, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model'; import { BusinessListingCriteria, CommercialPropertyListingCriteria, CountyResult, GeoResult, KeyValue, KeyValueStyle, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
import { CriteriaChangeService } from '../../services/criteria-change.service';
import { GeoService } from '../../services/geo.service'; import { GeoService } from '../../services/geo.service';
import { ListingsService } from '../../services/listings.service';
import { SelectOptionsService } from '../../services/select-options.service'; import { SelectOptionsService } from '../../services/select-options.service';
import { UserService } from '../../services/user.service';
import { SharedModule } from '../../shared/shared/shared.module'; import { SharedModule } from '../../shared/shared/shared.module';
import { ModalService } from './modal.service'; import { ModalService } from './modal.service';
@ -17,17 +20,33 @@ import { ModalService } from './modal.service';
}) })
export class SearchModalComponent { export class SearchModalComponent {
cities$: Observable<GeoResult[]>; cities$: Observable<GeoResult[]>;
counties$: Observable<CountyResult[]>;
cityLoading = false; cityLoading = false;
countyLoading = false;
cityInput$ = new Subject<string>(); cityInput$ = new Subject<string>();
constructor(public selectOptions: SelectOptionsService, public modalService: ModalService, private geoService: GeoService) {} countyInput$ = new Subject<string>();
private criteriaChangeSubscription: Subscription;
public criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
numberOfResults$: Observable<number>;
constructor(
public selectOptions: SelectOptionsService,
public modalService: ModalService,
private geoService: GeoService,
private criteriaChangeService: CriteriaChangeService,
private listingService: ListingsService,
private userService: UserService,
) {}
ngOnInit() { ngOnInit() {
this.setupCriteriaChangeListener();
this.modalService.message$.subscribe(msg => { this.modalService.message$.subscribe(msg => {
this.criteria = msg; this.criteria = msg;
this.setTotalNumberOfResults();
}); });
this.loadCities(); this.loadCities();
this.loadCounties();
} }
public criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
ngOnChanges() {}
categoryClicked(checked: boolean, value: string) { categoryClicked(checked: boolean, value: string) {
if (checked) { if (checked) {
this.criteria.types.push(value); this.criteria.types.push(value);
@ -54,13 +73,34 @@ export class SearchModalComponent {
), ),
); );
} }
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)),
),
),
),
);
}
private setupCriteriaChangeListener() {
this.criteriaChangeSubscription = this.criteriaChangeService.criteriaChange$.pipe(debounceTime(400)).subscribe(() => this.setTotalNumberOfResults());
}
trackByFn(item: GeoResult) { trackByFn(item: GeoResult) {
return item.id; return item.id;
} }
search() { search() {
console.log('Search criteria:', this.criteria); console.log('Search criteria:', this.criteria);
} }
getCounties() {
this.geoService.findCountiesStartingWith('');
}
closeModal() { closeModal() {
console.log('Closing modal'); console.log('Closing modal');
} }
@ -70,4 +110,16 @@ export class SearchModalComponent {
isTypeOfProfessionalClicked(v: KeyValue) { isTypeOfProfessionalClicked(v: KeyValue) {
return this.criteria.types.find(t => t === v.value); 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 === 'business' || this.criteria.criteriaType === 'commercialProperty') {
this.numberOfResults$ = this.listingService.getNumberOfListings(this.criteria, this.criteria.criteriaType);
} else if (this.criteria.criteriaType === 'broker') {
this.numberOfResults$ = this.userService.getNumberOfBroker(this.criteria);
} else {
this.numberOfResults$ = of();
}
}
}
} }

View File

@ -95,3 +95,6 @@
} }
</div> </div>
</div> </div>
@if(pageCount>1){
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator>
}

View File

@ -3,8 +3,9 @@ import { ChangeDetectorRef, Component } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { BusinessListing, User } from '../../../../../../bizmatch-server/src/models/db.model'; import { BusinessListing, User } from '../../../../../../bizmatch-server/src/models/db.model';
import { ListingType, UserListingCriteria, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model'; import { LISTINGS_PER_PAGE, ListingType, UserListingCriteria, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
import { PaginatorComponent } from '../../../components/paginator/paginator.component';
import { ImageService } from '../../../services/image.service'; import { ImageService } from '../../../services/image.service';
import { ListingsService } from '../../../services/listings.service'; import { ListingsService } from '../../../services/listings.service';
import { SearchService } from '../../../services/search.service'; import { SearchService } from '../../../services/search.service';
@ -15,7 +16,7 @@ import { getCriteriaStateObject } from '../../../utils/utils';
@Component({ @Component({
selector: 'app-broker-listings', selector: 'app-broker-listings',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, RouterModule, NgOptimizedImage], imports: [CommonModule, FormsModule, RouterModule, NgOptimizedImage, PaginatorComponent],
templateUrl: './broker-listings.component.html', templateUrl: './broker-listings.component.html',
styleUrls: ['./broker-listings.component.scss', '../../pages.scss'], styleUrls: ['./broker-listings.component.scss', '../../pages.scss'],
}) })
@ -39,6 +40,8 @@ export class BrokerListingsComponent {
env = environment; env = environment;
public category: 'business' | 'commercialProperty' | 'professionals_brokers' | undefined; public category: 'business' | 'commercialProperty' | 'professionals_brokers' | undefined;
emailToDirName = emailToDirName; emailToDirName = emailToDirName;
page = 1;
pageCount = 1;
constructor( constructor(
public selectOptions: SelectOptionsService, public selectOptions: SelectOptionsService,
private listingsService: ListingsService, private listingsService: ListingsService,
@ -51,14 +54,6 @@ export class BrokerListingsComponent {
private searchService: SearchService, private searchService: SearchService,
) { ) {
this.criteria = getCriteriaStateObject('broker'); this.criteria = getCriteriaStateObject('broker');
// this.route.data.subscribe(async () => {
// if (this.router.getCurrentNavigation().extras.state) {
// } else {
// this.first = this.criteria.page * this.criteria.length;
// this.rows = this.criteria.length;
// }
// this.init();
// });
this.init(); this.init();
this.searchService.currentCriteria.subscribe(criteria => { this.searchService.currentCriteria.subscribe(criteria => {
if (criteria && criteria.criteriaType === 'broker') { if (criteria && criteria.criteriaType === 'broker') {
@ -74,24 +69,20 @@ export class BrokerListingsComponent {
async init() { async init() {
this.search(); this.search();
} }
refine() {
this.criteria.start = 0;
this.criteria.page = 0;
this.search();
}
async search() { async search() {
const usersReponse = await this.userService.search(this.criteria); const usersReponse = await this.userService.search(this.criteria);
this.users = usersReponse.results; this.users = usersReponse.results;
this.totalRecords = usersReponse.totalCount; this.totalRecords = usersReponse.totalCount;
this.pageCount = this.totalRecords % LISTINGS_PER_PAGE === 0 ? this.totalRecords / LISTINGS_PER_PAGE : Math.floor(this.totalRecords / LISTINGS_PER_PAGE) + 1;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.cdRef.detectChanges(); this.cdRef.detectChanges();
} }
onPageChange(event: any) { onPageChange(page: any) {
this.criteria.start = event.first; this.criteria.start = (page - 1) * LISTINGS_PER_PAGE;
this.criteria.length = event.rows; this.criteria.length = LISTINGS_PER_PAGE;
this.criteria.page = event.page; this.criteria.page = page;
this.criteria.pageCount = event.pageCount;
this.search(); this.search();
} }
reset() {} reset() {}
} }

View File

@ -112,7 +112,9 @@
} }
</div> </div>
</div> </div>
@if(pageCount>1){
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator> <app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator>
}
<!-- <div class="container mx-auto px-4 py-8"> <!-- <div class="container mx-auto px-4 py-8">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">

View File

@ -50,14 +50,6 @@ export class BusinessListingsComponent {
private searchService: SearchService, private searchService: SearchService,
) { ) {
this.criteria = getCriteriaStateObject('business'); this.criteria = getCriteriaStateObject('business');
// this.route.data.subscribe(async () => {
// if (this.router.getCurrentNavigation().extras.state) {
// } else {
// this.first = this.criteria.page * this.criteria.length;
// this.rows = this.criteria.length;
// }
// this.init();
// });
this.init(); this.init();
this.searchService.currentCriteria.subscribe(criteria => { this.searchService.currentCriteria.subscribe(criteria => {
if (criteria && criteria.criteriaType === 'business') { if (criteria && criteria.criteriaType === 'business') {
@ -66,20 +58,14 @@ export class BusinessListingsComponent {
} }
}); });
} }
async ngOnInit() { async ngOnInit() {}
//initFlowbite();
}
async init() { async init() {
this.reset(); this.reset();
const statesResult = await this.listingsService.getAllStates('business'); const statesResult = await this.listingsService.getAllStates('business');
this.states = statesResult.map(ls => ({ name: this.selectOptions.getState(ls.state as string), value: ls.state, count: ls.count })); this.states = statesResult.map(ls => ({ name: this.selectOptions.getState(ls.state as string), value: ls.state, count: ls.count }));
this.search(); this.search();
} }
// refine() {
// this.criteria.start = 0;
// this.criteria.page = 0;
// this.search();
// }
async search() { async search() {
//this.listings = await this.listingsService.getListingsByPrompt(this.criteria); //this.listings = await this.listingsService.getListingsByPrompt(this.criteria);
const listingReponse = await this.listingsService.getListings(this.criteria, 'business'); const listingReponse = await this.listingsService.getListings(this.criteria, 'business');
@ -90,10 +76,9 @@ export class BusinessListingsComponent {
this.cdRef.detectChanges(); this.cdRef.detectChanges();
} }
onPageChange(page: any) { onPageChange(page: any) {
this.criteria.start = (page - 1) * LISTINGS_PER_PAGE + 1; this.criteria.start = (page - 1) * LISTINGS_PER_PAGE;
this.criteria.length = LISTINGS_PER_PAGE; this.criteria.length = LISTINGS_PER_PAGE;
this.criteria.page = page; this.criteria.page = page;
// this.criteria.pageCount = event.pageCount;
this.search(); this.search();
} }
imageErrorHandler(listing: ListingType) { imageErrorHandler(listing: ListingType) {

View File

@ -78,3 +78,6 @@
} }
</div> </div>
</div> </div>
@if(pageCount>1){
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator>
}

View File

@ -3,8 +3,9 @@ import { ChangeDetectorRef, Component } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { CommercialPropertyListing } from '../../../../../../bizmatch-server/src/models/db.model'; import { CommercialPropertyListing } from '../../../../../../bizmatch-server/src/models/db.model';
import { CommercialPropertyListingCriteria, ResponseCommercialPropertyListingArray } from '../../../../../../bizmatch-server/src/models/main.model'; import { CommercialPropertyListingCriteria, LISTINGS_PER_PAGE, ResponseCommercialPropertyListingArray } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
import { PaginatorComponent } from '../../../components/paginator/paginator.component';
import { ImageService } from '../../../services/image.service'; import { ImageService } from '../../../services/image.service';
import { ListingsService } from '../../../services/listings.service'; import { ListingsService } from '../../../services/listings.service';
import { SearchService } from '../../../services/search.service'; import { SearchService } from '../../../services/search.service';
@ -14,7 +15,7 @@ import { getCriteriaStateObject } from '../../../utils/utils';
@Component({ @Component({
selector: 'app-commercial-property-listings', selector: 'app-commercial-property-listings',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, RouterModule], imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent],
templateUrl: './commercial-property-listings.component.html', templateUrl: './commercial-property-listings.component.html',
styleUrls: ['./commercial-property-listings.component.scss', '../../pages.scss'], styleUrls: ['./commercial-property-listings.component.scss', '../../pages.scss'],
}) })
@ -35,6 +36,8 @@ export class CommercialPropertyListingsComponent {
totalRecords: number = 0; totalRecords: number = 0;
ts = new Date().getTime(); ts = new Date().getTime();
env = environment; env = environment;
page = 1;
pageCount = 1;
constructor( constructor(
public selectOptions: SelectOptionsService, public selectOptions: SelectOptionsService,
private listingsService: ListingsService, private listingsService: ListingsService,
@ -46,14 +49,6 @@ export class CommercialPropertyListingsComponent {
private searchService: SearchService, private searchService: SearchService,
) { ) {
this.criteria = getCriteriaStateObject('commercialProperty'); this.criteria = getCriteriaStateObject('commercialProperty');
// this.route.data.subscribe(async () => {
// if (this.router.getCurrentNavigation().extras.state) {
// } else {
// this.first = this.criteria.page * this.criteria.length;
// this.rows = this.criteria.length;
// }
// this.init();
// });
this.init(); this.init();
this.searchService.currentCriteria.subscribe(criteria => { this.searchService.currentCriteria.subscribe(criteria => {
if (criteria && criteria.criteriaType === 'commercialProperty') { if (criteria && criteria.criteriaType === 'commercialProperty') {
@ -77,14 +72,14 @@ export class CommercialPropertyListingsComponent {
const listingReponse = await this.listingsService.getListings(this.criteria, 'commercialProperty'); const listingReponse = await this.listingsService.getListings(this.criteria, 'commercialProperty');
this.listings = (<ResponseCommercialPropertyListingArray>listingReponse).results; this.listings = (<ResponseCommercialPropertyListingArray>listingReponse).results;
this.totalRecords = (<ResponseCommercialPropertyListingArray>listingReponse).totalCount; this.totalRecords = (<ResponseCommercialPropertyListingArray>listingReponse).totalCount;
this.pageCount = this.totalRecords % LISTINGS_PER_PAGE === 0 ? this.totalRecords / LISTINGS_PER_PAGE : Math.floor(this.totalRecords / LISTINGS_PER_PAGE) + 1;
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.cdRef.detectChanges(); this.cdRef.detectChanges();
} }
onPageChange(event: any) { onPageChange(page: any) {
this.criteria.start = event.first; this.criteria.start = (page - 1) * LISTINGS_PER_PAGE;
this.criteria.length = event.rows; this.criteria.length = LISTINGS_PER_PAGE;
this.criteria.page = event.page; this.criteria.page = page;
this.criteria.pageCount = event.pageCount;
this.search(); this.search();
} }
reset() { reset() {

View File

@ -0,0 +1,15 @@
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class CriteriaChangeService {
private criteriaChangeSubject = new Subject<void>();
criteriaChange$ = this.criteriaChangeSubject.asObservable();
notifyCriteriaChange() {
this.criteriaChangeSubject.next();
}
}

View File

@ -1,7 +1,7 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { GeoResult } from '../../../../bizmatch-server/src/models/main.model'; import { CountyResult, GeoResult } from '../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
@Injectable({ @Injectable({
@ -15,4 +15,7 @@ export class GeoService {
const stateString = state ? `/${state}` : ''; const stateString = state ? `/${state}` : '';
return this.http.get<GeoResult[]>(`${this.apiBaseUrl}/bizmatch/geo/${prefix}${stateString}`); return this.http.get<GeoResult[]>(`${this.apiBaseUrl}/bizmatch/geo/${prefix}${stateString}`);
} }
findCountiesStartingWith(prefix: string, states?: string[]): Observable<CountyResult[]> {
return this.http.post<CountyResult[]>(`${this.apiBaseUrl}/bizmatch/geo/counties`, { prefix, states });
}
} }

View File

@ -26,6 +26,9 @@ export class ListingsService {
const result = await lastValueFrom(this.http.post<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/find`, criteria)); const result = await lastValueFrom(this.http.post<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/find`, criteria));
return result; return result;
} }
getNumberOfListings(criteria: BusinessListingCriteria | CommercialPropertyListingCriteria, listingsCategory: 'business' | 'commercialProperty'): Observable<number> {
return this.http.post<number>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/findTotal`, criteria);
}
async getListingsByPrompt(criteria: BusinessListingCriteria | CommercialPropertyListingCriteria): Promise<BusinessListing[]> { async getListingsByPrompt(criteria: BusinessListingCriteria | CommercialPropertyListingCriteria): Promise<BusinessListing[]> {
const result = await lastValueFrom(this.http.post<BusinessListing[]>(`${this.apiBaseUrl}/bizmatch/listings/business/search`, criteria)); const result = await lastValueFrom(this.http.post<BusinessListing[]>(`${this.apiBaseUrl}/bizmatch/listings/business/search`, criteria));
return result; return result;

View File

@ -1,6 +1,6 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { lastValueFrom } from 'rxjs'; import { lastValueFrom, Observable } from 'rxjs';
import urlcat from 'urlcat'; import urlcat from 'urlcat';
import { User } from '../../../../bizmatch-server/src/models/db.model'; import { User } from '../../../../bizmatch-server/src/models/db.model';
import { ResponseUsersArray, StatesResult, UserListingCriteria } from '../../../../bizmatch-server/src/models/main.model'; import { ResponseUsersArray, StatesResult, UserListingCriteria } from '../../../../bizmatch-server/src/models/main.model';
@ -30,6 +30,9 @@ export class UserService {
async search(criteria?: UserListingCriteria): Promise<ResponseUsersArray> { async search(criteria?: UserListingCriteria): Promise<ResponseUsersArray> {
return await lastValueFrom(this.http.post<ResponseUsersArray>(`${this.apiBaseUrl}/bizmatch/user/search`, criteria)); return await lastValueFrom(this.http.post<ResponseUsersArray>(`${this.apiBaseUrl}/bizmatch/user/search`, criteria));
} }
getNumberOfBroker(criteria?: UserListingCriteria): Observable<number> {
return this.http.post<number>(`${this.apiBaseUrl}/bizmatch/user/findTotal`, criteria);
}
async getAllStates(): Promise<any> { async getAllStates(): Promise<any> {
return await lastValueFrom(this.http.get<StatesResult[]>(`${this.apiBaseUrl}/bizmatch/user/states/all`)); return await lastValueFrom(this.http.get<StatesResult[]>(`${this.apiBaseUrl}/bizmatch/user/states/all`));
} }

View File

@ -87,7 +87,6 @@ export function createEmptyBusinessListingCriteria(): BusinessListingCriteria {
start: 0, start: 0,
length: 0, length: 0,
page: 0, page: 0,
pageCount: 0,
state: '', state: '',
city: '', city: '',
types: [], types: [],
@ -117,7 +116,6 @@ export function createEmptyCommercialPropertyListingCriteria(): CommercialProper
start: 0, start: 0,
length: 0, length: 0,
page: 0, page: 0,
pageCount: 0,
state: '', state: '',
city: '', city: '',
types: [], types: [],
@ -135,7 +133,6 @@ export function createEmptyUserListingCriteria(): UserListingCriteria {
start: 0, start: 0,
length: 0, length: 0,
page: 0, page: 0,
pageCount: 0,
city: '', city: '',
types: [], types: [],
prompt: '', prompt: '',
@ -288,3 +285,78 @@ export function initFlowbiteFix() {
} }
}); });
} }
// export function arraysEqual(arr1: any[], arr2: any[]): boolean {
// if (arr1.length !== arr2.length) return false;
// for (let i = 0; i < arr1.length; i++) {
// if (arr1[i] !== arr2[i]) return false;
// }
// return true;
// }
export function compareObjects<T extends object>(obj1: T, obj2: T, ignoreProperties: (keyof T)[] = []): number {
let differences = 0;
const keys = Object.keys(obj1) as Array<keyof T>;
for (const key of keys) {
if (ignoreProperties.includes(key)) {
continue; // Überspringe diese Eigenschaft, wenn sie in der Ignore-Liste ist
}
const value1 = obj1[key];
const value2 = obj2[key];
if (!areValuesEqual(value1, value2)) {
differences++;
}
}
return differences;
}
function areValuesEqual(value1: any, value2: any): boolean {
if (Array.isArray(value1) || Array.isArray(value2)) {
return arraysEqual(value1, value2);
}
if (typeof value1 === 'string' || typeof value2 === 'string') {
return isEqualString(value1, value2);
}
if (typeof value1 === 'number' || typeof value2 === 'number') {
return isEqualNumber(value1, value2);
}
if (typeof value1 === 'boolean' || typeof value2 === 'boolean') {
return isEqualBoolean(value1, value2);
}
return value1 === value2;
}
function isEqualString(value1: any, value2: any): boolean {
const isEmptyOrNullish1 = value1 === undefined || value1 === null || value1 === '';
const isEmptyOrNullish2 = value2 === undefined || value2 === null || value2 === '';
return (isEmptyOrNullish1 && isEmptyOrNullish2) || value1 === value2;
}
function isEqualNumber(value1: any, value2: any): boolean {
const isZeroOrNullish1 = value1 === undefined || value1 === null || value1 === 0;
const isZeroOrNullish2 = value2 === undefined || value2 === null || value2 === 0;
return (isZeroOrNullish1 && isZeroOrNullish2) || value1 === value2;
}
function isEqualBoolean(value1: any, value2: any): boolean {
const isFalseOrNullish1 = value1 === undefined || value1 === null || value1 === false;
const isFalseOrNullish2 = value2 === undefined || value2 === null || value2 === false;
return (isFalseOrNullish1 && isFalseOrNullish2) || value1 === value2;
}
function arraysEqual(arr1: any[] | null | undefined, arr2: any[] | null | undefined): boolean {
if (arr1 === arr2) return true;
if (arr1 == null || arr2 == null) return false;
if (arr1.length !== arr2.length) return false;
for (let i = 0; i < arr1.length; i++) {
if (!areValuesEqual(arr1[i], arr2[i])) return false;
}
return true;
}