landing page finished

This commit is contained in:
Andreas Knuth 2024-07-29 21:23:26 +02:00
parent 6348af8862
commit 55e800009e
8 changed files with 164 additions and 54 deletions

View File

@ -10,6 +10,10 @@ export class GeoController {
findByPrefix(@Param('prefix') prefix: string): any { findByPrefix(@Param('prefix') prefix: string): any {
return this.geoService.findCitiesStartingWith(prefix); return this.geoService.findCitiesStartingWith(prefix);
} }
@Get('citiesandstates/:prefix')
findByCitiesAndStatesByPrefix(@Param('prefix') prefix: string): any {
return this.geoService.findCitiesAndStatesStartingWith(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 {

View File

@ -61,6 +61,43 @@ export class GeoService {
}); });
return state ? result.filter(e => e.state_code.toLowerCase() === state.toLowerCase()) : result; return state ? result.filter(e => e.state_code.toLowerCase() === state.toLowerCase()) : result;
} }
findCitiesAndStatesStartingWith(prefix: string, state?: string): Array<{ id: string; name: string; type: 'city' | 'state'; state_code: string }> {
const results: Array<{ id: string; name: string; type: 'city' | 'state'; state_code: string }> = [];
const lowercasePrefix = prefix.toLowerCase();
//for (const country of this.geo) {
// Suche nach passenden Staaten
for (const state of this.geo.states) {
if (state.name.toLowerCase().startsWith(lowercasePrefix)) {
results.push({
id: state.id.toString(),
name: state.name,
type: 'state',
state_code: state.state_code,
});
}
// Suche nach passenden Städten
for (const city of state.cities) {
if (city.name.toLowerCase().startsWith(lowercasePrefix)) {
results.push({
id: city.id.toString(),
name: city.name,
type: 'city',
state_code: state.state_code,
});
}
}
//}
}
return results.sort((a, b) => {
if (a.type === 'state' && b.type === 'city') return -1;
if (a.type === 'city' && b.type === 'state') return 1;
return a.name.localeCompare(b.name);
});
}
getCityWithCoords(state: string, city: string): City { getCityWithCoords(state: string, city: string): City {
return this.geo.states.find(s => s.state_code === state).cities.find(c => c.name === city); return this.geo.states.find(s => s.state_code === state).cities.find(c => c.name === city);
} }

View File

@ -230,6 +230,12 @@ export interface GeoResult {
state: string; state: string;
state_code: string; state_code: string;
} }
export interface CityAndStateResult {
id: number;
name: string;
type: string;
state_code: string;
}
export interface CountyResult { export interface CountyResult {
id: number; id: number;
name: string; name: string;

View File

@ -30,6 +30,7 @@
"@fortawesome/free-regular-svg-icons": "^6.5.2", "@fortawesome/free-regular-svg-icons": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2", "@fortawesome/free-solid-svg-icons": "^6.5.2",
"@ng-select/ng-select": "^13.4.1", "@ng-select/ng-select": "^13.4.1",
"@ngneat/until-destroy": "^10.0.0",
"@types/cropperjs": "^1.3.0", "@types/cropperjs": "^1.3.0",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"browser-bunyan": "^1.8.0", "browser-bunyan": "^1.8.0",
@ -69,4 +70,4 @@
"tailwindcss": "^3.4.4", "tailwindcss": "^3.4.4",
"typescript": "~5.4.5" "typescript": "~5.4.5"
} }
} }

View File

