diff --git a/bizmatch-server/src/ai/ai.service.ts b/bizmatch-server/src/ai/ai.service.ts index ade9f75..1c0dd14 100644 --- a/bizmatch-server/src/ai/ai.service.ts +++ b/bizmatch-server/src/ai/ai.service.ts @@ -3,30 +3,85 @@ 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'", -}; +// 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'", +// }; + +const BusinessListingCriteriaStructure = ` +export interface BusinessListingCriteria { + state: string; + city: string; + searchType: 'exact' | 'radius'; + radius: '20' | '50' | '100' | '200' | '300' | '400' | '500'; + + 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; + //types:"'Automotive'|'Industrial Services'|'Food and Restaurant'|'Real Estate'|'Retail'|'Oilfield SVE and MFG.'|'Service'|'Advertising'|'Agriculture'|'Franchise'|'Professional'|'Manufacturing'", + criteriaType: 'businessListings'; +} +`; +const CommercialPropertyListingCriteriaStructure = ` +export interface CommercialPropertyListingCriteria { + state: string; + city: string; + searchType: 'exact' | 'radius'; + radius: '20' | '50' | '100' | '200' | '300' | '400' | '500'; + + minPrice: number; + maxPrice: number; + //title: string; + //types:"'Retail'|'Land'|'Industrial'|'Office'|'Mixed Use'|'Multifamily'|'Uncategorized'" + criteriaType: 'commercialPropertyListings'; +} +`; +const UserListingCriteriaStructure = ` +export interface UserListingCriteria { + state: string; + city: string; + searchType: 'exact' | 'radius'; + radius: '20' | '50' | '100' | '200' | '300' | '400' | '500'; + + brokerName: string; + companyName: string; + counties: string[]; + criteriaType: 'brokerListings'; +} + +`; @Injectable() export class AiService { private readonly openai: OpenAI; @@ -67,8 +122,10 @@ export class AiService { { 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 and state as State Code and only county if explicitly mentioned`, + The criteriaType must be only either 'businessListings' or 'commercialPropertyListings' or 'brokerListings' !!!! + The format of the object (depending on your choice of criteriaType) must be either ${BusinessListingCriteriaStructure}, ${CommercialPropertyListingCriteriaStructure} or ${UserListingCriteriaStructure} !!!! + If location details available please fill city and state as State Code and only county if explicitly mentioned. + If you decide for searchType==='exact', please do not set the attribute radius`, }, { role: 'user', diff --git a/bizmatch/src/app/components/header/header.component.ts b/bizmatch/src/app/components/header/header.component.ts index b121ae2..39cd1a7 100644 --- a/bizmatch/src/app/components/header/header.component.ts +++ b/bizmatch/src/app/components/header/header.component.ts @@ -4,6 +4,7 @@ import { Component } 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 { KeycloakService } from 'keycloak-angular'; import { filter, Observable, Subject, Subscription } from 'rxjs'; @@ -17,6 +18,7 @@ import { UserService } from '../../services/user.service'; import { assignProperties, compareObjects, createEmptyBusinessListingCriteria, createEmptyCommercialPropertyListingCriteria, createEmptyUserListingCriteria, getCriteriaProxy, map2User } from '../../utils/utils'; import { DropdownComponent } from '../dropdown/dropdown.component'; import { ModalService } from '../search-modal/modal.service'; +@UntilDestroy() @Component({ selector: 'header', standalone: true, @@ -75,6 +77,12 @@ export class HeaderComponent { this.routerSubscription = this.router.events.pipe(filter(event => event instanceof NavigationEnd)).subscribe((event: any) => { this.checkCurrentRoute(event.urlAfterRedirects); }); + + this.userService.currentUser.pipe(untilDestroyed(this)).subscribe(u => { + if (u !== undefined) { + this.user = u; + } + }); } private checkCurrentRoute(url: string): void { this.baseRoute = url.split('/')[1]; // Nimmt den ersten Teil der Route nach dem ersten '/' diff --git a/bizmatch/src/app/components/search-modal/search-modal.component.html b/bizmatch/src/app/components/search-modal/search-modal.component.html index 96b86ad..fce32db 100644 --- a/bizmatch/src/app/components/search-modal/search-modal.component.html +++ b/bizmatch/src/app/components/search-modal/search-modal.component.html @@ -445,7 +445,7 @@
- +

Find businesses for sale.

Unlocking Exclusive Opportunities - Empowering Entrepreneurial Dreams

