diff --git a/bizmatch-server/src/listings/business-listing.service.ts b/bizmatch-server/src/listings/business-listing.service.ts index 5f91977..7acf9ae 100644 --- a/bizmatch-server/src/listings/business-listing.service.ts +++ b/bizmatch-server/src/listings/business-listing.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { and, arrayContains, count, eq, gte, ilike, inArray, lte, ne, or, SQL, sql } from 'drizzle-orm'; +import { and, arrayContains, asc, count, desc, eq, gte, ilike, inArray, lte, ne, or, SQL, sql } from 'drizzle-orm'; import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { Logger } from 'winston'; @@ -130,6 +130,36 @@ export class BusinessListingService { query.where(whereClause); } + // Sortierung + switch (criteria.sortBy) { + case 'priceAsc': + query.orderBy(asc(businesses.price)); + break; + case 'priceDesc': + query.orderBy(desc(businesses.price)); + break; + case 'srAsc': + query.orderBy(asc(businesses.salesRevenue)); + break; + case 'srDesc': + query.orderBy(desc(businesses.salesRevenue)); + break; + case 'cfAsc': + query.orderBy(asc(businesses.cashFlow)); + break; + case 'cfDesc': + query.orderBy(desc(businesses.cashFlow)); + break; + case 'creationDateFirst': + query.orderBy(asc(businesses.created)); + break; + case 'creationDateLast': + query.orderBy(desc(businesses.created)); + break; + default: + // Keine spezifische Sortierung, Standardverhalten kann hier eingefügt werden + break; + } // Paginierung query.limit(length).offset(start); diff --git a/bizmatch-server/src/listings/commercial-property.service.ts b/bizmatch-server/src/listings/commercial-property.service.ts index 2e7d067..99701f3 100644 --- a/bizmatch-server/src/listings/commercial-property.service.ts +++ b/bizmatch-server/src/listings/commercial-property.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { and, arrayContains, count, eq, gte, ilike, inArray, lte, ne, or, SQL, sql } from 'drizzle-orm'; +import { and, arrayContains, asc, count, desc, eq, gte, ilike, inArray, lte, ne, or, SQL, sql } from 'drizzle-orm'; import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { Logger } from 'winston'; @@ -66,6 +66,24 @@ export class CommercialPropertyService { const whereClause = and(...whereConditions); query.where(whereClause); } + // Sortierung + switch (criteria.sortBy) { + case 'priceAsc': + query.orderBy(asc(commercials.price)); + break; + case 'priceDesc': + query.orderBy(desc(commercials.price)); + break; + case 'creationDateFirst': + query.orderBy(asc(commercials.created)); + break; + case 'creationDateLast': + query.orderBy(desc(commercials.created)); + break; + default: + // Keine spezifische Sortierung, Standardverhalten kann hier eingefügt werden + break; + } // Paginierung query.limit(length).offset(start); diff --git a/bizmatch-server/src/models/db.model.ts b/bizmatch-server/src/models/db.model.ts index c338834..c0d857a 100644 --- a/bizmatch-server/src/models/db.model.ts +++ b/bizmatch-server/src/models/db.model.ts @@ -22,6 +22,8 @@ export interface UserData { created?: Date; updated?: Date; } +export type SortByOptions = 'priceAsc' | 'priceDesc' | 'creationDateFirst' | 'creationDateLast' | 'nameAsc' | 'nameDesc' | 'srAsc' | 'srDesc' | 'cfAsc' | 'cfDesc'; +export type SortByTypes = 'professional' | 'listing' | 'business' | 'commercial'; export type Gender = 'male' | 'female'; export type CustomerType = 'buyer' | 'seller' | 'professional'; export type CustomerSubType = 'broker' | 'cpa' | 'attorney' | 'titleCompany' | 'surveyor' | 'appraiser'; @@ -143,8 +145,7 @@ export const GeoSchema = z.object({ }, ), }); -const phoneRegex = /^\(\d{3}\)\s\d{3}-\d{4}$/; - +const phoneRegex = /^(\+1|1)?[-.\s]?\(?[2-9]\d{2}\)?[-.\s]?\d{3}[-.\s]?\d{4}$/; export const UserSchema = z .object({ id: z.string().uuid().optional().nullable(), diff --git a/bizmatch-server/src/models/main.model.ts b/bizmatch-server/src/models/main.model.ts index 34139c4..0107376 100644 --- a/bizmatch-server/src/models/main.model.ts +++ b/bizmatch-server/src/models/main.model.ts @@ -1,5 +1,5 @@ import Stripe from 'stripe'; -import { BusinessListing, CommercialPropertyListing, Sender, User } from './db.model'; +import { BusinessListing, CommercialPropertyListing, Sender, SortByOptions, SortByTypes, User } from './db.model'; import { State } from './server.model'; export interface StatesResult { @@ -11,6 +11,12 @@ export interface KeyValue { name: string; value: string; } +export interface KeyValueAsSortBy { + name: string; + value: SortByOptions; + type?: SortByTypes; + selectName?: string; +} export interface KeyValueRatio { label: string; value: number; @@ -63,6 +69,7 @@ export interface ListCriteria { state: string; city: GeoResult; prompt: string; + sortBy: SortByOptions; searchType: 'exact' | 'radius'; // radius: '5' | '20' | '50' | '100' | '200' | '300' | '400' | '500'; radius: number; diff --git a/bizmatch-server/src/select-options/select-options.controller.ts b/bizmatch-server/src/select-options/select-options.controller.ts index da3d0f9..a34192b 100644 --- a/bizmatch-server/src/select-options/select-options.controller.ts +++ b/bizmatch-server/src/select-options/select-options.controller.ts @@ -15,6 +15,7 @@ export class SelectOptionsController { typesOfCommercialProperty: this.selectOptionsService.typesOfCommercialProperty, customerSubTypes: this.selectOptionsService.customerSubTypes, distances: this.selectOptionsService.distances, + sortByOptions: this.selectOptionsService.sortByOptions, }; } } diff --git a/bizmatch-server/src/select-options/select-options.service.ts b/bizmatch-server/src/select-options/select-options.service.ts index 87e050c..55d8397 100644 --- a/bizmatch-server/src/select-options/select-options.service.ts +++ b/bizmatch-server/src/select-options/select-options.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { ImageType, KeyValue, KeyValueStyle } from '../models/main.model'; +import { ImageType, KeyValue, KeyValueAsSortBy, KeyValueStyle } from '../models/main.model'; @Injectable() export class SelectOptionsService { @@ -35,7 +35,19 @@ export class SelectOptionsService { { name: '$1M', value: '1000000' }, { name: '$5M', value: '5000000' }, ]; - + public sortByOptions: Array = [ + { name: 'Price Asc', value: 'priceAsc', type: 'listing' }, + { name: 'Price Desc', value: 'priceDesc', type: 'listing' }, + { name: 'Sales Revenue Asc', value: 'srAsc', type: 'business' }, + { name: 'Sales Revenue Desc', value: 'srDesc', type: 'business' }, + { name: 'Cash Flow Asc', value: 'cfAsc', type: 'business' }, + { name: 'Cash Flow Desc', value: 'cfDesc', type: 'business' }, + { name: 'Creation Date First', value: 'creationDateFirst', type: 'listing' }, + { name: 'Creation Date Last', value: 'creationDateLast', type: 'listing' }, + { name: 'Name Asc', value: 'nameAsc', type: 'professional' }, + { name: 'Name Desc', value: 'nameDesc', type: 'professional' }, + { name: 'Sort', value: null, selectName: 'Default Sorting' }, + ]; public distances: Array = [ { name: '5 miles', value: '5' }, { name: '20 miles', value: '20' }, diff --git a/bizmatch-server/src/user/user.service.ts b/bizmatch-server/src/user/user.service.ts index 48921be..96aa607 100644 --- a/bizmatch-server/src/user/user.service.ts +++ b/bizmatch-server/src/user/user.service.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { and, count, eq, ilike, inArray, or, SQL, sql } from 'drizzle-orm'; +import { and, asc, count, desc, eq, ilike, inArray, or, SQL, sql } from 'drizzle-orm'; import { NodePgDatabase } from 'drizzle-orm/node-postgres/driver'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { Logger } from 'winston'; @@ -64,7 +64,18 @@ export class UserService { const whereClause = and(...whereConditions); query.where(whereClause); } - + // Sortierung + switch (criteria.sortBy) { + case 'nameAsc': + query.orderBy(asc(schema.users.lastname)); + break; + case 'nameDesc': + query.orderBy(desc(schema.users.lastname)); + break; + default: + // Keine spezifische Sortierung, Standardverhalten kann hier eingefügt werden + break; + } // Paginierung query.limit(length).offset(start); diff --git a/bizmatch/src/app/app.component.html b/bizmatch/src/app/app.component.html index 3bd57da..cefdb7c 100644 --- a/bizmatch/src/app/app.component.html +++ b/bizmatch/src/app/app.component.html @@ -1,5 +1,5 @@ -
+
@if (actualRoute !=='home'){
} diff --git a/bizmatch/src/app/app.routes.ts b/bizmatch/src/app/app.routes.ts index 9398c62..fce362a 100644 --- a/bizmatch/src/app/app.routes.ts +++ b/bizmatch/src/app/app.routes.ts @@ -139,6 +139,13 @@ export const routes: Routes = [ path: 'pricing', component: PricingComponent, }, + { + path: 'pricingOverview', + component: PricingComponent, + data: { + pricingOverview: true, + }, + }, { path: 'pricing/:id', component: PricingComponent, diff --git a/bizmatch/src/app/components/footer/footer.component.html b/bizmatch/src/app/components/footer/footer.component.html index 381a636..d5bd355 100644 --- a/bizmatch/src/app/components/footer/footer.component.html +++ b/bizmatch/src/app/components/footer/footer.component.html @@ -11,6 +11,7 @@
diff --git a/bizmatch/src/app/components/header/header.component.html b/bizmatch/src/app/components/header/header.component.html index c58813d..cbff6ff 100644 --- a/bizmatch/src/app/components/header/header.component.html +++ b/bizmatch/src/app/components/header/header.component.html @@ -3,18 +3,44 @@ Flowbite Logo -
+
- @if(isListingUrl()){ + @if(isFilterUrl()){ + +
+ + + +
+
    + @for(item of sortByOptions; track item){ +
  • {{ item.selectName ? item.selectName : item.name }}
  • + } + +
+
+
}
} @else { } - + -->
- @if(isListingUrl()){ + @if(isFilterUrl()){
+ +
} diff --git a/bizmatch/src/app/components/header/header.component.ts b/bizmatch/src/app/components/header/header.component.ts index 9aa855b..d32ccaa 100644 --- a/bizmatch/src/app/components/header/header.component.ts +++ b/bizmatch/src/app/components/header/header.component.ts @@ -1,6 +1,6 @@ import { BreakpointObserver } from '@angular/cdk/layout'; import { CommonModule } from '@angular/common'; -import { Component } from '@angular/core'; +import { Component, HostListener } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { NavigationEnd, Router, RouterModule } from '@angular/router'; import { faUserGear } from '@fortawesome/free-solid-svg-icons'; @@ -8,11 +8,12 @@ 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'; -import { User } from '../../../../../bizmatch-server/src/models/db.model'; -import { BusinessListingCriteria, CommercialPropertyListingCriteria, emailToDirName, KeycloakUser, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model'; +import { SortByOptions, User } from '../../../../../bizmatch-server/src/models/db.model'; +import { BusinessListingCriteria, CommercialPropertyListingCriteria, emailToDirName, KeycloakUser, KeyValueAsSortBy, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model'; import { environment } from '../../../environments/environment'; import { CriteriaChangeService } from '../../services/criteria-change.service'; import { SearchService } from '../../services/search.service'; +import { SelectOptionsService } from '../../services/select-options.service'; import { SharedService } from '../../services/shared.service'; import { UserService } from '../../services/user.service'; import { assignProperties, compareObjects, createEmptyBusinessListingCriteria, createEmptyCommercialPropertyListingCriteria, createEmptyUserListingCriteria, getCriteriaProxy, map2User } from '../../utils/utils'; @@ -43,6 +44,8 @@ export class HeaderComponent { criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria; private routerSubscription: Subscription | undefined; baseRoute: string; + sortDropdownVisible: boolean; + sortByOptions: KeyValueAsSortBy[] = []; constructor( public keycloakService: KeycloakService, private router: Router, @@ -52,8 +55,15 @@ export class HeaderComponent { private modalService: ModalService, private searchService: SearchService, private criteriaChangeService: CriteriaChangeService, + public selectOptions: SelectOptionsService, ) {} - + @HostListener('document:click', ['$event']) + handleGlobalClick(event: Event) { + const target = event.target as HTMLElement; + if (target.id !== 'sortDropdownButton' && target.id !== 'sortDropdownMobileButton') { + this.sortDropdownVisible = false; + } + } async ngOnInit() { const token = await this.keycloakService.getToken(); this.keycloakUser = map2User(token); @@ -73,9 +83,11 @@ export class HeaderComponent { }); this.checkCurrentRoute(this.router.url); + this.setupSortByOptions(); this.routerSubscription = this.router.events.pipe(filter(event => event instanceof NavigationEnd)).subscribe((event: any) => { this.checkCurrentRoute(event.urlAfterRedirects); + this.setupSortByOptions(); }); this.userService.currentUser.pipe(untilDestroyed(this)).subscribe(u => { @@ -90,7 +102,19 @@ export class HeaderComponent { this.criteria = getCriteriaProxy(this.baseRoute, this); this.searchService.search(this.criteria); } - + setupSortByOptions() { + this.sortByOptions = []; + if (this.isProfessionalListing()) { + this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => s.type === 'professional')]; + } + if (this.isBusinessListing()) { + this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => s.type === 'business' || s.type === 'listing')]; + } + if (this.isCommercialPropertyListing()) { + this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => s.type === 'commercial' || s.type === 'listing')]; + } + this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => !s.type)]; + } ngAfterViewInit() {} async openModal() { @@ -113,9 +137,21 @@ export class HeaderComponent { isActive(route: string): boolean { return this.router.url === route; } - isListingUrl(): boolean { + isFilterUrl(): boolean { return ['/businessListings', '/commercialPropertyListings', '/brokerListings'].includes(this.router.url); } + isBusinessListing(): boolean { + return ['/businessListings'].includes(this.router.url); + } + isCommercialPropertyListing(): boolean { + return ['/commercialPropertyListings'].includes(this.router.url); + } + isProfessionalListing(): boolean { + return ['/brokerListings'].includes(this.router.url); + } + // isSortingUrl(): boolean { + // return ['/businessListings', '/commercialPropertyListings'].includes(this.router.url); + // } closeDropdown() { const dropdownButton = document.getElementById('user-menu-button'); const dropdownMenu = this.user ? document.getElementById('user-login') : document.getElementById('user-unknown'); @@ -148,11 +184,11 @@ export class HeaderComponent { } getNumberOfFiltersSet() { if (this.criteria?.criteriaType === 'brokerListings') { - return compareObjects(createEmptyUserListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius']); + return compareObjects(createEmptyUserListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius', 'sortBy']); } else if (this.criteria?.criteriaType === 'businessListings') { - return compareObjects(createEmptyBusinessListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius']); + return compareObjects(createEmptyBusinessListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius', 'sortBy']); } else if (this.criteria?.criteriaType === 'commercialPropertyListings') { - return compareObjects(createEmptyCommercialPropertyListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius']); + return compareObjects(createEmptyCommercialPropertyListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius', 'sortBy']); } else { return 0; } @@ -160,4 +196,12 @@ export class HeaderComponent { isAdmin() { return this.keycloakService.getUserRoles(true).includes('ADMIN'); } + sortBy(sortBy: SortByOptions) { + this.criteria.sortBy = sortBy; + this.sortDropdownVisible = false; + this.searchService.search(this.criteria); + } + toggleSortDropdown() { + this.sortDropdownVisible = !this.sortDropdownVisible; + } } 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 33ecb04..92c87bd 100644 --- a/bizmatch/src/app/components/search-modal/search-modal.component.html +++ b/bizmatch/src/app/components/search-modal/search-modal.component.html @@ -19,7 +19,7 @@
- + + @if(!pricingOverview){
+ }
@@ -128,9 +132,11 @@
+ @if(!pricingOverview){
+ }
diff --git a/bizmatch/src/app/pages/pricing/pricing.component.ts b/bizmatch/src/app/pages/pricing/pricing.component.ts index d651edb..784b465 100644 --- a/bizmatch/src/app/pages/pricing/pricing.component.ts +++ b/bizmatch/src/app/pages/pricing/pricing.component.ts @@ -21,6 +21,7 @@ import { map2User } from '../../utils/utils'; export class PricingComponent { private apiBaseUrl = environment.apiBaseUrl; private id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined; + pricingOverview: boolean | undefined = this.activatedRoute.snapshot.data['pricingOverview'] as boolean | undefined; keycloakUser: KeycloakUser; user: User; constructor(public keycloakService: KeycloakService, private http: HttpClient, private stripeService: StripeService, private activatedRoute: ActivatedRoute, private userService: UserService, private router: Router) {} @@ -36,12 +37,14 @@ export class PricingComponent { 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) { + } else if (!this.id && !this.pricingOverview) { this.user = await this.userService.getByMail(this.keycloakUser.email); if (this.user.subscriptionId) { this.router.navigate([`/account`]); } } + } else { + this.pricingOverview = false; } } diff --git a/bizmatch/src/app/services/select-options.service.ts b/bizmatch/src/app/services/select-options.service.ts index d2ef402..1227dcf 100644 --- a/bizmatch/src/app/services/select-options.service.ts +++ b/bizmatch/src/app/services/select-options.service.ts @@ -1,7 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { lastValueFrom } from 'rxjs'; -import { KeyValue, KeyValueStyle } from '../../../../bizmatch-server/src/models/main.model'; +import { KeyValue, KeyValueAsSortBy, KeyValueStyle } from '../../../../bizmatch-server/src/models/main.model'; import { environment } from '../../environments/environment'; @Injectable({ @@ -22,6 +22,7 @@ export class SelectOptionsService { this.gender = allSelectOptions.gender; this.typesOfCommercialProperty = allSelectOptions.typesOfCommercialProperty; this.distances = allSelectOptions.distances; + this.sortByOptions = allSelectOptions.sortByOptions; } public typesOfBusiness: Array; @@ -38,6 +39,10 @@ export class SelectOptionsService { public states: Array; public customerSubTypes: Array; public distances: Array; + public sortByOptions: Array; + getSortByOption(value: string) { + return this.sortByOptions.find(l => l.value === value)?.name; + } getState(value: string): string { return this.states.find(l => l.value === value)?.name; } @@ -75,4 +80,11 @@ export class SelectOptionsService { getIconTypeOfCommercials(value: string): string { return this.typesOfCommercialProperty.find(c => c.value === value)?.icon; } + getIconAndTextColorTypeOfCommercials(value: string): string { + const category = this.typesOfCommercialProperty.find(c => c.value === value); + return `${category?.icon} ${category?.textColorClass}`; + } + getTextColorTypeOfCommercial(value: string): string { + return this.typesOfCommercialProperty.find(c => c.value === value)?.textColorClass; + } } diff --git a/bizmatch/src/app/utils/utils.ts b/bizmatch/src/app/utils/utils.ts index 638fede..4adb580 100644 --- a/bizmatch/src/app/utils/utils.ts +++ b/bizmatch/src/app/utils/utils.ts @@ -15,6 +15,7 @@ export function createEmptyBusinessListingCriteria(): BusinessListingCriteria { city: null, types: [], prompt: '', + sortBy: null, criteriaType: 'businessListings', minPrice: null, maxPrice: null, @@ -45,6 +46,7 @@ export function createEmptyCommercialPropertyListingCriteria(): CommercialProper city: null, types: [], prompt: '', + sortBy: null, criteriaType: 'commercialPropertyListings', minPrice: null, maxPrice: null, @@ -62,6 +64,7 @@ export function createEmptyUserListingCriteria(): UserListingCriteria { city: null, types: [], prompt: '', + sortBy: null, criteriaType: 'brokerListings', brokerName: '', companyName: '', @@ -79,6 +82,7 @@ export function resetBusinessListingCriteria(criteria: BusinessListingCriteria) criteria.city = null; criteria.types = []; criteria.prompt = ''; + criteria.sortBy = null; criteria.criteriaType = 'businessListings'; criteria.minPrice = null; criteria.maxPrice = null; @@ -107,6 +111,7 @@ export function resetCommercialPropertyListingCriteria(criteria: CommercialPrope criteria.city = null; criteria.types = []; criteria.prompt = ''; + criteria.sortBy = null; criteria.criteriaType = 'commercialPropertyListings'; criteria.minPrice = null; criteria.maxPrice = null; @@ -122,6 +127,7 @@ export function resetUserListingCriteria(criteria: UserListingCriteria) { criteria.city = null; criteria.types = []; criteria.prompt = ''; + criteria.sortBy = null; criteria.criteriaType = 'brokerListings'; criteria.brokerName = ''; criteria.companyName = '';