@ -77,18 +77,18 @@
</ul> </ul>
</div> </div>
@if(criteria){ @if(criteria){
<div class="w-full max-w-3xl mx-auto bg-white rounded-lg flex flex-col md:flex-row border border-gray-300"> <div class="w-full max-w-3xl mx-auto bg-white rounded-lg flex flex-col md:flex-row md:border md:border-gray-300">
<div class="md:flex-none md:w-40 flex-1 border-r border-gray-300 overflow-hidden"> <div class="md:flex-none md:w-48 flex-1 md:border-r border-gray-300 overflow-hidden mb-2 md:mb-0">
<div class="relative"> <div class="relative max-sm:border border-gray-300 rounded-md">
<select <select
class="appearance-none bg-transparent w-full py-3 px-4 pr-8 focus:outline-none" class="appearance-none bg-transparent w-full py-3 px-4 pr-8 focus:outline-none md:border-none rounded-md md:rounded-none"
[ngModel]="criteria.types" [ngModel]="criteria.types"
(ngModelChange)="onTypesChange($event)" (ngModelChange)="onTypesChange($event)"
[ngClass]="{ 'placeholder-selected': criteria.types.length === 0 }" [ngClass]="{ 'placeholder-selected': criteria.types.length === 0 }"
> >
<option [value]="[]">Business Type</option> <option [value]="[]">{{ getPlaceholderLabel() }}</option>
@for(tob of selectOptions.typesOfBusiness; track tob){ @for(type of getTypes(); track type){
<option [value]="tob.value">{{ tob.name }}</option> <option [value]="type.value">{{ type.name }}</option>
} }
</select> </select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700"> <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700">
@ -96,29 +96,34 @@
</div> </div>
</div> </div>
</div> </div>
<div class="md:flex-auto md:w-36 lex-grow border-b md:border-b-0 md:border-r border-gray-300">
<ng-select <div class="md:flex-auto md:w-36 flex-grow md:border-r border-gray-300 mb-2 md:mb-0">
class="custom" <div class="relative max-sm:border border-gray-300 rounded-md">
[multiple]="false" <ng-select
[hideSelected]="true" class="custom md:border-none rounded-md md:rounded-none"
[trackByFn]="trackByFn" [multiple]="false"
[minTermLength]="2" [hideSelected]="true"
[loading]="cityLoading" [trackByFn]="trackByFn"
typeToSearchText="Please enter 2 or more characters" [minTermLength]="2"
[typeahead]="cityInput$" [loading]="cityLoading"
[ngModel]="cityOrState" typeToSearchText="Please enter 2 or more characters"
(ngModelChange)="setCity($event)" [typeahead]="cityInput$"
placeholder="Enter City or State ..." [ngModel]="cityOrState"
> (ngModelChange)="setCityOrState($event)"
@for (city of cities$ | async; track city.id) { placeholder="Enter City or State ..."
<ng-option [value]="city">{{ city.city }} - {{ selectOptions.getStateInitials(city.state) }}</ng-option> groupBy="type"
} >
</ng-select> @for (city of cities$ | async; track city.id) {
<ng-option [value]="city">{{ city.name }} - {{ city.state_code }}</ng-option>
}
</ng-select>
</div>
</div> </div>
<div class="md:flex-none md:w-36 flex-1 border-b md:border-b-0 md:border-r border-gray-300"> @if (criteria.radius){
<div class="relative"> <div class="md:flex-none md:w-36 flex-1 md:border-r border-gray-300 mb-2 md:mb-0">
<div class="relative max-sm:border border-gray-300 rounded-md">
<select <select
class="appearance-none bg-transparent w-full py-3 px-4 pr-8 focus:outline-none" class="appearance-none bg-transparent w-full py-3 px-4 pr-8 focus:outline-none md:border-none rounded-md md:rounded-none"
(ngModelChange)="onRadiusChange($event)" (ngModelChange)="onRadiusChange($event)"
[ngModel]="criteria.radius" [ngModel]="criteria.radius"
[ngClass]="{ 'placeholder-selected': !criteria.radius }" [ngClass]="{ 'placeholder-selected': !criteria.radius }"
@ -133,9 +138,13 @@
</div> </div>
</div> </div>
</div> </div>
}
<div class="bg-blue-600 hover:bg-blue-500 transition-colors duration-200"> <div class="bg-blue-600 hover:bg-blue-500 transition-colors duration-200 max-sm:rounded-md">
<button class="w-full h-full text-white font-semibold py-3 px-6 focus:outline-none" (click)="search()">Suchen</button> @if(getNumberOfFiltersSet()>0 && numberOfResults$){
<button class="w-full h-full text-white font-semibold py-3 px-6 focus:outline-none rounded-md md:rounded-none" (click)="search()">Search ({{ numberOfResults$ | async }})</button>
}@else {
<button class="w-full h-full text-white font-semibold py-3 px-6 focus:outline-none rounded-md md:rounded-none" (click)="search()">Search</button>
}
</div> </div>
</div> </div>
} }

View File

