homepage overhault, aiService 1. try

This commit is contained in:
Andreas Knuth 2024-07-26 19:18:28 +02:00
parent 38e943c18e
commit a6ae643458
15 changed files with 333 additions and 52 deletions

View File

@ -38,6 +38,7 @@
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"drizzle-orm": "^0.32.0", "drizzle-orm": "^0.32.0",
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"groq-sdk": "^0.5.0",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"jwks-rsa": "^3.1.0", "jwks-rsa": "^3.1.0",
"ky": "^1.4.0", "ky": "^1.4.0",

View File

@ -0,0 +1,12 @@
import { Body, Controller, Post } from '@nestjs/common';
import { AiService } from './ai.service.js';
@Controller('ai')
export class AiController {
constructor(private readonly aiService: AiService) {}
@Post()
async getBusinessCriteria(@Body('query') query: string) {
return this.aiService.getBusinessCriteria(query);
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AiController } from './ai.controller.js';
import { AiService } from './ai.service.js';
@Module({
controllers: [AiController],
providers: [AiService],
})
export class AiModule {}

View File

@ -0,0 +1,94 @@
import { Injectable } from '@nestjs/common';
import Groq from 'groq-sdk';
import OpenAI from 'openai';
import { BusinessListingCriteria } from '../models/main.model';
const businessListingCriteriaStructure = {
criteriaType: 'business | commercialProperty | broker',
types: "'Automotive'|'Industrial Services'|'Food and Restaurant'|'Real Estate'|'Retail'|'Oilfield SVE and MFG.'|'Service'|'Advertising'|'Agriculture'|'Franchise'|'Professional'|'Manufacturing'",
city: 'string',
state: 'string',
county: 'string',
minPrice: 'number',
maxPrice: 'number',
minRevenue: 'number',
maxRevenue: 'number',
minCashFlow: 'number',
maxCashFlow: 'number',
minNumberEmployees: 'number',
maxNumberEmployees: 'number',
establishedSince: 'number',
establishedUntil: 'number',
realEstateChecked: 'boolean',
leasedLocation: 'boolean',
franchiseResale: 'boolean',
title: 'string',
brokerName: 'string',
searchType: "'exact' | 'radius'",
radius: "'0' | '5' | '20' | '50' | '100' | '200' | '300' | '400' | '500'",
};
@Injectable()
export class AiService {
private readonly openai: OpenAI;
private readonly groq: Groq;
constructor() {
this.openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY, // Verwenden Sie Umgebungsvariablen für den API-Schlüssel
});
this.groq = new Groq({ apiKey: process.env.GROQ_API_KEY });
}
async getBusinessCriteria(query: string): Promise<BusinessListingCriteria> {
// const prompt = `
// Dieses Objekt ist wie folgt definiert: ${JSON.stringify(businessListingCriteriaStructure)}.
// Die Antwort darf nur das von dir befüllte JSON als unformatierten Text enthalten so das es von mir mit JSON.parse() einlesbar ist!!!!
// Falls es Ortsangaben gibt, dann befülle City, County und State wenn möglich Die Suchanfrage des Users lautet: "${query}"`;
const prompt = `The Search Query of the User is: "${query}"`;
let response = null;
try {
// response = await this.openai.chat.completions.create({
// model: 'gpt-4o-mini',
// //model: 'gpt-3.5-turbo',
// max_tokens: 300,
// messages: [
// {
// role: 'system',
// content: `Please create unformatted JSON Object from a user input.
// The type is: ${JSON.stringify(businessListingCriteriaStructure)}.,
// If location details available please fill city, county and state as State Code`,
// },
// ],
// temperature: 0.5,
// response_format: { type: 'json_object' },
// });
response = await this.groq.chat.completions.create({
messages: [
{
role: 'system',
content: `Please create unformatted JSON Object from a user input.
The type must be: ${JSON.stringify(businessListingCriteriaStructure)}.
If location details available please fill city, county and state as State Code`,
},
{
role: 'user',
content: prompt,
},
],
model: 'llama-3.1-70b-versatile',
//model: 'llama-3.1-8b-instant',
temperature: 0.2,
max_tokens: 300,
response_format: { type: 'json_object' },
});
const generatedCriteria = JSON.parse(response.choices[0]?.message?.content);
return generatedCriteria;
// return response.choices[0]?.message?.content;
} catch (error) {
console.error(`Error calling GPT-4 API: ${response.choices[0]}`, error);
throw new Error('Failed to generate business criteria');
}
}
}

View File