-
+
+ @if(!aiSearch){
  • @@ -76,7 +77,40 @@
- @if(criteria){ + } @if(aiSearch){ +
+
+
+ +
+
+
+ +
+
+ } @if(criteria && !aiSearch){
@@ -119,7 +153,7 @@
- @if (criteria.radius){ + @if (criteria.radius && !aiSearch){
+
diff --git a/bizmatch/src/app/pages/home/home.component.scss b/bizmatch/src/app/pages/home/home.component.scss index 7d4bd35..bec5f8e 100644 --- a/bizmatch/src/app/pages/home/home.component.scss +++ b/bizmatch/src/app/pages/home/home.component.scss @@ -28,10 +28,10 @@ select { } .toggle-checkbox:checked { right: 0; - border-color: #4fd1c5; + border-color: rgb(125 211 252); } .toggle-checkbox:checked + .toggle-label { - background-color: #4fd1c5; + background-color: rgb(125 211 252); } :host ::ng-deep .ng-select.ng-select-single .ng-select-container { height: 48px; @@ -56,9 +56,19 @@ select option { select.placeholder-selected { color: #999; /* Farbe für den Platzhalter */ } +input::placeholder { + color: #555; /* Dunkleres Grau */ + opacity: 1; /* Stellt sicher, dass die Deckkraft 100% ist */ +} /* Stellt sicher, dass die Optionen im Dropdown immer schwarz sind */ select:focus option, select:hover option { color: #000 !important; } +input[type='text'][name='aiSearchText'] { + padding: 14px; /* Innerer Abstand */ + font-size: 16px; /* Schriftgröße anpassen */ + box-sizing: border-box; /* Padding und Border in die Höhe und Breite einrechnen */ + height: 48px; +} diff --git a/bizmatch/src/app/pages/home/home.component.ts b/bizmatch/src/app/pages/home/home.component.ts index ebaa21a..3732330 100644 --- a/bizmatch/src/app/pages/home/home.component.ts +++ b/bizmatch/src/app/pages/home/home.component.ts @@ -1,20 +1,30 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectorRef, Component } from '@angular/core'; +import { ChangeDetectorRef, Component, ElementRef, ViewChild } from '@angular/core'; 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 { KeycloakService } from 'keycloak-angular'; -import { catchError, concat, debounceTime, distinctUntilChanged, Observable, of, Subject, Subscription, switchMap, tap } from 'rxjs'; +import { catchError, concat, debounceTime, distinctUntilChanged, lastValueFrom, Observable, of, Subject, Subscription, 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 { AiService } from '../../services/ai.service'; import { CriteriaChangeService } from '../../services/criteria-change.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 } from '../../utils/utils'; +import { + assignProperties, + compareObjects, + createEmptyBusinessListingCriteria, + createEmptyCommercialPropertyListingCriteria, + createEmptyUserListingCriteria, + createEnhancedProxy, + getCriteriaStateObject, + map2User, +} from '../../utils/utils'; @UntilDestroy() @Component({ selector: 'app-home', @@ -24,6 +34,7 @@ import { compareObjects, createEmptyBusinessListingCriteria, createEmptyCommerci styleUrl: './home.component.scss', }) export class HomeComponent { + placeholders: string[] = ['waterfront property close to Houston less than 1M', 'construction area with beach access close to San Diego']; activeTabAction: 'business' | 'commercialProperty' | 'broker' = 'business'; type: string; maxPrice: string; @@ -39,6 +50,18 @@ export class HomeComponent { cityOrState = undefined; private criteriaChangeSubscription: Subscription; numberOfResults$: Observable; + + aiSearch = false; + aiSearchText = ''; + loadingAi = false; + @ViewChild('aiSearchInput', { static: false }) searchInput!: ElementRef; + typingSpeed: number = 100; // Geschwindigkeit des Tippens (ms) + pauseTime: number = 2000; // Pausezeit, bevor der Text verschwindet (ms) + index: number = 0; + charIndex: number = 0; + typingInterval: any; + showInput: boolean = true; // Steuerung der Anzeige des Eingabefelds + public constructor( private router: Router, private modalService: ModalService, @@ -51,6 +74,7 @@ export class HomeComponent { public cdRef: ChangeDetectorRef, private listingService: ListingsService, private userService: UserService, + private aiService: AiService, ) {} async ngOnInit() { const token = await this.keycloakService.getToken(); @@ -193,4 +217,87 @@ export class HomeComponent { return 0; } } + toggleAiSearch() { + this.aiSearch = !this.aiSearch; + 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); + } + } + async generateAiResponse() { + this.loadingAi = true; + const result = await this.aiService.generateAiReponse(this.aiSearchText); + console.log(result); + let criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria | any; + if (result.criteriaType === 'businessListings') { + this.changeTab('business'); + criteria = result as BusinessListingCriteria; + } else if (result.criteriaType === 'commercialPropertyListings') { + this.changeTab('commercialProperty'); + criteria = result as CommercialPropertyListingCriteria; + } else { + this.changeTab('broker'); + criteria = result as UserListingCriteria; + } + const city = criteria.city as string; + if (city && city.length > 0) { + let results = await lastValueFrom(this.geoService.findCitiesStartingWith(city, criteria.state)); + if (results.length > 0) { + criteria.city = results[0]; + } else { + criteria.city = null; + } + } + if (criteria.radius && criteria.radius.length > 0) { + criteria.radius = parseInt(criteria.radius); + } + this.loadingAi = false; + this.criteria = assignProperties(this.criteria, criteria); + this.search(); + } } diff --git a/bizmatch/src/app/pages/pricing/pricing.component.ts b/bizmatch/src/app/pages/pricing/pricing.component.ts index d1a2ae8..6e7b75d 100644 --- a/bizmatch/src/app/pages/pricing/pricing.component.ts +++ b/bizmatch/src/app/pages/pricing/pricing.component.ts @@ -28,13 +28,19 @@ export class PricingComponent { async ngOnInit() { const token = await this.keycloakService.getToken(); this.keycloakUser = map2User(token); - if (this.id) { - this.checkout({ priceId: atob(this.id), email: this.keycloakUser.email, name: `${this.keycloakUser.firstName} ${this.keycloakUser.lastName}` }); - } - if (this.keycloakUser && !this.id) { - this.user = await this.userService.getByMail(this.keycloakUser.email); - if (this.user.subscriptionId) { + if (this.keycloakUser) { + if (this.id === 'free') { + this.user = await this.userService.getByMail(this.keycloakUser.email); + this.user.subscriptionPlan = 'free'; + await this.userService.save(this.user); this.router.navigate([`/account`]); + } else if (this.id) { + this.checkout({ priceId: atob(this.id), email: this.keycloakUser.email, name: `${this.keycloakUser.firstName} ${this.keycloakUser.lastName}` }); + } else if (!this.id) { + this.user = await this.userService.getByMail(this.keycloakUser.email); + if (this.user.subscriptionId) { + this.router.navigate([`/account`]); + } } } } @@ -55,7 +61,7 @@ export class PricingComponent { redirectUri: `${window.location.origin}/pricing/${btoa(priceId)}`, }); } else { - this.keycloakService.register({ redirectUri: `${window.location.origin}/account` }); + this.keycloakService.register({ redirectUri: `${window.location.origin}/pricing/free` }); } } } diff --git a/bizmatch/src/app/pages/subscription/account/account.component.ts b/bizmatch/src/app/pages/subscription/account/account.component.ts index a6e9ad2..cb5be76 100644 --- a/bizmatch/src/app/pages/subscription/account/account.component.ts +++ b/bizmatch/src/app/pages/subscription/account/account.component.ts @@ -10,7 +10,7 @@ import { ImageCropperComponent } from 'ngx-image-cropper'; import { QuillModule } from 'ngx-quill'; import { lastValueFrom } from 'rxjs'; import { User } from '../../../../../../bizmatch-server/src/models/db.model'; -import { AutoCompleteCompleteEvent, Invoice, StripeSubscription, UploadParams, ValidationMessage, createDefaultUser, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model'; +import { AutoCompleteCompleteEvent, Invoice, StripeSubscription, UploadParams, ValidationMessage, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model'; import { environment } from '../../../../environments/environment'; import { ConfirmationComponent } from '../../../components/confirmation/confirmation.component'; import { ConfirmationService } from '../../../components/confirmation/confirmation.service'; @@ -111,10 +111,6 @@ export class AccountComponent { this.subscriptions = await lastValueFrom(this.subscriptionService.getAllSubscriptions(this.user.email)); await this.synchronizeSubscriptions(this.subscriptions); - // if (this.subscriptions.length === 0) { - // this.subscriptions = [{ ended_at: null, start_date: Math.floor(new Date(this.user.created).getTime() / 1000), status: null, metadata: { plan: 'Free Plan' } }]; - // } - this.profileUrl = this.user.hasProfile ? `${this.env.imageBaseUrl}/pictures/profile/${emailToDirName(this.user.email)}.avif?_ts=${new Date().getTime()}` : `/assets/images/placeholder.png`; this.companyLogoUrl = this.user.hasCompanyLogo ? `${this.env.imageBaseUrl}/pictures/logo/${emailToDirName(this.user.email)}.avif?_ts=${new Date().getTime()}` : `/assets/images/placeholder.png`; @@ -168,22 +164,9 @@ export class AccountComponent { printInvoice(invoice: Invoice) {} async updateProfile(user: User) { - if (this.user.customerType === 'buyer') { - const confirmed = await this.confirmationService.showConfirmation({ message: 'Are you sure you want to switch to Buyer ? All your listings as well as all your professionals informations will be deleted' }); - if (confirmed) { - const id = this.user.id; - this.user = createDefaultUser(this.user.email, this.user.firstname, this.user.lastname, null); - this.user.customerType = 'buyer'; - this.user.id = id; - this.imageService.deleteLogoImagesByMail(this.user.email); - this.imageService.deleteProfileImagesByMail(this.user.email); - } else { - this.user.customerType = 'professional'; - return; - } - } try { await this.userService.save(this.user); + this.userService.changeUser(this.user); this.messageService.addMessage({ severity: 'success', text: 'Account changes have been persisted', duration: 3000 }); this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten this.validationMessages = []; diff --git a/bizmatch/src/app/pages/subscription/edit-commercial-property-listing/edit-commercial-property-listing.component.html b/bizmatch/src/app/pages/subscription/edit-commercial-property-listing/edit-commercial-property-listing.component.html index 7024ff8..11b8bd8 100644 --- a/bizmatch/src/app/pages/subscription/edit-commercial-property-listing/edit-commercial-property-listing.component.html +++ b/bizmatch/src/app/pages/subscription/edit-commercial-property-listing/edit-commercial-property-listing.component.html @@ -7,11 +7,12 @@ diff --git a/bizmatch/src/app/pages/subscription/edit-commercial-property-listing/edit-commercial-property-listing.component.ts b/bizmatch/src/app/pages/subscription/edit-commercial-property-listing/edit-commercial-property-listing.component.ts index 2f8ef1f..eadff1e 100644 --- a/bizmatch/src/app/pages/subscription/edit-commercial-property-listing/edit-commercial-property-listing.component.ts +++ b/bizmatch/src/app/pages/subscription/edit-commercial-property-listing/edit-commercial-property-listing.component.ts @@ -98,6 +98,7 @@ export class EditCommercialPropertyListingComponent { data: BusinessListing; userId: string; typesOfCommercialProperty = []; + listingCategories = []; env = environment; ts = new Date().getTime(); quillModules = { @@ -144,6 +145,13 @@ export class EditCommercialPropertyListingComponent { async ngOnInit() { const token = await this.keycloakService.getToken(); const keycloakUser = map2User(token); + const email = keycloakUser.email; + this.user = await this.userService.getByMail(email); + this.listingCategories = this.selectOptions.listingCategories + .filter(lc => lc.value === 'commercialProperty' || (this.user.customerSubType === 'broker' && lc.value === 'business')) + .map(e => { + return { name: e.name, value: e.value }; + }); if (this.mode === 'edit') { this.listing = (await lastValueFrom(this.listingsService.getListingById(this.id, 'commercialProperty'))) as CommercialPropertyListing; } else { diff --git a/bizmatch/src/app/services/ai.service.ts b/bizmatch/src/app/services/ai.service.ts new file mode 100644 index 0000000..7ccca84 --- /dev/null +++ b/bizmatch/src/app/services/ai.service.ts @@ -0,0 +1,19 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { lastValueFrom } from 'rxjs'; +import { BusinessListingCriteria, CommercialPropertyListingCriteria, UserListingCriteria } from '../../../../bizmatch-server/src/models/main.model'; +import { environment } from '../../environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class AiService { + private apiBaseUrl = environment.apiBaseUrl; + constructor(private http: HttpClient) {} + + async generateAiReponse(prompt: string): Promise { + let headers = new HttpHeaders(); + headers = headers.set('X-Hide-Loading', 'true'); + return lastValueFrom(this.http.post(`${this.apiBaseUrl}/bizmatch/ai`, { query: prompt }, { headers })); + } +} diff --git a/bizmatch/src/app/services/user.service.ts b/bizmatch/src/app/services/user.service.ts index 3005f07..4bd2d60 100644 --- a/bizmatch/src/app/services/user.service.ts +++ b/bizmatch/src/app/services/user.service.ts @@ -1,6 +1,6 @@ import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { lastValueFrom, Observable } from 'rxjs'; +import { BehaviorSubject, lastValueFrom, Observable } from 'rxjs'; import urlcat from 'urlcat'; import { User } from '../../../../bizmatch-server/src/models/db.model'; import { ResponseUsersArray, StatesResult, UserListingCriteria } from '../../../../bizmatch-server/src/models/main.model'; @@ -12,8 +12,14 @@ import { environment } from '../../environments/environment'; export class UserService { private apiBaseUrl = environment.apiBaseUrl; + private userSource = new BehaviorSubject(undefined); + currentUser = this.userSource.asObservable(); + constructor(private http: HttpClient) {} + changeUser(user: User) { + this.userSource.next(user); + } // ----------------------------- // DB services // ----------------------------- diff --git a/bizmatch/src/styles.scss b/bizmatch/src/styles.scss index c1621d6..e5c8d80 100644 --- a/bizmatch/src/styles.scss +++ b/bizmatch/src/styles.scss @@ -92,5 +92,5 @@ p-menubarsub ul { } input::placeholder, textarea::placeholder { - color: #cfd7e0 !important; + color: #999 !important; }