@ -35,7 +35,8 @@ select {
} }
:host ::ng-deep .ng-select.ng-select-single .ng-select-container { :host ::ng-deep .ng-select.ng-select-single .ng-select-container {
height: 48px; height: 48px;
border: unset; border: none;
background-color: transparent;
.ng-value-container .ng-input { .ng-value-container .ng-input {
top: 10px; top: 10px;
} }
@ -43,13 +44,6 @@ select {
display: none; display: none;
} }
} }
.flex-1-1-2 {
flex: 1 1 2%;
}
// .light {
// color: #999;
// }
// component.css
select { select {
color: #000; /* Standard-Textfarbe für das Dropdown */ color: #000; /* Standard-Textfarbe für das Dropdown */
// background-color: #fff; /* Hintergrundfarbe für das Dropdown */ // background-color: #fff; /* Hintergrundfarbe für das Dropdown */

View File

@ -3,17 +3,20 @@ 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 { NgSelectModule } from '@ng-select/ng-select'; import { NgSelectModule } from '@ng-select/ng-select';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { KeycloakService } from 'keycloak-angular'; import { KeycloakService } from 'keycloak-angular';
import onChange from 'on-change'; import onChange from 'on-change';
import { catchError, concat, distinctUntilChanged, Observable, of, Subject, switchMap, tap } from 'rxjs'; import { catchError, concat, debounceTime, distinctUntilChanged, Observable, of, Subject, Subscription, switchMap, tap } from 'rxjs';
import { BusinessListingCriteria, CommercialPropertyListingCriteria, GeoResult, KeycloakUser, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model'; import { BusinessListingCriteria, CityAndStateResult, CommercialPropertyListingCriteria, GeoResult, KeycloakUser, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
import { ModalService } from '../../components/search-modal/modal.service'; import { ModalService } from '../../components/search-modal/modal.service';
import { CriteriaChangeService } from '../../services/criteria-change.service'; 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 { ListingsService } from '../../services/listings.service';
import { SearchService } from '../../services/search.service'; import { SearchService } from '../../services/search.service';
import { SelectOptionsService } from '../../services/select-options.service'; import { SelectOptionsService } from '../../services/select-options.service';
import { getCriteriaStateObject, map2User } from '../../utils/utils'; import { UserService } from '../../services/user.service';
import { compareObjects, createEmptyBusinessListingCriteria, createEmptyCommercialPropertyListingCriteria, createEmptyUserListingCriteria, getCriteriaStateObject, map2User } from '../../utils/utils';
@UntilDestroy()
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
standalone: true, standalone: true,
@ -31,10 +34,12 @@ export class HomeComponent {
isMenuOpen = false; isMenuOpen = false;
user: KeycloakUser; user: KeycloakUser;
prompt: string; prompt: string;
cities$: Observable<GeoResult[]>; cities$: Observable<CityAndStateResult[]>;
cityLoading = false; cityLoading = false;
cityInput$ = new Subject<string>(); cityInput$ = new Subject<string>();
cityOrState = undefined; cityOrState = undefined;
private criteriaChangeSubscription: Subscription;
numberOfResults$: Observable<number>;
public constructor( public constructor(
private router: Router, private router: Router,
private modalService: ModalService, private modalService: ModalService,
@ -42,10 +47,11 @@ export class HomeComponent {
private activatedRoute: ActivatedRoute, private activatedRoute: ActivatedRoute,
public selectOptions: SelectOptionsService, public selectOptions: SelectOptionsService,
public keycloakService: KeycloakService, public keycloakService: KeycloakService,
private listingsService: ListingsService,
private criteriaChangeService: CriteriaChangeService, private criteriaChangeService: CriteriaChangeService,
private geoService: GeoService, private geoService: GeoService,
public cdRef: ChangeDetectorRef, public cdRef: ChangeDetectorRef,
private listingService: ListingsService,
private userService: UserService,
) {} ) {}
async ngOnInit() { async ngOnInit() {
const token = await this.keycloakService.getToken(); const token = await this.keycloakService.getToken();
@ -55,6 +61,7 @@ export class HomeComponent {
this.criteria = this.createEnhancedProxy(getCriteriaStateObject('business')); this.criteria = this.createEnhancedProxy(getCriteriaStateObject('business'));
this.user = map2User(token); this.user = map2User(token);
this.loadCities(); this.loadCities();
this.setupCriteriaChangeListener();
} }
async changeTab(tabname: 'business' | 'commercialProperty' | 'broker') { async changeTab(tabname: 'business' | 'commercialProperty' | 'broker') {
this.activeTabAction = tabname; this.activeTabAction = tabname;
@ -85,10 +92,11 @@ export class HomeComponent {
}); });
} }
search() { search() {
const data = { keep: true };
this.router.navigate([`${this.activeTabAction}Listings`]); this.router.navigate([`${this.activeTabAction}Listings`]);
} }
private setupCriteriaChangeListener() {
this.criteriaChangeSubscription = this.criteriaChangeService.criteriaChange$.pipe(untilDestroyed(this), debounceTime(400)).subscribe(() => this.setTotalNumberOfResults());
}
login() { login() {
this.keycloakService.login({ this.keycloakService.login({
redirectUri: window.location.href, redirectUri: window.location.href,
@ -113,7 +121,7 @@ export class HomeComponent {
// Wenn keine Option ausgewählt ist, setzen Sie types zurück auf ein leeres Array // Wenn keine Option ausgewählt ist, setzen Sie types zurück auf ein leeres Array
this.criteria.radius = null; this.criteria.radius = null;
} else { } else {
this.criteria.radius = value; this.criteria.radius = parseInt(value);
} }
} }
async openModal() { async openModal() {
@ -130,7 +138,8 @@ export class HomeComponent {
distinctUntilChanged(), distinctUntilChanged(),
tap(() => (this.cityLoading = true)), tap(() => (this.cityLoading = true)),
switchMap(term => switchMap(term =>
this.geoService.findCitiesStartingWith(term).pipe( //this.geoService.findCitiesStartingWith(term).pipe(
this.geoService.findCitiesAndStatesStartingWith(term).pipe(
catchError(() => of([])), // empty list on error catchError(() => of([])), // empty list on error
// map(cities => cities.map(city => city.city)), // transform the list of objects to a list of city names // map(cities => cities.map(city => city.city)), // transform the list of objects to a list of city names
tap(() => (this.cityLoading = false)), tap(() => (this.cityLoading = false)),
@ -142,14 +151,61 @@ export class HomeComponent {
trackByFn(item: GeoResult) { trackByFn(item: GeoResult) {
return item.id; return item.id;
} }
setCity(city) { setCityOrState(cityOrState: CityAndStateResult) {
if (city) { if (cityOrState) {
this.criteria.city = city.city; if (cityOrState.type === 'state') {
this.criteria.state = city.state_code; this.criteria.state = cityOrState.state_code;
} else {
this.criteria.city = cityOrState.name;
this.criteria.state = cityOrState.state_code;
this.criteria.searchType = 'radius';
this.criteria.radius = 20;
}
} else { } else {
this.criteria.city = null; this.criteria.city = null;
this.criteria.radius = null; this.criteria.radius = null;
this.criteria.searchType = 'exact'; this.criteria.searchType = 'exact';
} }
} }
getTypes() {
if (this.criteria.criteriaType === 'business') {
return this.selectOptions.typesOfBusiness;
} else if (this.criteria.criteriaType === 'commercialProperty') {
return this.selectOptions.typesOfCommercialProperty;
} else {
return this.selectOptions.customerSubTypes;
}
}
getPlaceholderLabel() {
if (this.criteria.criteriaType === 'business') {
return 'Business Type';
} else if (this.criteria.criteriaType === 'commercialProperty') {
return 'Property Type';
} else {
return 'Professional Type';
}
}
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();
}
}
}
getNumberOfFiltersSet() {
if (this.criteria?.criteriaType === 'broker') {
return compareObjects(createEmptyUserListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius']);
} else if (this.criteria?.criteriaType === 'business') {
return compareObjects(createEmptyBusinessListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius']);
} else if (this.criteria?.criteriaType === 'commercialProperty') {
return compareObjects(createEmptyCommercialPropertyListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius']);
} else {
return 0;
}
}
} }

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 { CountyResult, GeoResult } from '../../../../bizmatch-server/src/models/main.model'; import { CityAndStateResult, CountyResult, GeoResult } from '../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
@Injectable({ @Injectable({
@ -15,6 +15,9 @@ 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}`);
} }
findCitiesAndStatesStartingWith(prefix: string): Observable<CityAndStateResult[]> {
return this.http.get<CityAndStateResult[]>(`${this.apiBaseUrl}/bizmatch/geo/citiesandstates/${prefix}`);
}
findCountiesStartingWith(prefix: string, states?: string[]): Observable<CountyResult[]> { findCountiesStartingWith(prefix: string, states?: string[]): Observable<CountyResult[]> {
return this.http.post<CountyResult[]>(`${this.apiBaseUrl}/bizmatch/geo/counties`, { prefix, states }); return this.http.post<CountyResult[]>(`${this.apiBaseUrl}/bizmatch/geo/counties`, { prefix, states });
} }