@ -5,6 +5,7 @@ import * as dotenv from 'dotenv';
import fs from 'fs-extra'; import fs from 'fs-extra';
import { WinstonModule, utilities as nestWinstonModuleUtilities } from 'nest-winston'; import { WinstonModule, utilities as nestWinstonModuleUtilities } from 'nest-winston';
import * as winston from 'winston'; import * as winston from 'winston';
import { AiModule } from './ai/ai.module.js';
import { AppController } from './app.controller.js'; import { AppController } from './app.controller.js';
import { AppService } from './app.service.js'; import { AppService } from './app.service.js';
import { AuthModule } from './auth/auth.module.js'; import { AuthModule } from './auth/auth.module.js';
@ -73,6 +74,7 @@ loadEnvFiles();
SelectOptionsModule, SelectOptionsModule,
ImageModule, ImageModule,
PassportModule, PassportModule,
AiModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService, FileService], providers: [AppService, FileService],

View File

@ -62,6 +62,7 @@ export interface ListCriteria {
city: string; city: string;
prompt: string; prompt: string;
searchType: 'exact' | 'radius'; searchType: 'exact' | 'radius';
// radius: '5' | '20' | '50' | '100' | '200' | '300' | '400' | '500';
radius: number; radius: number;
criteriaType: 'business' | 'commercialProperty' | 'broker'; criteriaType: 'business' | 'commercialProperty' | 'broker';
} }

View File

@ -14,6 +14,7 @@ export class SelectOptionsController {
locations: this.selectOptionsService.locations, locations: this.selectOptionsService.locations,
typesOfCommercialProperty: this.selectOptionsService.typesOfCommercialProperty, typesOfCommercialProperty: this.selectOptionsService.typesOfCommercialProperty,
customerSubTypes: this.selectOptionsService.customerSubTypes, customerSubTypes: this.selectOptionsService.customerSubTypes,
distances: this.selectOptionsService.distances,
}; };
} }
} }

View File

@ -35,6 +35,17 @@ export class SelectOptionsService {
{ name: '$1M', value: '1000000' }, { name: '$1M', value: '1000000' },
{ name: '$5M', value: '5000000' }, { name: '$5M', value: '5000000' },
]; ];
public distances: Array<KeyValue> = [
{ name: '5 miles', value: '5' },
{ name: '20 miles', value: '20' },
{ name: '50 miles', value: '50' },
{ name: '100 miles', value: '100' },
{ name: '200 miles', value: '200' },
{ name: '300 miles', value: '300' },
{ name: '400 miles', value: '400' },
{ name: '500 miles', value: '500' },
];
public listingCategories: Array<KeyValue> = [ public listingCategories: Array<KeyValue> = [
{ name: 'Business', value: 'business' }, { name: 'Business', value: 'business' },
{ name: 'Commercial Property', value: 'commercialProperty' }, { name: 'Commercial Property', value: 'commercialProperty' },

View File

@ -7,28 +7,13 @@
<app-footer></app-footer> <app-footer></app-footer>
</div> </div>
<!-- <p-confirmDialog #cd>
<ng-template pTemplate="headless" let-message>
<div class="flex flex-column align-items-center p-5 surface-overlay border-round">
<span class="font-bold text-2xl block mb-2 mt-4">
{{ message.header }}
</span>
<p class="mb-0">{{ message.message }}</p>
<div class="flex align-items-center gap-2 mt-4">
<button pButton label="OK" (click)="cd.accept()" size="small"></button>
</div>
</div>
</ng-template>
</p-confirmDialog> -->
<!-- </div> -->
@if (loadingService.isLoading$ | async) { <!-- @if (loadingService.isLoading$ | async) {
<div class="spinner-overlay"> <div class="spinner-overlay">
<div class="spinner-container"> <div class="spinner-container">
<!-- <p-progressSpinner></p-progressSpinner> -->
<div class="spinner-text" *ngIf="loadingService.loadingText$ | async as loadingText">{{ loadingText }}</div> <div class="spinner-text" *ngIf="loadingService.loadingText$ | async as loadingText">{{ loadingText }}</div>
</div> </div>
</div> </div>
} } -->
<app-message-container></app-message-container> <app-message-container></app-message-container>
<app-search-modal></app-search-modal> <app-search-modal></app-search-modal>

View File

@ -8,6 +8,7 @@ import { DetailsBusinessListingComponent } from './pages/details/details-busines
import { DetailsCommercialPropertyListingComponent } from './pages/details/details-commercial-property-listing/details-commercial-property-listing.component'; import { DetailsCommercialPropertyListingComponent } from './pages/details/details-commercial-property-listing/details-commercial-property-listing.component';
import { DetailsUserComponent } from './pages/details/details-user/details-user.component'; import { DetailsUserComponent } from './pages/details/details-user/details-user.component';
import { HomeComponent } from './pages/home/home.component'; import { HomeComponent } from './pages/home/home.component';
import { Home1Component } from './pages/home1/home1.component';
import { BrokerListingsComponent } from './pages/listings/broker-listings/broker-listings.component'; import { BrokerListingsComponent } from './pages/listings/broker-listings/broker-listings.component';
import { BusinessListingsComponent } from './pages/listings/business-listings/business-listings.component'; import { BusinessListingsComponent } from './pages/listings/business-listings/business-listings.component';
import { CommercialPropertyListingsComponent } from './pages/listings/commercial-property-listings/commercial-property-listings.component'; import { CommercialPropertyListingsComponent } from './pages/listings/commercial-property-listings/commercial-property-listings.component';
@ -39,6 +40,10 @@ export const routes: Routes = [
path: 'home', path: 'home',
component: HomeComponent, component: HomeComponent,
}, },
{
path: 'home1',
component: Home1Component,
},
// ######### // #########
// Listings Details // Listings Details
{ {

View File

@ -1,4 +1,4 @@
::ng-deep .ng-select.custom .ng-select-container { :host ::ng-deep .ng-select.custom .ng-select-container {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(249 250 251 / var(--tw-bg-opacity)); background-color: rgb(249 250 251 / var(--tw-bg-opacity));
height: 46px; height: 46px;

View File

@ -36,7 +36,7 @@
<h1 class="text-3xl md:text-4xl lg:text-5xl font-bold text-blue-900 mb-4 text-center">Find businesses for sale.</h1> <h1 class="text-3xl md:text-4xl lg:text-5xl font-bold text-blue-900 mb-4 text-center">Find businesses for sale.</h1>
<p class="text-base md:text-lg lg:text-xl text-blue-600 mb-8 text-center">Unlocking Exclusive Opportunities - Empowering Entrepreneurial Dreams</p> <p class="text-base md:text-lg lg:text-xl text-blue-600 mb-8 text-center">Unlocking Exclusive Opportunities - Empowering Entrepreneurial Dreams</p>
<div class="bg-white bg-opacity-80 p-2 rounded-lg shadow-lg w-full"> <div class="bg-white bg-opacity-80 p-2 rounded-lg shadow-lg w-full">
<div class="text-sm lg:text-base text-center text-gray-500 border-b border-gray-200 dark:text-gray-400 dark:border-gray-700 flex justify-center"> <div class="text-sm lg:text-base mb-1 text-center text-gray-500 border-gray-200 dark:text-gray-400 dark:border-gray-700 flex justify-center">
<ul class="flex flex-wrap -mb-px"> <ul class="flex flex-wrap -mb-px">
<li class="me-2"> <li class="me-2">
<a <a
@ -76,15 +76,78 @@
</li> </li>
</ul> </ul>
</div> </div>
<div class="flex items-center border border-gray-300 rounded-full p-2"> @if(criteria){
<input type="text" [(ngModel)]="prompt" placeholder="AI Search" class="flex-grow px-4 py-2 outline-none rounded-full text-sm md:text-base" /> <div class="w-full max-w-3xl mx-auto bg-white rounded-lg flex flex-col md:flex-row border border-gray-300">
<button class="bg-blue-600 text-white p-2 rounded-full" (click)="search()"> <div class="md:flex-none md:w-40 flex-1 border-r border-gray-300 overflow-hidden">
<svg class="h-5 w-5 md:h-6 md:w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <div class="relative">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-4.35-4.35M10.5 18.5A7.5 7.5 0 1018 10.5 7.5 7.5 0 0010.5 18.5z"></path> <select
</svg> class="appearance-none bg-transparent w-full py-3 px-4 pr-8 focus:outline-none"
</button> [ngModel]="criteria.types"
(ngModelChange)="onTypesChange($event)"
[ngClass]="{ 'placeholder-selected': criteria.types.length === 0 }"
>
<option [value]="[]">Business Type</option>
@for(tob of selectOptions.typesOfBusiness; track tob){
<option [value]="tob.value">{{ tob.name }}</option>
}
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700">
<i class="fas fa-chevron-down text-xs"></i>
</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
class="custom"
[multiple]="false"
[hideSelected]="true"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="cityLoading"
typeToSearchText="Please enter 2 or more characters"
[typeahead]="cityInput$"
[ngModel]="cityOrState"
(ngModelChange)="setCity($event)"
placeholder="Enter City or State ..."
>
@for (city of cities$ | async; track city.id) {
<ng-option [value]="city">{{ city.city }} - {{ selectOptions.getStateInitials(city.state) }}</ng-option>
}
</ng-select>
</div>
<div class="md:flex-none md:w-36 flex-1 border-b md:border-b-0 md:border-r border-gray-300">
<div class="relative">
<select
class="appearance-none bg-transparent w-full py-3 px-4 pr-8 focus:outline-none"
(ngModelChange)="onRadiusChange($event)"
[ngModel]="criteria.radius"
[ngClass]="{ 'placeholder-selected': !criteria.radius }"
>
<option [value]="null">City Radius</option>
@for(dist of selectOptions.distances; track dist){
<option [value]="dist.value">{{ dist.name }}</option>
}
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-gray-700">
<i class="fas fa-chevron-down text-xs"></i>
</div>
</div>
</div>
<div class="bg-blue-600 hover:bg-blue-500 transition-colors duration-200">
<button class="w-full h-full text-white font-semibold py-3 px-6 focus:outline-none" (click)="search()">Suchen</button>
</div>
</div>
}
<div class="mt-4 flex items-center justify-center text-gray-700">
<span class="mr-2">AI-Search</span>
<span class="bg-teal-100 text-teal-800 text-xs font-semibold px-2 py-1 rounded">BETA</span>
<span class="ml-2">- Try now</span>
<div class="ml-4 relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
<input type="checkbox" name="toggle" id="toggle" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 border-gray-300 appearance-none cursor-pointer" />
<label for="toggle" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
</div>
</div> </div>
<div class="mt-4 text-gray-600 text-sm md:text-base text-center hover:cursor-pointer" (click)="openModal()">Or search using filters ▼</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,25 +1,3 @@
// :host {
// height: 100%;
// }
// .container {
// background-image: url(../../../assets/images/index-bg.webp);
// background-size: cover;
// background-position: center;
// height: 100vh;
// }
// .combo_lp {
// width: 200px;
// }
// .p-button-white {
// color: aliceblue;
// }
// .mt-11 {
// margin-top: 5.9rem !important;
// }
// .mt-22 {
// margin-top: 9.7rem !important;
// }
.bg-cover-custom { .bg-cover-custom {
background-image: url('/assets/images/index-bg.webp'); background-image: url('/assets/images/index-bg.webp');
background-size: cover; background-size: cover;
@ -28,3 +6,65 @@
box-shadow: 0 10px 15px rgba(0, 0, 0, 0.3); box-shadow: 0 10px 15px rgba(0, 0, 0, 0.3);
min-height: calc(100vh - 4rem); min-height: calc(100vh - 4rem);
} }
select:not([size]) {
background-image: unset;
}
[type='text'],
[type='email'],
[type='url'],
[type='password'],
[type='number'],
[type='date'],
[type='datetime-local'],
[type='month'],
[type='search'],
[type='tel'],
[type='time'],
[type='week'],
[multiple],
textarea,
select {
border: unset;
}
.toggle-checkbox:checked {
right: 0;
border-color: #4fd1c5;
}
.toggle-checkbox:checked + .toggle-label {
background-color: #4fd1c5;
}
:host ::ng-deep .ng-select.ng-select-single .ng-select-container {
height: 48px;
border: unset;
.ng-value-container .ng-input {
top: 10px;
}
span.ng-arrow-wrapper {
display: none;
}
}
.flex-1-1-2 {
flex: 1 1 2%;
}
// .light {
// color: #999;
// }
// component.css
select {
color: #000; /* Standard-Textfarbe für das Dropdown */
// background-color: #fff; /* Hintergrundfarbe für das Dropdown */
}
select option {
color: #000; /* Textfarbe für Dropdown-Optionen */
}
select.placeholder-selected {
color: #999; /* Farbe für den Platzhalter */
}
/* Stellt sicher, dass die Optionen im Dropdown immer schwarz sind */
select:focus option,
select:hover option {
color: #000 !important;
}

View File

@ -1,12 +1,15 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component } from '@angular/core'; 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 { KeycloakService } from 'keycloak-angular'; import { KeycloakService } from 'keycloak-angular';
import onChange from 'on-change'; import onChange from 'on-change';
import { BusinessListingCriteria, CommercialPropertyListingCriteria, KeycloakUser, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model'; import { catchError, concat, distinctUntilChanged, Observable, of, Subject, switchMap, tap } from 'rxjs';
import { BusinessListingCriteria, 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 { 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';
@ -14,7 +17,7 @@ import { getCriteriaStateObject, map2User } from '../../utils/utils';
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, RouterModule], imports: [CommonModule, FormsModule, RouterModule, NgSelectModule],
templateUrl: './home.component.html', templateUrl: './home.component.html',
styleUrl: './home.component.scss', styleUrl: './home.component.scss',
}) })
@ -28,6 +31,10 @@ export class HomeComponent {
isMenuOpen = false; isMenuOpen = false;
user: KeycloakUser; user: KeycloakUser;
prompt: string; prompt: string;
cities$: Observable<GeoResult[]>;
cityLoading = false;
cityInput$ = new Subject<string>();
cityOrState = undefined;
public constructor( public constructor(
private router: Router, private router: Router,
private modalService: ModalService, private modalService: ModalService,
@ -37,6 +44,8 @@ export class HomeComponent {
public keycloakService: KeycloakService, public keycloakService: KeycloakService,
private listingsService: ListingsService, private listingsService: ListingsService,
private criteriaChangeService: CriteriaChangeService, private criteriaChangeService: CriteriaChangeService,
private geoService: GeoService,
public cdRef: ChangeDetectorRef,
) {} ) {}
async ngOnInit() { async ngOnInit() {
const token = await this.keycloakService.getToken(); const token = await this.keycloakService.getToken();
@ -45,6 +54,7 @@ export class HomeComponent {
sessionStorage.removeItem('broker_criteria'); sessionStorage.removeItem('broker_criteria');
this.criteria = this.createEnhancedProxy(getCriteriaStateObject('business')); this.criteria = this.createEnhancedProxy(getCriteriaStateObject('business'));
this.user = map2User(token); this.user = map2User(token);
this.loadCities();
} }
async changeTab(tabname: 'business' | 'commercialProperty' | 'broker') { async changeTab(tabname: 'business' | 'commercialProperty' | 'broker') {
this.activeTabAction = tabname; this.activeTabAction = tabname;
@ -90,6 +100,22 @@ export class HomeComponent {
toggleMenu() { toggleMenu() {
this.isMenuOpen = !this.isMenuOpen; 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];
}
}
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 = value;
}
}
async openModal() { async openModal() {
const accepted = await this.modalService.showModal(this.criteria); const accepted = await this.modalService.showModal(this.criteria);
if (accepted) { if (accepted) {
@ -97,4 +123,33 @@ export class HomeComponent {
this.router.navigate([`${this.activeTabAction}Listings`]); this.router.navigate([`${this.activeTabAction}Listings`]);
} }
} }
private loadCities() {
this.cities$ = concat(
of([]), // default items
this.cityInput$.pipe(
distinctUntilChanged(),
tap(() => (this.cityLoading = true)),
switchMap(term =>
this.geoService.findCitiesStartingWith(term).pipe(
catchError(() => of([])), // empty list on error
// map(cities => cities.map(city => city.city)), // transform the list of objects to a list of city names
tap(() => (this.cityLoading = false)),
),
),
),
);
}
trackByFn(item: GeoResult) {
return item.id;
}
setCity(city) {
if (city) {
this.criteria.city = city.city;
this.criteria.state = city.state_code;
} else {
this.criteria.city = null;
this.criteria.radius = null;
this.criteria.searchType = 'exact';
}
}
} }

View File

@ -21,6 +21,7 @@ export class SelectOptionsService {
this.states = allSelectOptions.locations; this.states = allSelectOptions.locations;
this.gender = allSelectOptions.gender; this.gender = allSelectOptions.gender;
this.typesOfCommercialProperty = allSelectOptions.typesOfCommercialProperty; this.typesOfCommercialProperty = allSelectOptions.typesOfCommercialProperty;
this.distances = allSelectOptions.distances;
} }
public typesOfBusiness: Array<KeyValueStyle>; public typesOfBusiness: Array<KeyValueStyle>;
@ -36,6 +37,7 @@ export class SelectOptionsService {
public states: Array<any>; public states: Array<any>;
public customerSubTypes: Array<KeyValue>; public customerSubTypes: Array<KeyValue>;
public distances: Array<KeyValue>;
getState(value: string): string { getState(value: string): string {
return this.states.find(l => l.value === value)?.name; return this.states.find(l => l.value === value)?.name;
} }