new Landing page, stripped app

This commit is contained in:
Andreas Knuth 2025-04-05 12:25:50 +02:00
parent b39370a6b5
commit 83808263af
165 changed files with 9484 additions and 0 deletions

View File

@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

42
bizmatch-client/.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

View File

@ -0,0 +1,18 @@
{
"arrowParens": "avoid",
"embeddedLanguageFormatting": "auto",
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxBracketSameLine": false,
"jsxSingleQuote": false,
"printWidth": 250,
"proseWrap": "always",
"quoteProps": "as-needed",
"requirePragma": false,
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"useTabs": false,
"vueIndentScriptAndStyle": false
}

View File

@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
bizmatch-client/.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

28
bizmatch-client/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,28 @@
{
"editor.suggestSelection": "first",
"vsintellicode.modify.editor.suggestSelection": "automaticallyOverrodeDefaultValue",
"explorer.confirmDelete": false,
"typescript.updateImportsOnFileMove.enabled": "always",
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[html]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"[scss]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
},
"prettier.printWidth": 240,
"git.autofetch": false,
"git.autorefresh": true
}

42
bizmatch-client/.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
}
]
}

59
bizmatch-client/README.md Normal file
View File

@ -0,0 +1,59 @@
# BizmatchClient
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.2.6.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

View File

@ -0,0 +1,133 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"bizmatch-client": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss",
"skipTests": true
},
"@schematics/angular:class": {
"skipTests": true
},
"@schematics/angular:directive": {
"skipTests": true
},
"@schematics/angular:guard": {
"skipTests": true
},
"@schematics/angular:interceptor": {
"skipTests": true
},
"@schematics/angular:pipe": {
"skipTests": true
},
"@schematics/angular:resolver": {
"skipTests": true
},
"@schematics/angular:service": {
"skipTests": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/bizmatch-client",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
},
"src/favicon.ico",
"src/assets"
],
"styles": [
"src/styles.scss",
"node_modules/quill/dist/quill.snow.css"
],
"scripts": [],
"server": "src/main.server.ts",
"prerender": false,
"ssr": false
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "bizmatch-client:build:production"
},
"development": {
"buildTarget": "bizmatch-client:build:development"
}
},
"defaultConfiguration": "development",
"options": {
"proxyConfig": "proxy.conf.json"
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
],
"scripts": []
}
}
}
}
}
}

View File

@ -0,0 +1,66 @@
{
"name": "bizmatch-client",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"serve:ssr:bizmatch-client": "node dist/bizmatch-client/server/server.mjs"
},
"private": true,
"dependencies": {
"@angular/animations": "^19.2.5",
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/fire": "^19.0.0",
"@angular/forms": "^19.2.0",
"@angular/platform-browser": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0",
"@angular/platform-server": "^19.2.0",
"@angular/router": "^19.2.0",
"@angular/ssr": "^19.2.6",
"@fortawesome/angular-fontawesome": "^1.0.0",
"@fortawesome/fontawesome-free": "^6.7.2",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@ng-select/ng-select": "^14.2.6",
"@ngneat/until-destroy": "^10.0.0",
"browser-bunyan": "^1.8.0",
"dayjs": "^1.11.13",
"express": "^4.18.2",
"flowbite": "^3.1.2",
"jwt-decode": "^4.0.0",
"ngx-currency": "^19.0.0",
"ngx-image-cropper": "^9.1.5",
"ngx-mask": "^19.0.6",
"ngx-quill": "^27.0.1",
"ngx-sharebuttons": "^17.0.0",
"on-change": "^5.0.1",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"urlcat": "^3.1.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.6",
"@angular/cli": "^19.2.6",
"@angular/compiler-cli": "^19.2.0",
"@types/express": "^4.17.17",
"@types/jasmine": "~5.1.0",
"@types/node": "^18.18.0",
"autoprefixer": "^10.4.21",
"jasmine-core": "~5.6.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"typescript": "~5.7.2"
}
}

View File

@ -0,0 +1,28 @@
{
"/bizmatch": {
"target": "http://localhost:3000",
"secure": false,
"changeOrigin": true,
"logLevel": "debug"
},
"/pictures": {
"target": "http://localhost:8080",
"secure": false
},
"/ipify": {
"target": "https://api.ipify.org",
"secure": true,
"changeOrigin": true,
"pathRewrite": {
"^/ipify": ""
}
},
"/ipinfo": {
"target": "https://ipinfo.io",
"secure": true,
"changeOrigin": true,
"pathRewrite": {
"^/ipinfo": ""
}
}
}

View File

@ -0,0 +1,43 @@
<!-- <div class="container"> -->
<div class="wrapper" [ngClass]="{ 'print:bg-white': actualRoute !== 'home' }">
@if (actualRoute !=='home' && actualRoute !=='login' &&
actualRoute!=='emailVerification' && actualRoute!=='email-authorized'){
<header></header>
}
<main class="flex-1">
<router-outlet></router-outlet>
</main>
<app-footer></app-footer>
</div>
@if (loadingService.isLoading$ | async) {
<div class="spinner-overlay">
<div class="spinner-container">
@let loadingText = (loadingService.loadingText$ | async); @if(loadingText){
<div class="spinner-text">{{ loadingText }}</div>
}
<div role="status">
<svg
aria-hidden="true"
class="inline w-10 h-10 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
</div>
</div>
}
<app-message-container></app-message-container>
<app-search-modal></app-search-modal>
<app-confirmation></app-confirmation>
<app-email></app-email>

View File

@ -0,0 +1,51 @@
import { CommonModule } from '@angular/common';
import { Component, HostListener } from '@angular/core';
import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/router';
import { filter } from 'rxjs';
import build from '../build';
import { ConfirmationComponent } from './components/confirmation.component';
import { EMailComponent } from './components/email/email.component';
import { FooterComponent } from './components/footer/footer.component';
import { HeaderComponent } from './components/header/header.component';
import { MessageContainerComponent } from './components/message/message-container.component';
import { SearchModalComponent } from './components/search-modal/search-modal.component';
import { ConfirmationService } from './services/confirmation.service';
import { LoadingService } from './services/loading.service';
import { UserService } from './services/user.service';
@Component({
selector: 'app-root',
imports: [CommonModule, RouterOutlet, HeaderComponent, FooterComponent, MessageContainerComponent, SearchModalComponent, ConfirmationComponent, EMailComponent],
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
})
export class AppComponent {
build = build;
title = 'bizmatch';
actualRoute = '';
public constructor(public loadingService: LoadingService, private router: Router, private activatedRoute: ActivatedRoute, private userService: UserService, private confirmationService: ConfirmationService) {
this.router.events.pipe(filter(event => event instanceof NavigationEnd)).subscribe(() => {
let currentRoute = this.activatedRoute.root;
while (currentRoute.children[0] !== undefined) {
currentRoute = currentRoute.children[0];
}
this.actualRoute = currentRoute.snapshot.url[0].path;
});
}
ngOnInit() {}
@HostListener('window:keydown', ['$event'])
handleKeyboardEvent(event: KeyboardEvent) {
if (event.shiftKey && event.ctrlKey && event.key === 'V') {
this.showVersionDialog();
}
}
showVersionDialog() {
this.confirmationService.showConfirmation({
message: `App Version: ${this.build.timestamp}`,
buttons: 'none',
});
}
}

View File

@ -0,0 +1,11 @@
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { appConfig } from './app.config';
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(),
]
};
export const config = mergeApplicationConfig(appConfig, serverConfig);

View File

@ -0,0 +1,48 @@
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { APP_INITIALIZER, ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { initializeApp, provideFirebaseApp } from '@angular/fire/app';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
import { provideRouter, withEnabledBlockingInitialNavigation, withInMemoryScrolling } from '@angular/router';
import { environment } from '../environments/environment';
import { routes } from './app.routes';
import { LoadingInterceptor } from './interceptors/loading.interceptor';
import { TimeoutInterceptor } from './interceptors/timeout.interceptor';
import { SelectOptionsService } from './services/select-options.service';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptorsFromDi()),
{
provide: APP_INITIALIZER,
useFactory: initServices,
multi: true,
deps: [SelectOptionsService],
},
{
provide: HTTP_INTERCEPTORS,
useClass: LoadingInterceptor,
multi: true,
},
{
provide: HTTP_INTERCEPTORS,
useClass: TimeoutInterceptor,
multi: true,
},
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(
routes,
withEnabledBlockingInitialNavigation(),
withInMemoryScrolling({
scrollPositionRestoration: 'enabled',
anchorScrolling: 'enabled',
}),
),
provideClientHydration(withEventReplay()),
provideFirebaseApp(() => initializeApp(environment.firebaseConfig)),
],
};
function initServices(selectOptions: SelectOptionsService) {
return async () => {
await selectOptions.init();
};
}

View File

@ -0,0 +1,93 @@
import { Routes } from '@angular/router';
import { AuthGuard } from './guards/auth.guard';
import { AccountComponent } from './pages/account/account.component';
import { BusinessListingsComponent } from './pages/business-listings/business-listings.component';
import { DetailsBusinessListingComponent } from './pages/details-business-listing/details-business-listing.component';
import { EditBusinessListingComponent } from './pages/edit-business-listing/edit-business-listing.component';
import { EmailAuthorizedComponent } from './pages/email-authorized/email-authorized.component';
import { EmailUsComponent } from './pages/email-us/email-us.component';
import { EmailVerificationComponent } from './pages/email-verification/email-verification.component';
import { FavoritesComponent } from './pages/favorites/favorites.component';
import { HomeComponent } from './pages/home/home.component';
import { LoginRegisterComponent } from './pages/login-register/login-register.component';
import { LogoutComponent } from './pages/logout/logout.component';
import { MyListingComponent } from './pages/my-listing/my-listing.component';
import { NotFoundComponent } from './pages/not-found/not-found.component';
import { UserListComponent } from './pages/user-list/user-list.component';
export const routes: Routes = [
{
path: 'businessListings',
component: BusinessListingsComponent,
},
{
path: 'home',
component: HomeComponent,
},
{
path: 'details-business-listing/:id',
component: DetailsBusinessListingComponent,
},
{
path: 'createBusinessListing',
component: EditBusinessListingComponent,
canActivate: [AuthGuard],
},
{
path: 'login/:page',
component: LoginRegisterComponent,
},
{
path: 'login',
component: LoginRegisterComponent,
},
{
path: 'notfound',
component: NotFoundComponent,
},
{
path: 'account',
component: AccountComponent,
canActivate: [AuthGuard],
},
{
path: 'editBusinessListing/:id',
component: EditBusinessListingComponent,
canActivate: [AuthGuard],
},
{
path: 'myListings',
component: MyListingComponent,
canActivate: [AuthGuard],
},
{
path: 'myFavorites',
component: FavoritesComponent,
canActivate: [AuthGuard],
},
{
path: 'emailUs',
component: EmailUsComponent,
// canActivate: [AuthGuard],
},
{
path: 'logout',
component: LogoutComponent,
canActivate: [AuthGuard],
},
{
path: 'emailVerification',
component: EmailVerificationComponent,
},
{
path: 'email-authorized',
component: EmailAuthorizedComponent,
},
{
path: 'admin/users',
component: UserListComponent,
canActivate: [AuthGuard],
},
{ path: '**', redirectTo: 'home' },
];

View File

@ -0,0 +1,56 @@
import { Component, Input } from '@angular/core';
import { ControlValueAccessor } from '@angular/forms';
import { Subscription } from 'rxjs';
import { ValidationMessagesService } from '../validation-messages.service';
@Component({
selector: 'app-base-input',
template: ``,
imports: [],
})
export abstract class BaseInputComponent implements ControlValueAccessor {
@Input() value: any = '';
validationMessage: string = '';
onChange: any = () => {};
onTouched: any = () => {};
subscription: Subscription | null = null;
@Input() label: string = '';
// @Input() id: string = '';
@Input() name: string = '';
isTooltipVisible = false;
constructor(protected validationMessagesService: ValidationMessagesService) {}
ngOnInit() {
this.subscription = this.validationMessagesService.messages$.subscribe(() => {
this.updateValidationMessage();
});
}
ngOnDestroy() {
if (this.subscription) {
this.subscription.unsubscribe();
}
}
writeValue(value: any): void {
if (value !== undefined) {
this.value = value;
}
}
registerOnChange(fn: any): void {
this.onChange = fn;
}
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
updateValidationMessage(): void {
this.validationMessage = this.validationMessagesService.getMessage(this.name);
}
setDisabledState?(isDisabled: boolean): void {}
toggleTooltip(event: Event) {
event.preventDefault();
event.stopPropagation();
this.isTooltipVisible = !this.isTooltipVisible;
}
}

View File

@ -0,0 +1,54 @@
import { AsyncPipe, NgIf } from '@angular/common';
import { Component } from '@angular/core';
import { ConfirmationService } from '../services/confirmation.service';
@Component({
selector: 'app-confirmation',
imports: [AsyncPipe, NgIf],
template: `
<div *ngIf="confirmationService.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 p-4 w-full max-w-md max-h-full">
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<button
(click)="confirmationService.reject()"
type="button"
class="absolute top-3 end-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
>
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
</svg>
<span class="sr-only">Close modal</span>
</button>
<div class="p-4 md:p-5 text-center">
<svg class="mx-auto mb-4 text-gray-400 w-12 h-12 dark:text-gray-200" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 11V6m0 8h.01M19 10a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
@let confirmation = (confirmationService.confirmation$ | async);
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">
{{ confirmation?.message }}
</h3>
@if(confirmation?.buttons==='both'){
<button
(click)="confirmationService.accept()"
type="button"
class="text-white bg-red-600 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 dark:focus:ring-red-800 font-medium rounded-lg text-sm inline-flex items-center px-5 py-2.5 text-center mr-2"
>
Yes, I'm sure
</button>
<button
(click)="confirmationService.reject()"
type="button"
class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
>
No, cancel
</button>
}
</div>
</div>
</div>
</div>
`,
})
export class ConfirmationComponent {
constructor(public confirmationService: ConfirmationService) {}
}

View File

@ -0,0 +1,43 @@
<!-- Main modal -->
<div *ngIf="eMailService.modalVisible$ | async" id="authentication-modal" tabindex="-1" class="z-40 fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center">
<div class="relative p-4 w-full max-w-md max-h-full">
<!-- Modal content -->
<div class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<!-- Modal header -->
<div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Email listing to a friend</h3>
<button
(click)="eMailService.reject()"
type="button"
class="end-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
>
<svg class="w-3 h-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
</svg>
<span class="sr-only">Close modal</span>
</button>
</div>
<!-- Modal body -->
<div class="p-4 md:p-5">
<form class="space-y-4" action="#">
<div>
<app-validated-input label="Your Email" name="yourEmail" [(ngModel)]="shareByEMail.yourEmail"></app-validated-input>
</div>
<div>
<app-validated-input label="Your Name" name="yourName" [(ngModel)]="shareByEMail.yourName"></app-validated-input>
</div>
<div>
<app-validated-input label="Your Friend's EMail" name="recipientEmail" [(ngModel)]="shareByEMail.recipientEmail"></app-validated-input>
</div>
<button
(click)="sendMail()"
class="w-full 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 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
>
Send EMail
</button>
</form>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,40 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { ShareByEMail } from '../../../../../bizmatch-server/src/models/db.model';
import { MailService } from '../../services/mail.service';
import { ValidatedInputComponent } from '../validated-input/validated-input.component';
import { ValidationMessagesService } from '../validation-messages.service';
import { EMailService } from './email.service';
@UntilDestroy()
@Component({
selector: 'app-email',
standalone: true,
imports: [CommonModule, FormsModule, ValidatedInputComponent],
templateUrl: './email.component.html',
template: ``,
})
export class EMailComponent {
shareByEMail: ShareByEMail = {};
constructor(public eMailService: EMailService, private mailService: MailService, private validationMessagesService: ValidationMessagesService) {}
ngOnInit() {
this.eMailService.shareByEMail$.pipe(untilDestroyed(this)).subscribe(val => {
this.shareByEMail = val;
});
}
async sendMail() {
try {
const result = await this.mailService.mailToFriend(this.shareByEMail);
this.eMailService.accept(this.shareByEMail);
} catch (error) {
if (error.error && Array.isArray(error.error?.message)) {
this.validationMessagesService.updateMessages(error.error.message);
}
}
}
ngOnDestroy() {
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
}
}

View File

@ -0,0 +1,33 @@
import { Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs';
import { ShareByEMail } from '../../../../../bizmatch-server/src/models/db.model';
@Injectable({
providedIn: 'root',
})
export class EMailService {
private modalVisibleSubject = new Subject<boolean>();
private shareByEMailSubject = new Subject<ShareByEMail>();
private resolvePromise!: (value: boolean | ShareByEMail) => void;
modalVisible$: Observable<boolean> = this.modalVisibleSubject.asObservable();
shareByEMail$: Observable<ShareByEMail> = this.shareByEMailSubject.asObservable();
showShareByEMail(shareByEMail: ShareByEMail): Promise<boolean | ShareByEMail> {
this.shareByEMailSubject.next(shareByEMail);
this.modalVisibleSubject.next(true);
return new Promise<boolean | ShareByEMail>(resolve => {
this.resolvePromise = resolve;
});
}
accept(value: ShareByEMail): void {
this.modalVisibleSubject.next(false);
this.resolvePromise(value);
}
reject(): void {
this.modalVisibleSubject.next(false);
this.resolvePromise(false);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,20 @@
:host {
width: 100%;
}
@media (max-width: 1023px) {
.order-2 {
order: 2;
}
.order-3 {
order: 3;
}
}
section p {
display: block;
margin-block-start: 1em;
margin-block-end: 1em;
margin-inline-start: 0px;
margin-inline-end: 0px;
unicode-bidi: isolate;
}

View File

@ -0,0 +1,21 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterModule } from '@angular/router';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
@Component({
selector: 'app-footer',
standalone: true,
imports: [CommonModule, FormsModule, RouterModule, FontAwesomeModule],
templateUrl: './footer.component.html',
styleUrl: './footer.component.scss',
})
export class FooterComponent {
privacyVisible = false;
termsVisible = false;
currentYear: number = new Date().getFullYear();
isHomeRoute = false;
constructor(private router: Router) {}
ngOnInit() {}
}

View File

@ -0,0 +1,124 @@
<nav class="bg-white border-gray-200 dark:bg-gray-900 print:hidden">
<div class="flex flex-wrap items-center justify-between mx-auto p-4">
<a routerLink="/home" class="flex items-center space-x-3 rtl:space-x-reverse">
<img src="assets/images/header-logo.png" class="h-8" alt="Flowbite Logo" />
</a>
<div class="flex items-center md:order-2 space-x-3 rtl:space-x-reverse">
<!-- Filter button -->
@if(isFilterUrl()){
<button
type="button"
#triggerButton
(click)="openModal()"
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"
>
<i class="fas fa-filter mr-2"></i>Filter ({{ getNumberOfFiltersSet() }})
</button>
<!-- Sort button -->
<div class="relative">
<button
type="button"
id="sortDropdownButton"
class="max-sm:hidden px-4 py-2 text-sm font-medium 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"
(click)="toggleSortDropdown()"
[ngClass]="{ 'text-blue-500': selectOptions.getSortByOption(criteria?.sortBy) !== 'Sort', 'text-gray-900': selectOptions.getSortByOption(criteria?.sortBy) === 'Sort' }"
>
<i class="fas fa-sort mr-2"></i>{{ selectOptions.getSortByOption(criteria?.sortBy) }}
</button>
<!-- Sort options dropdown -->
<div *ngIf="sortDropdownVisible" class="absolute right-0 z-50 w-48 md:mt-2 max-md:mt-20 max-md:mr-[-2.5rem] bg-white border border-gray-200 rounded-lg drop-shadow-custom-bg dark:bg-gray-800 dark:border-gray-600">
<ul class="py-1 text-sm text-gray-700 dark:text-gray-200">
@for(item of sortByOptions; track item){
<li (click)="sortBy(item.value)" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">{{ item.selectName ? item.selectName : item.name }}</li>
}
</ul>
</div>
</div>
}
<button
type="button"
class="flex text-sm bg-gray-400 rounded-full md:me-0 focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600"
id="user-menu-button"
aria-expanded="false"
[attr.data-dropdown-toggle]="user ? 'user-login' : 'user-unknown'"
data-dropdown-placement="bottom"
>
<span class="sr-only">Open user menu</span>
@if(isProfessional || (authService.isAdmin() | async) && user?.hasProfile){
<img class="w-8 h-8 rounded-full object-cover" src="{{ profileUrl }}" alt="user photo" />
} @else {
<i class="flex justify-center items-center text-stone-50 w-8 h-8 rounded-full fa-solid fa-bars"></i>
}
</button>
<!-- Dropdown menu -->
@if(user){
<div class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded-lg shadow dark:bg-gray-700 dark:divide-gray-600" id="user-login">
<div class="px-4 py-3">
<span class="block text-sm text-gray-900 dark:text-white">Welcome, {{ user.firstname }} </span>
<span class="block text-sm text-gray-500 truncate dark:text-gray-400">{{ user.email }}</span>
</div>
<ul class="py-2" aria-labelledby="user-menu-button">
<li>
<a routerLink="/account" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Account</a>
</li>
@if(isProfessional || (authService.isAdmin() | async) && user?.hasProfile){
<li>
<a routerLink="/createBusinessListing" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Create Listing</a>
</li>
}
<li>
<a routerLink="/myListings" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">My Listings</a>
</li>
<li>
<a routerLink="/myFavorites" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">My Favorites</a>
</li>
<li>
<a routerLink="/logout" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Logout</a>
</li>
</ul>
@if(authService.isAdmin() | async){
<ul class="py-2">
<li>
<a routerLink="admin/users" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Users (Admin)</a>
</li>
</ul>
}
</div>
} @else {
<div class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded-lg shadow dark:bg-gray-700 dark:divide-gray-600" id="user-unknown">
<ul class="py-2" aria-labelledby="user-menu-button">
<li>
<a routerLink="/login" [queryParams]="{ mode: 'login' }" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Log In</a>
</li>
<li>
<a routerLink="/login" [queryParams]="{ mode: 'register' }" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Sign Up</a>
</li>
</ul>
</div>
}
</div>
</div>
<!-- Mobile filter button -->
<div class="md:hidden flex justify-center pb-4">
<button
(click)="openModal()"
type="button"
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"
>
<i class="fas fa-filter mr-2"></i>Filter ({{ getNumberOfFiltersSet() }})
</button>
<!-- Sorting -->
<button
(click)="toggleSortDropdown()"
type="button"
id="sortDropdownMobileButton"
class="mx-4 w-1/2 px-4 py-2 text-sm font-medium 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"
[ngClass]="{ 'text-blue-500': selectOptions.getSortByOption(criteria?.sortBy) !== 'Sort', 'text-gray-900': selectOptions.getSortByOption(criteria?.sortBy) === 'Sort' }"
>
<i class="fas fa-sort mr-2"></i>{{ selectOptions.getSortByOption(criteria?.sortBy) }}
</button>
</div>
</nav>

View File

@ -0,0 +1,13 @@
::ng-deep p-menubarsub{
margin-left: auto;
}
::ng-deep .p-tabmenu .p-tabmenu-nav .p-tabmenuitem .p-menuitem-link{
border:1px solid #ffffff;
}
::ng-deep .p-tabmenu .p-tabmenu-nav .p-tabmenuitem.p-highlight .p-menuitem-link {
border-bottom: 2px solid #3B82F6 !important;
}
::ng-deep .p-menubar{
border:unset;
background: unset;
}

View File

@ -0,0 +1,200 @@
import { BreakpointObserver } from '@angular/cdk/layout';
import { CommonModule } from '@angular/common';
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';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { debounceTime, filter, Observable, Subject, Subscription } from 'rxjs';
import { SortByOptions, User } from '../../../../../bizmatch-server/src/models/db.model';
import { BusinessListingCriteria, emailToDirName, KeycloakUser, KeyValueAsSortBy } from '../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../environments/environment';
import { SharedService } from '../../services/shared.service';
import { Collapse, Dropdown } from 'flowbite';
import { AuthService } from '../../services/auth.service';
import { CriteriaChangeService } from '../../services/criteria-change.service';
import { ListingsService } from '../../services/listings.service';
import { ModalService } from '../../services/modal.service';
import { SearchService } from '../../services/search.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { UserService } from '../../services/user.service';
import { assignProperties, compareObjects, createEmptyBusinessListingCriteria, getCriteriaProxy, map2User } from '../../utils/utils';
@UntilDestroy()
@Component({
selector: 'header',
imports: [CommonModule, RouterModule, FormsModule],
templateUrl: './header.component.html',
styleUrl: './header.component.scss',
})
export class HeaderComponent {
public buildVersion = environment.buildVersion;
user$: Observable<KeycloakUser>;
keycloakUser: KeycloakUser;
user: User;
activeItem;
faUserGear = faUserGear;
profileUrl: string;
env = environment;
private filterDropdown: Dropdown | null = null;
isMobile: boolean = false;
private destroy$ = new Subject<void>();
prompt: string;
private subscription: Subscription;
criteria: BusinessListingCriteria;
private routerSubscription: Subscription | undefined;
baseRoute: string;
sortDropdownVisible: boolean;
sortByOptions: KeyValueAsSortBy[] = [];
numberOfBroker$: Observable<number>;
numberOfCommercial$: Observable<number>;
constructor(
private router: Router,
private userService: UserService,
private sharedService: SharedService,
private breakpointObserver: BreakpointObserver,
private modalService: ModalService,
private searchService: SearchService,
private criteriaChangeService: CriteriaChangeService,
public selectOptions: SelectOptionsService,
public authService: AuthService,
private listingService: ListingsService,
) {}
@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.authService.getToken();
this.keycloakUser = map2User(token);
if (this.keycloakUser) {
this.user = await this.userService.getByMail(this.keycloakUser?.email);
this.profileUrl = this.user.hasProfile ? `${this.env.imageBaseUrl}/pictures/profile/${emailToDirName(this.user.email)}.avif?_ts=${new Date().getTime()}` : `/assets/images/placeholder.png`;
}
this.sharedService.currentProfilePhoto.subscribe(photoUrl => {
this.profileUrl = photoUrl;
});
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.subscription = this.criteriaChangeService.criteriaChange$.pipe(debounceTime(400)).subscribe(() => {
this.criteria = getCriteriaProxy(this.baseRoute, this);
});
this.userService.currentUser.pipe(untilDestroyed(this)).subscribe(u => {
this.user = u;
});
}
private checkCurrentRoute(url: string): void {
this.baseRoute = url.split('/')[1]; // Nimmt den ersten Teil der Route nach dem ersten '/'
const specialRoutes = [, '', ''];
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() {
const modalResult = await this.modalService.showModal(this.criteria);
if (modalResult.accepted) {
this.searchService.search(this.criteria);
} else {
this.criteria = assignProperties(this.criteria, modalResult.criteria);
}
}
navigateWithState(dest: string, state: any) {
this.router.navigate([dest], { state: state });
}
isActive(route: string): boolean {
return this.router.url === route;
}
isEmailUsUrl(): boolean {
return ['/emailUs'].includes(this.router.url);
}
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');
if (dropdownButton && dropdownMenu) {
const dropdown = new Dropdown(dropdownMenu, dropdownButton);
dropdown.hide();
}
}
closeMobileMenu() {
const targetElement = document.getElementById('navbar-user');
const triggerElement = document.querySelector('[data-collapse-toggle="navbar-user"]');
if (targetElement instanceof HTMLElement && triggerElement instanceof HTMLElement) {
const collapse = new Collapse(targetElement, triggerElement);
collapse.collapse();
}
}
closeMenusAndSetCriteria(path: string) {
this.closeDropdown();
this.closeMobileMenu();
const criteria = getCriteriaProxy(path, this);
criteria.page = 1;
criteria.start = 0;
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
getNumberOfFiltersSet() {
if (this.criteria?.criteriaType === 'businessListings') {
return compareObjects(createEmptyBusinessListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius', 'sortBy']);
} else {
return 0;
}
}
sortBy(sortBy: SortByOptions) {
this.criteria.sortBy = sortBy;
this.sortDropdownVisible = false;
this.searchService.search(this.criteria);
}
toggleSortDropdown() {
this.sortDropdownVisible = !this.sortDropdownVisible;
}
get isProfessional() {
return this.user?.customerType === 'professional';
}
}

View File

@ -0,0 +1,12 @@
<!-- Modal -->
<div *ngIf="showModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center">
<div class="bg-white p-5 rounded-lg shadow-xl" style="width: 90%; max-width: 600px">
<h3 class="text-lg font-semibold mb-4">Crop Image</h3>
<image-cropper (loadImageFailed)="loadImageFailed()" [imageChangedEvent]="imageChangedEvent" [maintainAspectRatio]="false" format="png" (imageCropped)="imageCropped($event)"></image-cropper>
<div class="mt-4 flex justify-end">
<button (click)="closeModal()" class="mr-2 px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300">Cancel</button>
<button (click)="uploadImage()" class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">Upload</button>
</div>
</div>
</div>
<input type="file" #fileInput style="display: none" (change)="fileChangeEvent($event)" accept="image/*" />

View File

@ -0,0 +1,6 @@
::ng-deep image-cropper {
justify-content: center;
& > div {
width: unset !important;
}
}

View File

@ -0,0 +1,69 @@
import { CommonModule } from '@angular/common';
import { Component, ElementRef, Input, output, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { ImageCroppedEvent, ImageCropperComponent } from 'ngx-image-cropper';
import { UploadParams } from '../../../../../bizmatch-server/src/models/main.model';
import { ImageService } from '../../services/image.service';
import { ListingsService } from '../../services/listings.service';
export interface UploadReponse {
success: boolean;
type: 'uploadPropertyPicture' | 'uploadCompanyLogo' | 'uploadProfile';
}
@Component({
selector: 'app-image-crop-and-upload',
standalone: true,
imports: [CommonModule, FormsModule, RouterModule, FontAwesomeModule, ImageCropperComponent],
templateUrl: './image-crop-and-upload.component.html',
styleUrl: './image-crop-and-upload.component.scss',
})
export class ImageCropAndUploadComponent {
showModal = false;
imageChangedEvent: any = '';
croppedImage: Blob | null = null;
@Input() uploadParams: UploadParams;
uploadFinished = output<UploadReponse>();
@ViewChild('fileInput', { static: true }) fileInput!: ElementRef<HTMLInputElement>;
constructor(private imageService: ImageService, private listingsService: ListingsService) {}
ngOnInit() {}
ngOnChanges() {
this.openFileDialog();
}
openFileDialog() {
if (this.uploadParams) {
this.fileInput.nativeElement.click();
}
}
fileChangeEvent(event: any): void {
this.imageChangedEvent = event;
this.showModal = true;
}
imageCropped(event: ImageCroppedEvent) {
this.croppedImage = event.blob;
}
closeModal() {
this.imageChangedEvent = null;
this.croppedImage = null;
this.showModal = false;
this.fileInput.nativeElement.value = '';
this.uploadFinished.emit({ success: false, type: this.uploadParams.type });
}
async uploadImage() {
if (this.croppedImage) {
await this.imageService.uploadImage(this.croppedImage, this.uploadParams.type, this.uploadParams.imagePath, this.uploadParams.serialId);
this.closeModal();
this.uploadFinished.emit({ success: true, type: this.uploadParams.type });
}
}
loadImageFailed() {
console.error('Load image failed');
}
}

View File

@ -0,0 +1,35 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { Message, MessageService } from '../../services/message.service';
import { MessageComponent } from './message.component';
@Component({
selector: 'app-message-container',
standalone: true,
imports: [CommonModule, MessageComponent],
template: `
<div class="fixed top-5 right-5 z-50 flex flex-col items-end">
<app-message
*ngFor="let message of messages"
[message]="message"
(close)="removeMessage(message)"
>
</app-message>
</div>
`,
})
export class MessageContainerComponent implements OnInit {
messages: Message[] = [];
constructor(private messageService: MessageService) {}
ngOnInit(): void {
this.messageService.messages$.subscribe((messages) => {
this.messages = messages;
});
}
removeMessage(message: Message): void {
this.messageService.removeMessage(message);
}
}

View File

@ -0,0 +1,94 @@
import { animate, style, transition, trigger } from '@angular/animations';
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Message } from '../../services/message.service';
@Component({
selector: 'app-message',
standalone: true,
imports: [CommonModule],
template: `
<div [@toastAnimation]="'in'" [ngClass]="getClasses()" role="alert">
<div class="ms-3 text-sm font-medium">{{ message.text }}</div>
<button
type="button"
(click)="onClose()"
class="ms-auto -mx-1.5 -my-1.5 rounded-lg p-1.5 inline-flex items-center justify-center h-8 w-8"
[ngClass]="getCloseButtonClasses()"
aria-label="Close"
>
<span class="sr-only">Close</span>
<svg
class="w-3 h-3"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 14 14"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"
/>
</svg>
</button>
</div>
`,
animations: [
trigger('toastAnimation', [
transition(':enter', [
style({ transform: 'translateY(100%)', opacity: 0 }),
animate(
'300ms ease-out',
style({ transform: 'translateY(0)', opacity: 1 })
),
]),
transition(':leave', [
animate(
'300ms ease-in',
style({ transform: 'translateY(100%)', opacity: 0 })
),
]),
]),
],
})
export class MessageComponent {
@Input() message!: Message;
@Output() close = new EventEmitter<void>();
onClose(): void {
this.close.emit();
}
getClasses(): string {
return `flex items-center w-full max-w-xs p-4 mb-4 text-gray-500 rounded-lg shadow ${this.getSeverityClasses()}`;
}
getCloseButtonClasses(): string {
switch (this.message.severity) {
case 'success':
return 'text-green-600 hover:bg-green-200 focus:ring-green-400';
case 'danger':
return 'text-red-600 hover:bg-red-200 focus:ring-red-400';
case 'warning':
return 'text-yellow-600 hover:bg-yellow-200 focus:ring-yellow-400';
default:
return 'text-blue-600 hover:bg-blue-200 focus:ring-blue-400';
}
}
private getSeverityClasses(): string {
switch (this.message.severity) {
case 'success':
return 'bg-green-100 text-green-700';
case 'danger':
return 'bg-red-100 text-red-700';
case 'warning':
return 'bg-yellow-100 text-yellow-700';
default:
return 'bg-blue-100 text-blue-700';
}
}
}

View File

@ -0,0 +1 @@
<p>paginator works!</p>

View File

@ -0,0 +1,98 @@
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
@Component({
selector: 'app-paginator',
standalone: true,
imports: [CommonModule],
template: `
<nav class="my-2" aria-label="Page navigation">
<ul class="flex justify-center items-center -space-x-px h-8 text-sm">
<li>
<a
(click)="onPageChange(currentPage - 1)"
[class.pointer-events-none]="currentPage === 1"
class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-e-0 border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
>
<span class="sr-only">Previous</span>
<svg class="w-2.5 h-2.5 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 1 1 5l4 4" />
</svg>
</a>
</li>
<ng-container *ngFor="let page of visiblePages">
<li *ngIf="page !== '...'">
<a
(click)="onPageChange(page)"
[ngClass]="
page === currentPage
? 'z-10 flex items-center justify-center px-3 h-8 leading-tight text-blue-600 border border-blue-300 bg-blue-50 hover:bg-blue-100 hover:text-blue-700 dark:border-gray-700 dark:bg-gray-700 dark:text-white'
: 'flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white'
"
>
{{ page }}
</a>
</li>
<li *ngIf="page === '...'">
<span class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400">...</span>
</li>
</ng-container>
<li>
<a
(click)="onPageChange(currentPage + 1)"
[class.pointer-events-none]="currentPage === pageCount"
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
>
<span class="sr-only">Next</span>
<svg class="w-2.5 h-2.5 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4" />
</svg>
</a>
</li>
</ul>
</nav>
`,
})
export class PaginatorComponent implements OnChanges {
@Input() page = 1;
@Input() pageCount = 1;
@Output() pageChange = new EventEmitter<number>();
currentPage = 1;
visiblePages: (number | string)[] = [];
ngOnChanges(changes: SimpleChanges): void {
if (changes['page'] || changes['pageCount']) {
this.currentPage = this.page;
this.updateVisiblePages();
}
}
updateVisiblePages(): void {
const totalPages = this.pageCount;
const current = this.currentPage;
if (totalPages <= 6) {
this.visiblePages = Array.from({ length: totalPages }, (_, i) => i + 1);
} else {
if (current <= 3) {
this.visiblePages = [1, 2, 3, 4, '...', totalPages];
} else if (current >= totalPages - 2) {
this.visiblePages = [1, '...', totalPages - 3, totalPages - 2, totalPages - 1, totalPages];
} else {
this.visiblePages = [1, '...', current - 1, current, current + 1, '...', totalPages];
}
}
}
onPageChange(page: number | string): void {
if (typeof page === 'string') {
return;
}
if (page >= 1 && page <= this.pageCount && page !== this.currentPage) {
this.currentPage = page;
this.pageChange.emit(page);
this.updateVisiblePages();
}
}
}

View File

@ -0,0 +1,394 @@
<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 bg-white rounded-lg shadow">
<div class="flex items-start justify-between p-4 border-b rounded-t">
@if(criteria.criteriaType==='businessListings'){
<h3 class="text-xl font-semibold text-gray-900">
Business Listing Search
</h3>
} @else if (criteria.criteriaType==='commercialPropertyListings'){
<h3 class="text-xl font-semibold text-gray-900">
Property Listing Search
</h3>
} @else {
<h3 class="text-xl font-semibold text-gray-900">
Professional Listing Search
</h3>
}
<button
(click)="close()"
type="button"
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ml-auto inline-flex justify-center items-center"
>
<svg
class="w-3 h-3"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 14 14"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"
/>
</svg>
<span class="sr-only">Close Modal</span>
</button>
</div>
<div class="p-6 space-y-6">
<div class="flex space-x-4 mb-4">
<button
class="text-blue-600 font-medium border-b-2 border-blue-600 pb-2"
>
Classic Search
</button>
<i
data-tooltip-target="tooltip-light"
class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-blue-500"
(click)="clearFilter()"
></i>
<div
id="tooltip-light"
role="tooltip"
class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg shadow-sm opacity-0 tooltip"
>
Clear all Filter
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-4">
<div>
<label
for="state"
class="block mb-2 text-sm font-medium text-gray-900"
>Location - State</label
>
<ng-select
class="custom"
[items]="selectOptions?.states"
bindLabel="name"
bindValue="value"
[ngModel]="criteria.state"
(ngModelChange)="setState($event)"
name="state"
>
</ng-select>
</div>
<!-- <div>
<app-validated-city
label="Location - City"
name="city"
[ngModel]="criteria.city"
(ngModelChange)="setCity($event)"
labelClasses="text-gray-900 font-medium"
[state]="criteria.state"
></app-validated-city>
</div> -->
<!-- New section for city search type -->
<div *ngIf="criteria.city">
<label class="block mb-2 text-sm font-medium text-gray-900"
>Search Type</label
>
<div class="flex items-center space-x-4">
<label class="inline-flex items-center">
<input
type="radio"
class="form-radio"
name="searchType"
[(ngModel)]="criteria.searchType"
value="exact"
/>
<span class="ml-2">Exact City</span>
</label>
<label class="inline-flex items-center">
<input
type="radio"
class="form-radio"
name="searchType"
[(ngModel)]="criteria.searchType"
value="radius"
/>
<span class="ml-2">Radius Search</span>
</label>
</div>
</div>
<!-- New section for radius selection -->
<div
*ngIf="criteria.city && criteria.searchType === 'radius'"
class="space-y-2"
>
<label class="block mb-2 text-sm font-medium text-gray-900"
>Select Radius (in miles)</label
>
<div class="flex flex-wrap">
@for (radius of [5, 20, 50, 100, 200, 300, 400, 500]; track
radius) {
<button
type="button"
class="px-3 py-2 text-xs font-medium text-center border border-gray-200 hover:bg-gray-500 hover:text-white"
[ngClass]="
criteria.radius === radius
? 'text-white bg-gray-500'
: 'text-gray-900 bg-white'
"
(click)="criteria.radius = radius"
>
{{ radius }}
</button>
}
</div>
</div>
<div>
<label
for="price"
class="block mb-2 text-sm font-medium text-gray-900"
>Price</label
>
<div class="flex items-center space-x-2">
<app-validated-price
name="price-from"
[(ngModel)]="criteria.minPrice"
placeholder="From"
inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"
></app-validated-price>
<span>-</span>
<app-validated-price
name="price-to"
[(ngModel)]="criteria.maxPrice"
placeholder="To"
inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"
></app-validated-price>
</div>
</div>
<div>
<label
for="salesRevenue"
class="block mb-2 text-sm font-medium text-gray-900"
>Sales Revenue</label
>
<div class="flex items-center space-x-2">
<app-validated-price
name="salesRevenue-from"
[(ngModel)]="criteria.minRevenue"
placeholder="From"
inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"
></app-validated-price>
<span>-</span>
<app-validated-price
name="salesRevenue-to"
[(ngModel)]="criteria.maxRevenue"
placeholder="To"
inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"
></app-validated-price>
</div>
</div>
<div>
<label
for="cashflow"
class="block mb-2 text-sm font-medium text-gray-900"
>Cashflow</label
>
<div class="flex items-center space-x-2">
<app-validated-price
name="cashflow-from"
[(ngModel)]="criteria.minCashFlow"
placeholder="From"
inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"
></app-validated-price>
<span>-</span>
<app-validated-price
name="cashflow-to"
[(ngModel)]="criteria.maxCashFlow"
placeholder="To"
inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"
></app-validated-price>
</div>
</div>
<div>
<label
for="title"
class="block mb-2 text-sm font-medium text-gray-900"
>Title / Description (Free Search)</label
>
<input
type="text"
id="title"
[(ngModel)]="criteria.title"
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. Restaurant"
/>
</div>
</div>
<div class="space-y-4">
<div>
<label class="block mb-2 text-sm font-medium text-gray-900"
>Category</label
>
<div class="grid grid-cols-2 gap-2">
@for(tob of selectOptions.typesOfBusiness; track tob){
<div class="flex items-center">
<input
type="checkbox"
id="automotive"
[ngModel]="isTypeOfBusinessClicked(tob)"
(ngModelChange)="categoryClicked($event, tob.value)"
value="{{ tob.value }}"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
<label
for="automotive"
class="ml-2 text-sm font-medium text-gray-900"
>{{ tob.name }}</label
>
</div>
}
</div>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-gray-900"
>Type of Property</label
>
<div class="space-y-2">
<div class="flex items-center">
<input
[(ngModel)]="criteria.realEstateChecked"
(ngModelChange)="
onCheckboxChange('realEstateChecked', $event)
"
type="checkbox"
name="realEstateChecked"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
<label
for="realEstateChecked"
class="ml-2 text-sm font-medium text-gray-900"
>Real Estate</label
>
</div>
<div class="flex items-center">
<input
[(ngModel)]="criteria.leasedLocation"
(ngModelChange)="onCheckboxChange('leasedLocation', $event)"
type="checkbox"
name="leasedLocation"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
<label
for="leasedLocation"
class="ml-2 text-sm font-medium text-gray-900"
>Leased Location</label
>
</div>
<div class="flex items-center">
<input
[(ngModel)]="criteria.franchiseResale"
(ngModelChange)="
onCheckboxChange('franchiseResale', $event)
"
type="checkbox"
name="franchiseResale"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
<label
for="franchiseResale"
class="ml-2 text-sm font-medium text-gray-900"
>Franchise</label
>
</div>
</div>
</div>
<div>
<label
for="numberEmployees"
class="block mb-2 text-sm font-medium text-gray-900"
>Number of Employees</label
>
<div class="flex items-center space-x-2">
<input
type="number"
id="numberEmployees-from"
[(ngModel)]="criteria.minNumberEmployees"
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-1/2 p-2.5"
placeholder="From"
/>
<span>-</span>
<input
type="number"
id="numberEmployees-to"
[(ngModel)]="criteria.maxNumberEmployees"
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-1/2 p-2.5"
placeholder="To"
/>
</div>
</div>
<div>
<label
for="establishedSince"
class="block mb-2 text-sm font-medium text-gray-900"
>Established Since</label
>
<div class="flex items-center space-x-2">
<input
type="number"
id="establishedSince-From"
[(ngModel)]="criteria.establishedSince"
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-1/2 p-2.5"
placeholder="YYYY"
/>
<span>-</span>
<input
type="number"
id="establishedSince-To"
[(ngModel)]="criteria.establishedUntil"
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-1/2 p-2.5"
placeholder="YYYY"
/>
</div>
</div>
<div>
<label
for="brokername"
class="block mb-2 text-sm font-medium text-gray-900"
>Broker Name / Company Name</label
>
<input
type="text"
id="brokername"
[(ngModel)]="criteria.brokerName"
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. Brokers Invest"
/>
</div>
</div>
</div>
</div>
<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"
>
Search ({{ numberOfResults$ | async }})
</button>
<button
type="button"
(click)="close()"
class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10"
>
Cancel
</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,9 @@
:host ::ng-deep .ng-select.custom .ng-select-container {
--tw-bg-opacity: 1;
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
height: 46px;
border-radius: 0.5rem;
.ng-value-container .ng-input {
top: 10px;
}
}

View File

@ -0,0 +1,153 @@
import { AsyncPipe, CommonModule, NgIf } from '@angular/common';
import { Component } from '@angular/core';
import { NgSelectModule } from '@ng-select/ng-select';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { debounceTime, Observable, of, Subject, Subscription } from 'rxjs';
import { BusinessListingCriteria, CountyResult, GeoResult, KeyValue, KeyValueStyle } from '../../../../../bizmatch-server/src/models/main.model';
import { ModalService } from '../../services/modal.service';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { CriteriaChangeService } from '../../services/criteria-change.service';
import { ListingsService } from '../../services/listings.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { UserService } from '../../services/user.service';
import { resetBusinessListingCriteria } from '../../utils/utils';
import { ValidatedPriceComponent } from '../validated-price/validated-price.component';
@UntilDestroy()
@Component({
selector: 'app-search-modal',
standalone: true,
imports: [CommonModule, FormsModule, RouterModule, FontAwesomeModule, AsyncPipe, NgIf, NgSelectModule, ValidatedPriceComponent],
templateUrl: './search-modal.component.html',
styleUrl: './search-modal.component.scss',
})
export class SearchModalComponent {
// cities$: Observable<GeoResult[]>;
counties$: Observable<CountyResult[]>;
// cityLoading = false;
countyLoading = false;
// cityInput$ = new Subject<string>();
countyInput$ = new Subject<string>();
private criteriaChangeSubscription: Subscription;
public criteria: BusinessListingCriteria;
backupCriteria: BusinessListingCriteria;
numberOfResults$: Observable<number>;
cancelDisable = false;
constructor(public selectOptions: SelectOptionsService, public modalService: ModalService, private criteriaChangeService: CriteriaChangeService, private listingService: ListingsService, private userService: UserService) {}
ngOnInit() {
this.setupCriteriaChangeListener();
this.modalService.message$.pipe(untilDestroyed(this)).subscribe(msg => {
this.criteria = msg as BusinessListingCriteria;
this.backupCriteria = JSON.parse(JSON.stringify(msg));
this.setTotalNumberOfResults();
});
this.modalService.modalVisible$.pipe(untilDestroyed(this)).subscribe(val => {
if (val) {
this.criteria.page = 1;
this.criteria.start = 0;
}
});
// this.loadCities();
// this.loadCounties();
}
ngOnChanges() {}
categoryClicked(checked: boolean, value: string) {
if (checked) {
this.criteria.types.push(value);
} else {
const index = this.criteria.types.findIndex(t => t === value);
if (index > -1) {
this.criteria.types.splice(index, 1);
}
}
}
// 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)),
// ),
// ),
// ),
// );
// }
setCity(city) {
if (city) {
this.criteria.city = city;
this.criteria.state = city.state;
} else {
this.criteria.city = null;
this.criteria.radius = null;
this.criteria.searchType = 'exact';
}
}
setState(state: string) {
if (state) {
this.criteria.state = state;
} else {
this.criteria.state = null;
this.setCity(null);
}
}
private setupCriteriaChangeListener() {
this.criteriaChangeSubscription = this.criteriaChangeService.criteriaChange$.pipe(debounceTime(400)).subscribe(() => {
this.setTotalNumberOfResults();
this.cancelDisable = true;
});
}
trackByFn(item: GeoResult) {
return item.id;
}
search() {
console.log('Search criteria:', this.criteria);
}
// getCounties() {
// this.geoService.findCountiesStartingWith('');
// }
closeModal() {
console.log('Closing modal');
}
isTypeOfBusinessClicked(v: KeyValueStyle) {
return this.criteria.types.find(t => t === v.value);
}
isTypeOfProfessionalClicked(v: KeyValue) {
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 === 'businessListings' || this.criteria.criteriaType === 'commercialPropertyListings') {
this.numberOfResults$ = this.listingService.getNumberOfListings(this.criteria);
} else if (this.criteria.criteriaType === 'brokerListings') {
this.numberOfResults$ = this.userService.getNumberOfBroker();
} else {
this.numberOfResults$ = of();
}
}
}
clearFilter() {
resetBusinessListingCriteria(this.criteria);
}
close() {
this.modalService.reject(this.backupCriteria);
}
onCheckboxChange(checkbox: string, value: boolean) {
// Deaktivieren Sie alle Checkboxes
(<BusinessListingCriteria>this.criteria).realEstateChecked = false;
(<BusinessListingCriteria>this.criteria).leasedLocation = false;
(<BusinessListingCriteria>this.criteria).franchiseResale = false;
// Aktivieren Sie nur die aktuell ausgewählte Checkbox
this.criteria[checkbox] = value;
}
}

View File

@ -0,0 +1,3 @@
<div [id]="id" role="tooltip" class="max-w-72 w-max absolute z-50 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 dark:bg-gray-700">
{{ text }}
</div>

View File

@ -0,0 +1,29 @@
/* Diese Styles kannst du in deine globale styles.css oder in eine eigene tooltip.component.css-Datei packen */
.tooltip-arrow {
position: absolute;
width: 8px;
height: 8px;
background: inherit;
transform: rotate(45deg);
}
.arrow-top {
top: -4px;
left: calc(50% - 4px);
}
.arrow-right {
right: -4px;
top: calc(50% - 4px);
}
.arrow-bottom {
bottom: -4px;
left: calc(50% - 4px);
}
.arrow-left {
left: -4px;
top: calc(50% - 4px);
}

View File

@ -0,0 +1,167 @@
import { CommonModule } from '@angular/common';
import { AfterViewInit, Component, ElementRef, Input, OnChanges, OnDestroy, Renderer2, SimpleChanges } from '@angular/core';
@Component({
selector: 'app-tooltip',
standalone: true,
imports: [CommonModule],
templateUrl: './tooltip.component.html',
})
export class TooltipComponent implements AfterViewInit, OnDestroy, OnChanges {
@Input() id: string;
@Input() text: string;
@Input() isVisible: boolean = false;
@Input() position: 'top' | 'right' | 'bottom' | 'left' = 'top';
private tooltipElement: HTMLElement | null = null;
private arrowElement: HTMLElement | null = null;
private resizeObserver: ResizeObserver | null = null;
private parentElement: HTMLElement | null = null;
constructor(private elementRef: ElementRef, private renderer: Renderer2) {}
ngAfterViewInit() {
this.tooltipElement = document.getElementById(this.id);
this.parentElement = this.elementRef.nativeElement.parentElement;
if (this.tooltipElement && this.parentElement) {
// Create arrow element
this.arrowElement = this.renderer.createElement('div');
this.renderer.addClass(this.arrowElement, 'tooltip-arrow');
this.renderer.appendChild(this.tooltipElement, this.arrowElement);
// Setup resize observer
this.setupResizeObserver();
// Initial positioning
this.updatePosition();
// Initial visibility
this.updateTooltipVisibility();
}
}
ngOnChanges(changes: SimpleChanges) {
if (changes['isVisible'] && this.tooltipElement) {
this.updateTooltipVisibility();
}
if ((changes['position'] || changes['isVisible']) && this.isVisible) {
setTimeout(() => this.updatePosition(), 0);
}
}
ngOnDestroy() {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
}
private setupResizeObserver() {
if (window.ResizeObserver && this.parentElement) {
this.resizeObserver = new ResizeObserver(() => {
if (this.isVisible) {
this.updatePosition();
}
});
this.resizeObserver.observe(this.parentElement);
if (this.tooltipElement) {
this.resizeObserver.observe(this.tooltipElement);
}
}
}
private updateTooltipVisibility() {
if (!this.tooltipElement) return;
if (this.isVisible) {
this.renderer.removeClass(this.tooltipElement, 'invisible');
this.renderer.removeClass(this.tooltipElement, 'opacity-0');
this.renderer.addClass(this.tooltipElement, 'visible');
this.renderer.addClass(this.tooltipElement, 'opacity-100');
this.updatePosition();
} else {
this.renderer.removeClass(this.tooltipElement, 'visible');
this.renderer.removeClass(this.tooltipElement, 'opacity-100');
this.renderer.addClass(this.tooltipElement, 'invisible');
this.renderer.addClass(this.tooltipElement, 'opacity-0');
}
}
private updatePosition() {
if (!this.tooltipElement || !this.parentElement || !this.arrowElement) return;
const parentRect = this.parentElement.getBoundingClientRect();
const tooltipRect = this.tooltipElement.getBoundingClientRect();
// Reset any previous positioning
this.renderer.removeStyle(this.tooltipElement, 'top');
this.renderer.removeStyle(this.tooltipElement, 'right');
this.renderer.removeStyle(this.tooltipElement, 'bottom');
this.renderer.removeStyle(this.tooltipElement, 'left');
// Reset arrow classes
this.renderer.removeClass(this.arrowElement, 'arrow-top');
this.renderer.removeClass(this.arrowElement, 'arrow-right');
this.renderer.removeClass(this.arrowElement, 'arrow-bottom');
this.renderer.removeClass(this.arrowElement, 'arrow-left');
let top: number = 0;
let left: number = 0;
switch (this.position) {
case 'top':
top = -tooltipRect.height - 8;
left = (parentRect.width - tooltipRect.width) / 2;
this.renderer.addClass(this.arrowElement, 'arrow-bottom');
break;
case 'right':
top = (parentRect.height - tooltipRect.height) / 2;
left = parentRect.width + 8;
this.renderer.addClass(this.arrowElement, 'arrow-left');
break;
case 'bottom':
top = parentRect.height + 8;
left = (parentRect.width - tooltipRect.width) / 2;
this.renderer.addClass(this.arrowElement, 'arrow-top');
break;
case 'left':
top = (parentRect.height - tooltipRect.height) / 2;
left = -tooltipRect.width - 8;
this.renderer.addClass(this.arrowElement, 'arrow-right');
break;
}
// Apply positioning
this.renderer.setStyle(this.tooltipElement, 'top', `${top}px`);
this.renderer.setStyle(this.tooltipElement, 'left', `${left}px`);
// Make sure the tooltip stays within viewport
this.adjustToViewport();
}
private adjustToViewport() {
if (!this.tooltipElement) return;
const tooltipRect = this.tooltipElement.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Adjust horizontal position
if (tooltipRect.left < 0) {
this.renderer.setStyle(this.tooltipElement, 'left', '0px');
} else if (tooltipRect.right > viewportWidth) {
const newLeft = Math.max(0, viewportWidth - tooltipRect.width);
this.renderer.setStyle(this.tooltipElement, 'left', `${newLeft}px`);
}
// Adjust vertical position
if (tooltipRect.top < 0) {
this.renderer.setStyle(this.tooltipElement, 'top', '0px');
} else if (tooltipRect.bottom > viewportHeight) {
const newTop = Math.max(0, viewportHeight - tooltipRect.height);
this.renderer.setStyle(this.tooltipElement, 'top', `${newTop}px`);
}
}
}

View File

@ -0,0 +1,31 @@
<div>
<label for="type" class="block text-sm font-bold text-gray-700 mb-1 relative w-fit {{ labelClasses }}"
>{{ label }} @if(validationMessage){
<div
attr.data-tooltip-target="tooltip-{{ name }}"
class="absolute inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 dark:border-gray-900 hover:cursor-pointer"
(click)="toggleTooltip($event)"
(touchstart)="toggleTooltip($event)"
>
!
</div>
<app-tooltip id="tooltip-{{ name }}" [text]="validationMessage" [isVisible]="isTooltipVisible"></app-tooltip>
}
</label>
<ng-select
class="custom"
[multiple]="false"
[hideSelected]="true"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="cityLoading"
typeToSearchText="Please enter 2 or more characters"
[typeahead]="cityInput$"
ngModel="{{ value?.name }} {{ value ? '-' : '' }} {{ value?.state }}"
(ngModelChange)="onInputChange($event)"
>
@for (city of cities$ | async; track city.id) {
<ng-option [value]="city">{{ city.name }} - {{ city.state }}</ng-option>
}
</ng-select>
</div>

View File

@ -0,0 +1,9 @@
:host ::ng-deep .ng-select.custom .ng-select-container {
// --tw-bg-opacity: 1;
// background-color: rgb(249 250 251 / var(--tw-bg-opacity));
// height: 42px;
border-radius: 0.5rem;
.ng-value-container .ng-input {
top: 10px;
}
}

View File

@ -0,0 +1,70 @@
import { CommonModule } from '@angular/common';
import { Component, forwardRef, Input } from '@angular/core';
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
import { NgSelectModule } from '@ng-select/ng-select';
import { catchError, concat, distinctUntilChanged, Observable, of, Subject, switchMap, tap } from 'rxjs';
import { GeoResult } from '../../../../../bizmatch-server/src/models/main.model';
import { City } from '../../../../../bizmatch-server/src/models/server.model';
import { GeoService } from '../../services/geo.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { BaseInputComponent } from '../base-input/base-input.component';
import { TooltipComponent } from '../tooltip/tooltip.component';
import { ValidationMessagesService } from '../validation-messages.service';
@Component({
selector: 'app-validated-city',
standalone: true,
imports: [CommonModule, FormsModule, NgSelectModule, TooltipComponent],
templateUrl: './validated-city.component.html',
styleUrl: './validated-city.component.scss',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ValidatedCityComponent),
multi: true,
},
],
})
export class ValidatedCityComponent extends BaseInputComponent {
@Input() items;
@Input() labelClasses: string;
@Input() state: string;
cities$: Observable<GeoResult[]>;
cityInput$ = new Subject<string>();
countyInput$ = new Subject<string>();
cityLoading = false;
constructor(validationMessagesService: ValidationMessagesService, private geoService: GeoService, public selectOptions: SelectOptionsService) {
super(validationMessagesService);
}
override ngOnInit() {
super.ngOnInit();
this.loadCities();
}
onInputChange(event: City): void {
this.value = event; //{ ...event, longitude: parseFloat(event.longitude), latitude: parseFloat(event.latitude) };
this.onChange(this.value);
}
private loadCities() {
this.cities$ = concat(
of([]), // default items
this.cityInput$.pipe(
distinctUntilChanged(),
tap(() => (this.cityLoading = true)),
switchMap(term =>
this.geoService.findCitiesStartingWith(term, this.state).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;
}
compareFn = (item, selected) => {
return item.id === selected.id;
};
}

View File

@ -0,0 +1,34 @@
<div>
@if(label){
<label for="type" class="block text-sm font-bold text-gray-700 mb-1 relative w-fit {{ labelClasses }}"
>{{ label }} @if(validationMessage){
<div
attr.data-tooltip-target="tooltip-{{ name }}"
class="absolute inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 dark:border-gray-900 hover:cursor-pointer"
(click)="toggleTooltip($event)"
(touchstart)="toggleTooltip($event)"
>
!
</div>
<app-tooltip id="tooltip-{{ name }}" [text]="validationMessage" [isVisible]="isTooltipVisible"></app-tooltip>
}
</label>
}
<ng-select
class="custom"
[multiple]="false"
[hideSelected]="true"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="countyLoading"
typeToSearchText="Please enter 2 or more characters"
[typeahead]="countyInput$"
ngModel="{{ value }}"
(ngModelChange)="onInputChange($event)"
[readonly]="readonly"
>
@for (county of counties$ | async; track county.id) {
<ng-option [value]="county">{{ county }}</ng-option>
}
</ng-select>
</div>

View File

@ -0,0 +1,9 @@
:host ::ng-deep .ng-select.custom .ng-select-container {
// --tw-bg-opacity: 1;
// background-color: rgb(249 250 251 / var(--tw-bg-opacity));
// height: 42px;
border-radius: 0.5rem;
.ng-value-container .ng-input {
top: 10px;
}
}

View File

@ -0,0 +1,70 @@
import { CommonModule } from '@angular/common';
import { Component, forwardRef, Input } from '@angular/core';
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
import { NgSelectModule } from '@ng-select/ng-select';
import { catchError, concat, distinctUntilChanged, map, Observable, of, Subject, switchMap, tap } from 'rxjs';
import { CountyResult, GeoResult } from '../../../../../bizmatch-server/src/models/main.model';
import { City } from '../../../../../bizmatch-server/src/models/server.model';
import { GeoService } from '../../services/geo.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { BaseInputComponent } from '../base-input/base-input.component';
import { TooltipComponent } from '../tooltip/tooltip.component';
import { ValidationMessagesService } from '../validation-messages.service';
@Component({
selector: 'app-validated-county',
standalone: true,
imports: [CommonModule, FormsModule, NgSelectModule, TooltipComponent],
templateUrl: './validated-county.component.html',
styleUrl: './validated-county.component.scss',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ValidatedCountyComponent),
multi: true,
},
],
})
export class ValidatedCountyComponent extends BaseInputComponent {
@Input() items;
@Input() labelClasses: string;
@Input() state: string;
@Input() readonly = false;
counties$: Observable<CountyResult[]>;
countyLoading = false;
countyInput$ = new Subject<string>();
constructor(validationMessagesService: ValidationMessagesService, private geoService: GeoService, public selectOptions: SelectOptionsService) {
super(validationMessagesService);
}
override ngOnInit() {
super.ngOnInit();
this.loadCounties();
}
onInputChange(event: City): void {
this.value = event; //{ ...event, longitude: parseFloat(event.longitude), latitude: parseFloat(event.latitude) };
this.onChange(this.value);
}
private loadCounties() {
this.counties$ = concat(
of([]), // default items
this.countyInput$.pipe(
distinctUntilChanged(),
tap(() => (this.countyLoading = true)),
switchMap(term =>
this.geoService.findCountiesStartingWith(term, this.state ? [this.state] : null).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)),
),
),
),
);
}
trackByFn(item: GeoResult) {
return item.id;
}
compareFn = (item, selected) => {
return item.id === selected.id;
};
}

View File

@ -0,0 +1,27 @@
<div>
<label [for]="name" class="block text-sm font-bold text-gray-700 mb-1 relative w-fit">
{{ label }}
@if(validationMessage){
<div
attr.data-tooltip-target="tooltip-{{ name }}"
class="absolute inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 dark:border-gray-900 hover:cursor-pointer"
(click)="toggleTooltip($event)"
(touchstart)="toggleTooltip($event)"
>
!
</div>
<app-tooltip id="tooltip-{{ name }}" [text]="validationMessage" [isVisible]="isTooltipVisible"></app-tooltip>
}
</label>
<input
type="text"
[id]="name"
[ngModel]="value"
(ngModelChange)="onInputChange($event)"
(blur)="onTouched()"
[attr.name]="name"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
[mask]="mask"
[dropSpecialCharacters]="false"
/>
</div>

View File

@ -0,0 +1,44 @@
import { CommonModule } from '@angular/common';
import { Component, forwardRef, Input } from '@angular/core';
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
import { NgxMaskDirective, NgxMaskPipe, provideNgxMask } from 'ngx-mask';
import { BaseInputComponent } from '../base-input/base-input.component';
import { TooltipComponent } from '../tooltip/tooltip.component';
import { ValidationMessagesService } from '../validation-messages.service';
@Component({
selector: 'app-validated-input',
templateUrl: './validated-input.component.html',
standalone: true,
imports: [CommonModule, FormsModule, TooltipComponent, NgxMaskDirective, NgxMaskPipe],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ValidatedInputComponent),
multi: true,
},
provideNgxMask(),
],
})
export class ValidatedInputComponent extends BaseInputComponent {
@Input() kind: 'text' | 'number' | 'email' = 'text';
@Input() mask: string;
constructor(validationMessagesService: ValidationMessagesService) {
super(validationMessagesService);
}
onInputChange(event: string | number): void {
if (this.kind === 'number') {
if (typeof event === 'number') {
this.value = event;
} else {
this.value = parseFloat(event);
}
} else {
const text = event as string;
this.value = text?.length > 0 ? event : null;
}
// this.value = event?.length > 0 ? (this.kind === 'number' ? parseFloat(event) : event) : null;
this.onChange(this.value);
}
}

View File

@ -0,0 +1,31 @@
<div>
<label for="type" class="block text-sm font-bold text-gray-700 mb-1 relative w-fit {{ labelClasses }}"
>{{ label }} @if(validationMessage){
<div
attr.data-tooltip-target="tooltip-{{ name }}"
class="absolute inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 dark:border-gray-900 hover:cursor-pointer"
(click)="toggleTooltip($event)"
(touchstart)="toggleTooltip($event)"
>
!
</div>
<app-tooltip id="tooltip-{{ name }}" [text]="validationMessage" [isVisible]="isTooltipVisible"></app-tooltip>
}
</label>
<ng-select
class="custom"
[multiple]="false"
[hideSelected]="true"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="placeLoading"
typeToSearchText="Please enter 2 or more characters"
[typeahead]="placeInput$"
ngModel="{{ formatGeoAddress(value) }}"
(ngModelChange)="onInputChange($event)"
>
@for (place of places$ | async; track place.place_id) {
<ng-option [value]="place">{{ formatPlaceAddress(place) }}</ng-option>
}
</ng-select>
</div>

View File

@ -0,0 +1,9 @@
:host ::ng-deep .ng-select.custom .ng-select-container {
// --tw-bg-opacity: 1;
// background-color: rgb(249 250 251 / var(--tw-bg-opacity));
// height: 42px;
border-radius: 0.5rem;
.ng-value-container .ng-input {
top: 10px;
}
}

View File

@ -0,0 +1,159 @@
import { CommonModule } from '@angular/common';
import { Component, forwardRef, Input } from '@angular/core';
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
import { NgSelectModule } from '@ng-select/ng-select';
import { catchError, concat, debounceTime, distinctUntilChanged, Observable, of, Subject, switchMap, tap } from 'rxjs';
import { GeoResult } from '../../../../../bizmatch-server/src/models/main.model';
import { Place } from '../../../../../bizmatch-server/src/models/server.model';
import { GeoService } from '../../services/geo.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { BaseInputComponent } from '../base-input/base-input.component';
import { TooltipComponent } from '../tooltip/tooltip.component';
import { ValidationMessagesService } from '../validation-messages.service';
@Component({
selector: 'app-validated-location',
standalone: true,
imports: [CommonModule, FormsModule, NgSelectModule, TooltipComponent],
templateUrl: './validated-location.component.html',
styleUrl: './validated-location.component.scss',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ValidatedLocationComponent),
multi: true,
},
],
})
export class ValidatedLocationComponent extends BaseInputComponent {
@Input() items;
@Input() labelClasses: string;
places$: Observable<Place[]>;
placeInput$ = new Subject<string>();
placeLoading = false;
constructor(validationMessagesService: ValidationMessagesService, private geoService: GeoService, public selectOptions: SelectOptionsService) {
super(validationMessagesService);
}
override ngOnInit() {
super.ngOnInit();
this.loadCities();
}
onInputChange(event: Place): void {
this.value = event; //{ ...event, longitude: parseFloat(event.longitude), latitude: parseFloat(event.latitude) };
if (event) {
this.value = {
id: event?.place_id,
name: event?.address.city,
county: event?.address.county,
street: event?.address.road,
housenumber: event?.address.house_number,
state: event?.address['ISO3166-2-lvl4'].substr(3),
latitude: event ? parseFloat(event?.lat) : undefined,
longitude: event ? parseFloat(event?.lon) : undefined,
};
}
this.onChange(this.value);
}
private loadCities() {
this.places$ = concat(
of([]), // default items
this.placeInput$.pipe(
debounceTime(300),
distinctUntilChanged(),
tap(() => (this.placeLoading = true)),
switchMap(term =>
this.geoService.findLocationStartingWith(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.placeLoading = false)),
),
),
),
);
}
trackByFn(item: GeoResult) {
return item.id;
}
compareFn = (item, selected) => {
return item.id === selected.id;
};
formatGeoAddress(geoResult: GeoResult | null | undefined): string {
// Überprüfen, ob geoResult null oder undefined ist
if (!geoResult) {
return '';
}
let addressParts: string[] = [];
// Füge Hausnummer hinzu, wenn vorhanden
if (geoResult.housenumber) {
addressParts.push(geoResult.housenumber);
}
// Füge Straße hinzu, wenn vorhanden
if (geoResult.street) {
addressParts.push(geoResult.street);
}
// Kombiniere Hausnummer und Straße
let address = addressParts.join(' ');
// Füge Namen hinzu, wenn vorhanden
if (geoResult.name) {
address = address ? `${address}, ${geoResult.name}` : geoResult.name;
}
// Füge County hinzu, wenn vorhanden
if (geoResult.county) {
address = address ? `${address}, ${geoResult.county}` : geoResult.county;
}
// Füge Bundesland hinzu, wenn vorhanden
if (geoResult.state) {
address = address ? `${address} - ${geoResult.state}` : geoResult.state;
}
return address;
}
formatPlaceAddress(place: Place | null | undefined): string {
// Überprüfen, ob place null oder undefined ist
if (!place) {
return '';
}
const { house_number, road, city, county, state } = place.address;
let addressParts: string[] = [];
// Füge Hausnummer hinzu, wenn vorhanden
if (house_number) {
addressParts.push(house_number);
}
// Füge Straße hinzu, wenn vorhanden
if (road) {
addressParts.push(road);
}
// Kombiniere Hausnummer und Straße
let address = addressParts.join(' ');
// Füge Stadt hinzu, wenn vorhanden
if (city) {
address = address ? `${address}, ${city}` : city;
}
// Füge County hinzu, wenn vorhanden
if (county) {
address = address ? `${address}, ${county}` : county;
}
// Füge Bundesland hinzu, wenn vorhanden
if (state) {
address = address ? `${address} - ${state}` : state;
}
return address;
}
}

View File

@ -0,0 +1,16 @@
<div>
<label for="type" class="block text-sm font-bold text-gray-700 mb-1 relative w-fit"
>{{ label }} @if(validationMessage){
<div
attr.data-tooltip-target="tooltip-{{ name }}"
class="absolute inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 dark:border-gray-900 hover:cursor-pointer"
(click)="toggleTooltip($event)"
(touchstart)="toggleTooltip($event)"
>
!
</div>
<app-tooltip id="tooltip-{{ name }}" [text]="validationMessage" [isVisible]="isTooltipVisible"></app-tooltip>
}
</label>
<ng-select [items]="items" bindLabel="name" bindValue="value" [(ngModel)]="value" (ngModelChange)="onInputChange($event)" name="type"> </ng-select>
</div>

View File

@ -0,0 +1,31 @@
import { CommonModule } from '@angular/common';
import { Component, forwardRef, Input } from '@angular/core';
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
import { NgSelectModule } from '@ng-select/ng-select';
import { BaseInputComponent } from '../base-input/base-input.component';
import { TooltipComponent } from '../tooltip/tooltip.component';
import { ValidationMessagesService } from '../validation-messages.service';
@Component({
selector: 'app-validated-ng-select',
standalone: true,
imports: [CommonModule, FormsModule, NgSelectModule, TooltipComponent],
templateUrl: './validated-ng-select.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ValidatedNgSelectComponent),
multi: true,
},
],
})
export class ValidatedNgSelectComponent extends BaseInputComponent {
@Input() items;
constructor(validationMessagesService: ValidationMessagesService) {
super(validationMessagesService);
}
onInputChange(event: Event): void {
this.value = event;
this.onChange(this.value);
}
}

View File

@ -0,0 +1,31 @@
<div>
@if(label){
<label [for]="name" class="block text-sm font-bold text-gray-700 mb-1 relative w-fit">
{{ label }}
@if(validationMessage){
<div
attr.data-tooltip-target="tooltip-{{ name }}"
class="absolute inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 dark:border-gray-900 hover:cursor-pointer"
(click)="toggleTooltip($event)"
(touchstart)="toggleTooltip($event)"
>
!
</div>
<app-tooltip id="tooltip-{{ name }}" [text]="validationMessage" [isVisible]="isTooltipVisible"></app-tooltip>
}
</label>
}
<input
type="text"
[id]="name"
[ngModel]="value"
(ngModelChange)="onInputChange($event)"
(blur)="onTouched()"
[attr.name]="name"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 {{ inputClasses }}"
[options]="{ prefix: '$', thousands: ',', decimal: '.', precision: 0, align: 'left' }"
currencyMask
autocomplete="off"
[placeholder]="placeholder"
/>
</div>

View File

@ -0,0 +1,34 @@
import { CommonModule } from '@angular/common';
import { Component, forwardRef, Input } from '@angular/core';
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
import { NgxCurrencyDirective } from 'ngx-currency';
import { BaseInputComponent } from '../base-input/base-input.component';
import { TooltipComponent } from '../tooltip/tooltip.component';
import { ValidationMessagesService } from '../validation-messages.service';
@Component({
selector: 'app-validated-price',
standalone: true,
imports: [CommonModule, FormsModule, TooltipComponent, NgxCurrencyDirective],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ValidatedPriceComponent),
multi: true,
},
],
templateUrl: './validated-price.component.html',
styles: `:host{width:100%}`,
})
export class ValidatedPriceComponent extends BaseInputComponent {
@Input() inputClasses: string;
@Input() placeholder: string = '';
constructor(validationMessagesService: ValidationMessagesService) {
super(validationMessagesService);
}
onInputChange(event: Event): void {
this.value = !event ? null : event;
this.onChange(this.value);
}
}

View File

@ -0,0 +1,15 @@
<label [for]="name" class="block text-sm font-bold text-gray-700 mb-1 relative w-fit">
{{ label }}
@if(validationMessage){
<div
attr.data-tooltip-target="tooltip-{{ name }}"
class="absolute inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 dark:border-gray-900 hover:cursor-pointer"
(click)="toggleTooltip($event)"
(touchstart)="toggleTooltip($event)"
>
!
</div>
<app-tooltip id="tooltip-{{ name }}" [text]="validationMessage" [isVisible]="isTooltipVisible"></app-tooltip>
}
</label>
<quill-editor [(ngModel)]="value" (ngModelChange)="onInputChange($event)" (onBlur)="onTouched()" [id]="name" [attr.name]="name" [modules]="quillModules"></quill-editor>

View File

@ -0,0 +1,37 @@
import { CommonModule } from '@angular/common';
import { Component, forwardRef } from '@angular/core';
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
import { QuillModule } from 'ngx-quill';
import { BaseInputComponent } from '../base-input/base-input.component';
import { TooltipComponent } from '../tooltip/tooltip.component';
import { ValidationMessagesService } from '../validation-messages.service';
@Component({
selector: 'app-validated-quill',
templateUrl: './validated-quill.component.html',
styles: `quill-editor {
width: 100%;
}`,
standalone: true,
imports: [CommonModule, FormsModule, QuillModule, TooltipComponent],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ValidatedQuillComponent),
multi: true,
},
],
})
export class ValidatedQuillComponent extends BaseInputComponent {
quillModules = {
toolbar: [['bold', 'italic', 'underline', 'strike'], [{ list: 'ordered' }, { list: 'bullet' }], [{ header: [1, 2, 3, 4, 5, 6, false] }], [{ color: [] }, { background: [] }], ['clean']],
};
constructor(validationMessagesService: ValidationMessagesService) {
super(validationMessagesService);
}
onInputChange(event: Event): void {
this.value = event;
this.onChange(this.value);
}
}

View File

@ -0,0 +1,30 @@
<div>
<label [for]="name" class="block text-sm font-bold text-gray-700 mb-1 relative w-fit">
{{ label }}
@if(validationMessage){
<div
attr.data-tooltip-target="tooltip-{{ name }}"
class="absolute inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 dark:border-gray-900 hover:cursor-pointer"
(click)="toggleTooltip($event)"
(touchstart)="toggleTooltip($event)"
>
!
</div>
<app-tooltip id="tooltip-{{ name }}" [text]="validationMessage" [isVisible]="isTooltipVisible"></app-tooltip>
}
</label>
<select
[disabled]="disabled"
[id]="name"
[name]="name"
[ngModel]="value"
(change)="onSelectChange($event)"
(blur)="onTouched()"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
>
<option value="" disabled selected>Select an option</option>
<option *ngFor="let option of options" [value]="option.value">
{{ option.label }}
</option>
</select>
</div>

View File

@ -0,0 +1,36 @@
import { CommonModule } from '@angular/common';
import { Component, EventEmitter, forwardRef, Input, Output } from '@angular/core';
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
import { BaseInputComponent } from '../base-input/base-input.component';
import { TooltipComponent } from '../tooltip/tooltip.component';
import { ValidationMessagesService } from '../validation-messages.service';
@Component({
selector: 'app-validated-select',
templateUrl: './validated-select.component.html',
standalone: true,
imports: [CommonModule, FormsModule, TooltipComponent],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ValidatedSelectComponent),
multi: true,
},
],
})
export class ValidatedSelectComponent extends BaseInputComponent {
@Input() options: Array<{ value: any; label: string }> = [];
@Input() disabled = false;
@Output() valueChange = new EventEmitter<any>();
constructor(validationMessagesService: ValidationMessagesService) {
super(validationMessagesService);
}
onSelectChange(event: Event): void {
const value = (event.target as HTMLSelectElement).value;
this.value = value;
this.onChange(value);
this.valueChange.emit(value);
}
}

View File

@ -0,0 +1,17 @@
<div>
<label [for]="name" class="block text-sm font-bold text-gray-700 mb-1 relative w-fit">
{{ label }}
@if(validationMessage){
<div
attr.data-tooltip-target="tooltip-{{ name }}"
class="absolute inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 dark:border-gray-900 hover:cursor-pointer"
(click)="toggleTooltip($event)"
(touchstart)="toggleTooltip($event)"
>
!
</div>
<app-tooltip id="tooltip-{{ name }}" [text]="validationMessage" [isVisible]="isTooltipVisible"></app-tooltip>
}
</label>
<textarea [id]="name" [ngModel]="value" (ngModelChange)="onInputChange($event)" [attr.name]="name" class="w-full p-2 border border-gray-300 rounded-md" rows="3"></textarea>
</div>

View File

@ -0,0 +1,30 @@
import { CommonModule } from '@angular/common';
import { Component, forwardRef } from '@angular/core';
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
import { BaseInputComponent } from '../base-input/base-input.component';
import { TooltipComponent } from '../tooltip/tooltip.component';
import { ValidationMessagesService } from '../validation-messages.service';
@Component({
selector: 'app-validated-textarea',
templateUrl: './validated-textarea.component.html',
standalone: true,
imports: [CommonModule, FormsModule, TooltipComponent],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ValidatedTextareaComponent),
multi: true,
},
],
})
export class ValidatedTextareaComponent extends BaseInputComponent {
constructor(validationMessagesService: ValidationMessagesService) {
super(validationMessagesService);
}
onInputChange(event: string): void {
this.value = event?.length > 0 ? event : null;
this.onChange(this.value);
}
}

View File

@ -0,0 +1,31 @@
import { Injectable, InjectionToken } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
export interface ValidationMessage {
field: string;
message: string;
}
export const VALIDATION_MESSAGES = new InjectionToken<ValidationMessagesService>('VALIDATION_MESSAGES');
@Injectable({
providedIn: 'root',
})
export class ValidationMessagesService {
private messagesSubject = new BehaviorSubject<ValidationMessage[]>([]);
messages$: Observable<ValidationMessage[]> = this.messagesSubject.asObservable();
updateMessages(messages: ValidationMessage[]): void {
this.messagesSubject.next(messages);
}
clearMessages(): void {
this.messagesSubject.next([]);
}
getMessage(field: string): string | null {
const messages = this.messagesSubject.value;
const message = messages.find(m => m.field === field);
return message ? message.message : null;
}
}

View File

@ -0,0 +1,21 @@
import { Injectable } from '@angular/core';
import { CanActivate, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
import { createLogger } from '../utils/utils';
const logger = createLogger('AuthGuard');
@Injectable({
providedIn: 'root',
})
export class AuthGuard implements CanActivate {
constructor(private authService: AuthService, private router: Router) {}
async canActivate(): Promise<boolean> {
const token = await this.authService.getToken();
if (token) {
return true;
} else {
this.router.navigate(['/login-register']);
return false;
}
}
}

View File

@ -0,0 +1,35 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
import { Observable, of } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root',
})
export class ListingCategoryGuard implements CanActivate {
private apiBaseUrl = environment.apiBaseUrl;
constructor(private http: HttpClient, private router: Router) {}
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
const id = route.paramMap.get('id');
const url = `${this.apiBaseUrl}/bizmatch/listings/undefined/${id}`;
return this.http.get<any>(url).pipe(
tap(response => {
const category = response.listingsCategory;
if (category === 'business') {
this.router.navigate(['details-business-listing', id]);
} else if (category === 'commercialProperty') {
this.router.navigate(['details-commercial-property-listing', id]);
} else {
this.router.navigate(['not-found']);
}
}),
catchError(() => {
return of(this.router.createUrlTree(['/not-found']));
}),
);
}
}

View File

@ -0,0 +1,34 @@
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, from } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { environment } from '../../environments/environment';
import { AuthService } from '../services/auth.service';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
constructor(private authService: AuthService) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// Prüfe, ob die Anfrage an die apiBaseUrl gerichtet ist
const isApiRequest = req.url.startsWith(environment.apiBaseUrl);
if (!isApiRequest) {
// Wenn es keine API-Anfrage ist, leite die Anfrage unverändert weiter
return next.handle(req);
}
// Wenn es eine API-Anfrage ist, füge den Token hinzu (falls vorhanden)
return from(this.authService.getToken()).pipe(
switchMap(token => {
if (token) {
const clonedReq = req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
});
return next.handle(clonedReq);
}
return next.handle(req);
}),
);
}
}

View File

@ -0,0 +1,29 @@
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, tap } from 'rxjs';
import { v4 } from 'uuid';
import { LoadingService } from '../services/loading.service';
@Injectable()
export class LoadingInterceptor implements HttpInterceptor {
constructor(private loadingService: LoadingService) {}
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
const hideLoading = request.headers.get('X-Hide-Loading') === 'true';
const requestId = `HTTP-${v4()}`;
if (!hideLoading) {
this.loadingService.startLoading(requestId, request.url);
}
return next.handle(request).pipe(
tap({
finalize: () => {
if (!hideLoading) {
this.loadingService.stopLoading(requestId);
}
},
}),
);
}
}

View File

@ -0,0 +1,34 @@
import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Inject, Injectable, Optional } from '@angular/core';
import { Observable, throwError, TimeoutError } from 'rxjs';
import { catchError, timeout } from 'rxjs/operators';
@Injectable()
export class TimeoutInterceptor implements HttpInterceptor {
constructor(@Optional() @Inject('TIMEOUT_DURATION') private timeoutDuration: number = 5000) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// Überprüfen, ob die URL mit '/ai' endet
if (req.url.endsWith('/ai')) {
return next.handle(req).pipe(
timeout(this.timeoutDuration),
catchError((error: any) => {
if (error instanceof TimeoutError) {
// Timeout error handling
return throwError(
() =>
new HttpErrorResponse({
error: 'Request timed out',
status: 408, // HTTP status code for Request Timeout
}),
);
}
return throwError(() => error);
}),
);
}
// Für alle anderen URLs ohne Timeout fortfahren
return next.handle(req);
}
}

View File

@ -0,0 +1,318 @@
<div class="container mx-auto p-4">
@if (user){
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6">
<form #accountForm="ngForm" class="space-y-4">
<h2 class="text-2xl font-bold mb-4">Account Details</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="md:col-span-2">
<label for="email" class="block text-sm font-medium text-gray-700">E-mail (required)</label>
<input type="email" id="email" name="email" [(ngModel)]="user.email" disabled class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" />
<p class="text-xs text-gray-500 mt-1">You can only modify your email by contacting us at support&#64;bizmatch.net</p>
</div>
@if (isProfessional || (authService.isAdmin() | async)){
<div class="flex flex-row items-center justify-around md:space-x-4">
<div class="flex h-full justify-between flex-col">
<p class="text-sm font-medium text-gray-700 mb-1">Company Logo</p>
<div class="w-20 h-20 w-full rounded-md flex items-center justify-center relative">
@if(user?.hasCompanyLogo){
<img src="{{ companyLogoUrl }}" alt="Company logo" class="max-w-full max-h-full" />
<div class="absolute top-[-0.5rem] right-[0rem] bg-white rounded-full p-1 drop-shadow-custom-bg hover:cursor-pointer" (click)="deleteConfirm('logo')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" class="w-4 h-4 text-gray-600">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
} @else {
<img src="assets/images/placeholder.png" class="max-w-full max-h-full" />
}
</div>
<button
type="button"
class="mt-2 w-full px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
(click)="uploadCompanyLogo()"
>
Upload
</button>
</div>
<div class="flex h-full justify-between flex-col">
<p class="text-sm font-medium text-gray-700 mb-1">Your Profile Picture</p>
<div class="w-20 h-20 w-full rounded-md flex items-center justify-center relative">
@if(user?.hasProfile){
<img src="{{ profileUrl }}" alt="Profile picture" class="max-w-full max-h-full" />
<div class="absolute top-[-0.5rem] right-[0rem] bg-white rounded-full p-1 drop-shadow-custom-bg hover:cursor-pointer" (click)="deleteConfirm('profile')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" class="w-4 h-4 text-gray-600">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
} @else {
<img src="assets/images/placeholder.png" class="max-w-full max-h-full" />
}
</div>
<button
type="button"
class="mt-2 w-full px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
(click)="uploadProfile()"
>
Upload
</button>
</div>
</div>
}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<app-validated-input label="First Name" name="firstname" [(ngModel)]="user.firstname"></app-validated-input>
<app-validated-input label="Last Name" name="lastname" [(ngModel)]="user.lastname"></app-validated-input>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- <div>
<label for="customerType" class="block text-sm font-medium text-gray-700">Customer Type</label>
<select id="customerType" name="customerType" [(ngModel)]="user.customerType" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<option *ngFor="let type of customerTypes" [value]="type">{{ type | titlecase }}</option>
</select>
</div> -->
@if ((authService.isAdmin() | async) && !id){
<div>
<label for="customerType" class="block text-sm font-medium text-gray-700">User Type</label>
<span class="bg-blue-100 text-blue-800 text-sm font-medium me-2 px-2.5 py-0.5 rounded dark:bg-blue-900 dark:text-blue-300">ADMIN</span>
</div>
}
<app-validated-select [disabled]="true" label="Customer Type" name="customerType" [(ngModel)]="user.customerType" [options]="customerTypeOptions"></app-validated-select>
@if (isProfessional){
<!-- <div>
<label for="customerSubType" class="block text-sm font-medium text-gray-700">Professional Type</label>
<select id="customerSubType" name="customerSubType" [(ngModel)]="user.customerSubType" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<option *ngFor="let subType of customerSubTypes" [value]="subType">{{ subType | titlecase }}</option>
</select>
</div> -->
<app-validated-select label="Professional Type" name="customerSubType" [(ngModel)]="user.customerSubType" [options]="customerSubTypeOptions"></app-validated-select>
}
</div>
@if (isProfessional){
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- <div>
<label for="companyName" class="block text-sm font-medium text-gray-700">Company Name</label>
<input type="text" id="companyName" name="companyName" [(ngModel)]="user.companyName" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" />
</div> -->
<!-- <div>
<label for="description" class="block text-sm font-medium text-gray-700">Describe yourself</label>
<input type="text" id="description" name="description" [(ngModel)]="user.description" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" />
</div> -->
<app-validated-input label="Company Name" name="companyName" [(ngModel)]="user.companyName"></app-validated-input>
<app-validated-input label="Describe Yourself" name="description" [(ngModel)]="user.description"></app-validated-input>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- <div>
<label for="phoneNumber" class="block text-sm font-medium text-gray-700">Your Phone Number</label>
<input type="tel" id="phoneNumber" name="phoneNumber" [(ngModel)]="user.phoneNumber" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" />
</div>
<div>
<label for="companyWebsite" class="block text-sm font-medium text-gray-700">Company Website</label>
<input type="url" id="companyWebsite" name="companyWebsite" [(ngModel)]="user.companyWebsite" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" />
</div>
<div>
<label for="companyLocation" class="block text-sm font-medium text-gray-700">Company Location</label>
<input type="text" id="companyLocation" name="companyLocation" [(ngModel)]="user.companyLocation" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" />
</div> -->
<app-validated-input label="Your Phone Number" name="phoneNumber" [(ngModel)]="user.phoneNumber" mask="(000) 000-0000"></app-validated-input>
<app-validated-input label="Company Website" name="companyWebsite" [(ngModel)]="user.companyWebsite"></app-validated-input>
<!-- <app-validated-input label="Company Location" name="companyLocation" [(ngModel)]="user.companyLocation"></app-validated-input> -->
<!-- <app-validated-city label="Company Location" name="location" [(ngModel)]="user.location"></app-validated-city> -->
<app-validated-location label="Company Location" name="location" [(ngModel)]="user.location"></app-validated-location>
</div>
<!-- <div>
<label for="companyOverview" class="block text-sm font-medium text-gray-700">Company Overview</label>
<quill-editor [(ngModel)]="user.companyOverview" name="companyOverview" [modules]="quillModules"></quill-editor>
</div> -->
<div>
<app-validated-quill label="Company Overview" name="companyOverview" [(ngModel)]="user.companyOverview"></app-validated-quill>
</div>
<div>
<!-- <label for="offeredServices" class="block text-sm font-medium text-gray-700">Services We Offer</label>
<quill-editor [(ngModel)]="user.offeredServices" name="offeredServices" [modules]="quillModules"></quill-editor> -->
<app-validated-quill label="Services We Offer" name="offeredServices" [(ngModel)]="user.offeredServices"></app-validated-quill>
</div>
<div>
<h3 class="text-lg font-medium text-gray-700 mb-2 relative w-fit">
Areas We Serve @if(getValidationMessage('areasServed')){
<div
[attr.data-tooltip-target]="tooltipTargetAreasServed"
class="absolute inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 dark:border-gray-900 hover:cursor-pointer"
>
!
</div>
<app-tooltip [id]="tooltipTargetAreasServed" [text]="getValidationMessage('areasServed')"></app-tooltip>
}
</h3>
<div class="grid grid-cols-12 gap-4">
<div class="col-span-6">
<label for="state" class="block text-sm font-medium text-gray-700">State</label>
</div>
<div class="col-span-5">
<label for="county" class="block text-sm font-medium text-gray-700">County</label>
</div>
</div>
@for (areasServed of user.areasServed; track areasServed; let i=$index){
<div class="grid grid-cols-12 md:gap-4 gap-1 mb-3 md:mb-1">
<div class="col-span-6">
<ng-select [items]="selectOptions?.states" bindLabel="name" bindValue="value" [(ngModel)]="areasServed.state" (ngModelChange)="setState(i, $event)" name="areasServed_state{{ i }}"> </ng-select>
</div>
<div class="col-span-5">
<!-- <input type="text" id="county{{ i }}" name="county{{ i }}" [(ngModel)]="areasServed.county" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" /> -->
<app-validated-county name="county{{ i }}" [(ngModel)]="areasServed.county" labelClasses="text-gray-900 font-medium" [state]="areasServed.state" [readonly]="!areasServed.state"></app-validated-county>
</div>
<div class="col-span-1">
<button type="button" class="px-2 py-1 bg-red-500 text-white rounded-md h-[42px] w-8" (click)="removeArea(i)">-</button>
</div>
</div>
}
<div class="mt-2">
<button type="button" class="px-2 py-1 bg-green-500 text-white rounded-md mr-2 h-[42px] w-8" (click)="addArea()">+</button>
<span class="text-sm text-gray-500 ml-2">[Add more Areas or remove existing ones.]</span>
</div>
</div>
<div>
<h3 class="text-lg font-medium text-gray-700 mb-2 relative">
Licensed In@if(getValidationMessage('licensedIn')){
<div
[attr.data-tooltip-target]="tooltipTargetLicensed"
class="absolute inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 dark:border-gray-900 hover:cursor-pointer"
>
!
</div>
<app-tooltip [id]="tooltipTargetLicensed" [text]="getValidationMessage('licensedIn')"></app-tooltip>
}
</h3>
<div class="grid grid-cols-12 gap-4">
<div class="col-span-6">
<label for="state" class="block text-sm font-medium text-gray-700">State</label>
</div>
<div class="col-span-5">
<label for="county" class="block text-sm font-medium text-gray-700">License Number</label>
</div>
</div>
@for (licensedIn of user.licensedIn; track licensedIn; let i=$index){
<div class="grid grid-cols-12 md:gap-4 gap-1 mb-3 md:mb-1">
<div class="col-span-6">
<ng-select [items]="selectOptions?.states" bindLabel="name" bindValue="value" [(ngModel)]="licensedIn.state" name="licensedIn_state{{ i }}"> </ng-select>
</div>
<div class="col-span-5">
<input
type="text"
id="licenseNumber{{ i }}"
name="licenseNumber{{ i }}"
[(ngModel)]="licensedIn.registerNo"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<button type="button" class="px-2 py-1 bg-red-500 text-white rounded-md h-[42px] w-8" (click)="removeLicence(i)">-</button>
</div>
}
<div class="mt-2">
<button type="button" class="px-2 py-1 bg-green-500 text-white rounded-md mr-2 h-[42px] w-8" (click)="addLicence()">+</button>
<span class="text-sm text-gray-500 ml-2">[Add more licenses or remove existing ones.]</span>
</div>
</div>
}
<!-- <div class="flex items-center !my-8">
<label class="flex items-center cursor-pointer">
<div class="relative">
<input type="checkbox" [(ngModel)]="user.showInDirectory" name="showInDirectory" class="hidden" />
<div class="toggle-bg block w-12 h-6 rounded-full bg-gray-600 transition"></div>
</div>
<div class="ml-3 text-gray-700 font-medium">Show your profile in Professional Directory</div>
</label>
</div> -->
<div class="flex justify-start">
<button type="submit" class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" (click)="updateProfile(user)">
Update Profile
</button>
</div>
</form>
<!-- <div class="mt-8 max-lg:hidden">
<h3 class="text-lg font-medium text-gray-700 mb-2">Membership Level</h3>
<div class="overflow-x-auto">
<div class="inline-block min-w-full">
<div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Level</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Start Date</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">End Date</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Next Settlement</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@for (subscription of subscriptions; track subscriptions; let i=$index){
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ getLevel(i) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ getStartDate(i) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ getEndDate(i) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ getNextSettlement(i) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ getStatus(i) }}</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="mt-8 sm:hidden">
<h3 class="text-lg font-medium text-gray-700 mb-1">Membership Level</h3>
<div class="space-y-2">
@for (subscription of subscriptions; track subscriptions; let i=$index){
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
<div class="px-4 py-5 sm:px-6">
<dl class="grid grid-cols-1 gap-x-4 gap-y-2 sm:grid-cols-2">
<div class="sm:col-span-1 flex">
<dt class="text-sm font-bold text-gray-500 mr-2">Level</dt>
<dd class="text-sm text-gray-900">{{ getLevel(i) }}</dd>
</div>
<div class="sm:col-span-1 flex">
<dt class="text-sm font-bold text-gray-500 mr-2">Start Date</dt>
<dd class="text-sm text-gray-900">{{ getStartDate(i) }}</dd>
</div>
<div class="sm:col-span-1 flex">
<dt class="text-sm font-bold text-gray-500 mr-2">End Date</dt>
<dd class="text-sm text-gray-900">{{ getEndDate(i) }}</dd>
</div>
<div class="sm:col-span-1 flex">
<dt class="text-sm font-bold text-gray-500 mr-2">Next Settlement</dt>
<dd class="text-sm text-gray-900">{{ getNextSettlement(i) }}</dd>
</div>
<div class="sm:col-span-1 flex">
<dt class="text-sm font-bold text-gray-500 mr-2">Status</dt>
<dd class="text-sm text-gray-900">{{ getStatus(i) }}</dd>
</div>
</dl>
</div>
</div>
}
</div>
</div> -->
<!-- @if(user.subscriptionPlan==='free'){
<div class="flex justify-start">
<button
routerLink="/pricing"
class="py-2.5 px-5 me-2 mb-2 text-sm font-medium text-white focus:outline-none bg-green-500 rounded-lg border border-gray-400 hover:bg-green-600 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
>
Upgrade Subscription Plan
</button>
</div>
} -->
</div>
}
</div>
<app-image-crop-and-upload [uploadParams]="uploadParams" (uploadFinished)="uploadFinished($event)"></app-image-crop-and-upload>
<app-confirmation></app-confirmation>

View File

@ -0,0 +1,42 @@
.rounded-logo {
border-radius: 6px;
width: 120px;
height: 30px;
border: 1px solid #6b7280;
padding: 1px 1px;
object-fit: contain;
}
.rounded-profile {
// @extend .rounded-logo;
max-width: 100px;
max-height: 120px;
border-radius: 6px;
border: 1px solid #6b7280;
padding: 1px 1px;
object-fit: contain;
}
.wfull {
width: 100%;
}
.image-wrap {
position: relative; /* Ermöglicht die absolute Positionierung des Icons bezogen auf diesen Container */
display: inline-block; /* Erlaubt die Inline-Anordnung, falls mehrere Bilder vorhanden sind */
}
/* Stil für das FontAwesome Icon */
.image-wrap fa-icon {
position: absolute;
top: -5px; /* Positioniert das Icon am oberen Rand des Bildes */
right: -18px; /* Positioniert das Icon am rechten Rand des Bildes */
color: #fff; /* Weiße Farbe für das Icon */
background-color: rgba(0, 0, 0, 0.5); /* Halbtransparenter Hintergrund für bessere Sichtbarkeit */
padding: 5px; /* Ein wenig Platz um das Icon */
cursor: pointer; /* Verwandelt den Cursor in eine Hand, um Interaktivität anzudeuten */
border-radius: 8px; /* Optional: Abrunden der linken unteren Ecke für ästhetische Zwecke */
font-size: 0.7rem;
}
quill-editor {
width: 100%;
}
:host ::ng-deep .ng-select.ng-select-single .ng-select-container {
height: 42px;
}

View File

@ -0,0 +1,257 @@
import { CommonModule, DatePipe, TitleCasePipe } from '@angular/common';
import { ChangeDetectorRef, Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { faTrash } from '@fortawesome/free-solid-svg-icons';
import { NgSelectModule } from '@ng-select/ng-select';
import { NgxCurrencyDirective } from 'ngx-currency';
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, UploadParams, emailToDirName } from '../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../environments/environment';
import { ConfirmationComponent } from '../../components/confirmation.component';
import { ImageCropAndUploadComponent, UploadReponse } from '../../components/image-crop-and-upload/image-crop-and-upload.component';
import { MessageComponent } from '../../components/message/message.component';
import { TooltipComponent } from '../../components/tooltip/tooltip.component';
import { ValidatedCityComponent } from '../../components/validated-city/validated-city.component';
import { ValidatedCountyComponent } from '../../components/validated-county/validated-county.component';
import { ValidatedInputComponent } from '../../components/validated-input/validated-input.component';
import { ValidatedLocationComponent } from '../../components/validated-location/validated-location.component';
import { ValidatedQuillComponent } from '../../components/validated-quill/validated-quill.component';
import { ValidatedSelectComponent } from '../../components/validated-select/validated-select.component';
import { ValidationMessage, ValidationMessagesService } from '../../components/validation-messages.service';
import { AuthService } from '../../services/auth.service';
import { ConfirmationService } from '../../services/confirmation.service';
import { GeoService } from '../../services/geo.service';
import { ImageService } from '../../services/image.service';
import { LoadingService } from '../../services/loading.service';
import { MessageService } from '../../services/message.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { SharedService } from '../../services/shared.service';
import { UserService } from '../../services/user.service';
import { TOOLBAR_OPTIONS, map2User } from '../../utils/utils';
@Component({
selector: 'app-account',
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
FontAwesomeModule,
QuillModule,
NgxCurrencyDirective,
NgSelectModule,
ImageCropperComponent,
ConfirmationComponent,
ImageCropAndUploadComponent,
MessageComponent,
ValidatedInputComponent,
ValidatedSelectComponent,
ValidatedQuillComponent,
ValidatedCityComponent,
TooltipComponent,
ValidatedCountyComponent,
ValidatedLocationComponent,
],
providers: [TitleCasePipe, DatePipe],
templateUrl: './account.component.html',
styleUrl: './account.component.scss',
})
export class AccountComponent {
id: string | undefined;
user: User;
companyLogoUrl: string;
profileUrl: string;
type: 'company' | 'profile';
environment = environment;
editorModules = TOOLBAR_OPTIONS;
env = environment;
faTrash = faTrash;
quillModules = {
toolbar: [['bold', 'italic', 'underline', 'strike'], [{ list: 'ordered' }, { list: 'bullet' }], [{ header: [1, 2, 3, 4, 5, 6, false] }], [{ color: [] }, { background: [] }], ['clean']],
};
uploadParams: UploadParams;
validationMessages: ValidationMessage[] = [];
customerTypeOptions: Array<{ value: string; label: string }> = [];
customerSubTypeOptions: Array<{ value: string; label: string }> = [];
tooltipTargetAreasServed = 'tooltip-areasServed';
tooltipTargetLicensed = 'tooltip-licensedIn';
// subscriptions: StripeSubscription[] | any[];
constructor(
public userService: UserService,
private geoService: GeoService,
public selectOptions: SelectOptionsService,
private cdref: ChangeDetectorRef,
private activatedRoute: ActivatedRoute,
private loadingService: LoadingService,
private imageUploadService: ImageService,
private imageService: ImageService,
private confirmationService: ConfirmationService,
private messageService: MessageService,
private sharedService: SharedService,
private titleCasePipe: TitleCasePipe,
private validationMessagesService: ValidationMessagesService,
// private subscriptionService: SubscriptionsService,
private datePipe: DatePipe,
private router: Router,
public authService: AuthService,
) {}
async ngOnInit() {
this.id = this.activatedRoute.snapshot.params['id'] as string | undefined;
if (this.id) {
this.user = await this.userService.getById(this.id);
} else {
const token = await this.authService.getToken();
const keycloakUser = map2User(token);
const email = keycloakUser.email;
this.user = await this.userService.getByMail(email);
}
// this.subscriptions = await lastValueFrom(this.subscriptionService.getAllSubscriptions(this.user.email));
// await this.synchronizeSubscriptions(this.subscriptions);
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`;
this.customerTypeOptions = this.selectOptions.customerTypes
// .filter(ct => ct.value === 'buyer' || ct.value === 'seller' || this.user.customerType === 'professional')
.map(type => ({
value: type.value,
label: this.titleCasePipe.transform(type.name),
}));
this.customerSubTypeOptions = this.selectOptions.customerSubTypes
// .filter(ct => ct.value !== 'broker' || this.user.customerSubType === 'broker')
.map(type => ({
value: type.value,
label: this.titleCasePipe.transform(type.name),
}));
}
ngOnDestroy() {
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
}
printInvoice(invoice: Invoice) {}
async updateProfile(user: User) {
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 = [];
} catch (error) {
this.messageService.addMessage({
severity: 'danger',
text: 'An error occurred while saving the profile - Please check your inputs',
duration: 5000,
});
if (error.error && Array.isArray(error.error?.message)) {
this.validationMessagesService.updateMessages(error.error.message);
this.validationMessages = error.error.message;
}
}
}
onUploadCompanyLogo(event: any) {
const uniqueSuffix = '?_ts=' + new Date().getTime();
this.companyLogoUrl = `${this.env.imageBaseUrl}/pictures/logo/${emailToDirName(this.user.email)}${uniqueSuffix}`;
}
onUploadProfilePicture(event: any) {
const uniqueSuffix = '?_ts=' + new Date().getTime();
this.profileUrl = `${this.env.imageBaseUrl}/pictures/profile/${emailToDirName(this.user.email)}${uniqueSuffix}`;
}
setImageToFallback(event: Event) {
(event.target as HTMLImageElement).src = `/assets/images/placeholder.png`; // Pfad zum Platzhalterbild
}
suggestions: string[] | undefined;
async search(event: AutoCompleteCompleteEvent) {
const result = await lastValueFrom(this.geoService.findCitiesStartingWith(event.query));
this.suggestions = result.map(r => `${r.name} - ${r.state}`).slice(0, 5);
}
addLicence() {
this.user.licensedIn.push({ registerNo: '', state: '' });
}
removeLicence(index: number) {
this.user.licensedIn.splice(index, 1);
}
addArea() {
this.user.areasServed.push({ county: '', state: '' });
}
removeArea(index: number) {
this.user.areasServed.splice(index, 1);
}
get isProfessional() {
return this.user.customerType === 'professional';
}
uploadCompanyLogo() {
this.uploadParams = { type: 'uploadCompanyLogo', imagePath: emailToDirName(this.user.email) };
}
uploadProfile() {
this.uploadParams = { type: 'uploadProfile', imagePath: emailToDirName(this.user.email) };
}
async uploadFinished(response: UploadReponse) {
if (response.success) {
if (response.type === 'uploadCompanyLogo') {
this.user.hasCompanyLogo = true; //
this.companyLogoUrl = `${this.env.imageBaseUrl}/pictures/logo/${emailToDirName(this.user.email)}.avif?_ts=${new Date().getTime()}`;
} else {
this.user.hasProfile = true;
this.profileUrl = `${this.env.imageBaseUrl}/pictures/profile/${emailToDirName(this.user.email)}.avif?_ts=${new Date().getTime()}`;
this.sharedService.changeProfilePhoto(this.profileUrl);
}
this.userService.changeUser(this.user);
await this.userService.saveGuaranteed(this.user);
}
}
async deleteConfirm(type: 'profile' | 'logo') {
const confirmed = await this.confirmationService.showConfirmation({ message: `Do you want to delete your ${type === 'logo' ? 'Logo' : 'Profile'} image` });
if (confirmed) {
if (type === 'profile') {
this.user.hasProfile = false;
await Promise.all([this.imageService.deleteProfileImagesByMail(this.user.email), this.userService.saveGuaranteed(this.user)]);
} else {
this.user.hasCompanyLogo = false;
await Promise.all([this.imageService.deleteLogoImagesByMail(this.user.email), this.userService.saveGuaranteed(this.user)]);
}
this.user = await this.userService.getById(this.user.id);
// this.messageService.showMessage('Image deleted');
this.messageService.addMessage({
severity: 'success',
text: 'Image deleted.',
duration: 3000, // 3 seconds
});
}
}
getValidationMessage(fieldName: string): string {
const message = this.validationMessages.find(msg => msg.field === fieldName);
return message ? message.message : '';
}
setState(index: number, state: string) {
if (state === null) {
this.user.areasServed[index].county = null;
}
}
// getLevel(i: number) {
// return this.subscriptions[i].metadata.plan;
// }
// getStartDate(i: number) {
// return this.datePipe.transform(new Date(this.subscriptions[i].start_date * 1000));
// }
// getEndDate(i: number) {
// return this.subscriptions[i].status === 'trialing' ? this.datePipe.transform(new Date(this.subscriptions[i].current_period_end * 1000)) : '---';
// }
// getNextSettlement(i: number) {
// return this.subscriptions[i].status === 'active' ? this.datePipe.transform(new Date(this.subscriptions[i].current_period_end * 1000)) : '---';
// }
// getStatus(i: number) {
// return this.subscriptions[i].status ? this.subscriptions[i].status : '';
// }
}

View File

@ -0,0 +1,110 @@
<div class="container mx-auto p-4">
@if(listings?.length>0){
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Anzahl der Spalten auf 3 reduziert und den Abstand erhöht -->
@for (listing of listings; track listing.id) {
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg overflow-hidden hover:shadow-xl">
<!-- Hover-Effekt hinzugefügt -->
<div class="p-6 flex flex-col h-full relative z-[0]">
<div class="flex items-center mb-4">
<i [class]="selectOptions.getIconAndTextColorType(listing.type)" class="mr-2 text-xl"></i>
<!-- Icon vergrößert -->
<span [class]="selectOptions.getTextColorType(listing.type)" class="font-bold text-lg">{{ selectOptions.getBusiness(listing.type) }}</span>
<!-- Schriftgröße erhöht -->
</div>
<h2 class="text-xl font-semibold mb-4">
<!-- Überschrift vergrößert -->
{{ listing.title }}
@if(listing.draft){
<span class="bg-red-100 text-red-800 text-sm font-medium me-2 ml-2 px-2.5 py-0.5 rounded dark:bg-red-900 dark:text-red-300">Draft</span>
}
</h2>
<div class="flex justify-between">
<!-- State Badge -->
<span class="w-fit inline-flex items-center justify-center px-2 py-1 mb-4 text-xs font-bold leading-none bg-gray-200 text-gray-700 rounded-full">
{{ selectOptions.getState(listing.location.state) }}
</span>
<p class="text-sm text-gray-600 mb-4">
<strong>{{ getDaysListed(listing) }} days listed</strong>
</p>
</div>
<!-- Asking Price hervorgehoben -->
<p class="text-base font-bold text-gray-800 mb-2">
<strong>Asking price:</strong> <span class="text-green-600"> {{ listing.price | currency : 'USD' : 'symbol' : '1.0-0' }}</span>
</p>
<p class="text-sm text-gray-600 mb-2"><strong>Sales revenue:</strong> {{ listing.salesRevenue | currency : 'USD' : 'symbol' : '1.0-0' }}</p>
<p class="text-sm text-gray-600 mb-2"><strong>Net profit:</strong> {{ listing.cashFlow | currency : 'USD' : 'symbol' : '1.0-0' }}</p>
<p class="text-sm text-gray-600 mb-2"><strong>Location:</strong> {{ listing.location.name ? listing.location.name : listing.location.county }}</p>
<p class="text-sm text-gray-600 mb-4"><strong>Established:</strong> {{ listing.established }}</p>
<img src="{{ env.imageBaseUrl }}/pictures/logo/{{ listing.imageName }}.avif?_ts={{ ts }}" alt="Company logo" class="absolute bottom-[80px] right-[20px] h-[45px] w-auto" />
<!-- Position und Größe des Bildes angepasst -->
<div class="flex-grow"></div>
<button class="bg-green-500 hover:bg-green-600 text-white px-5 py-3 rounded-full w-full flex items-center justify-center mt-4 transition-colors duration-200" [routerLink]="['/details-business-listing', listing.id]">
<!-- Button-Größe und Hover-Effekt verbessert -->
View Full Listing
<i class="fas fa-arrow-right ml-2"></i>
</button>
</div>
</div>
}
</div>
} @else if (listings?.length===0){
<div class="w-full flex items-center flex-wrap justify-center gap-10">
<div class="grid gap-4 w-60">
<svg class="mx-auto" xmlns="http://www.w3.org/2000/svg" width="154" height="161" viewBox="0 0 154 161" fill="none">
<path
d="M0.0616455 84.4268C0.0616455 42.0213 34.435 7.83765 76.6507 7.83765C118.803 7.83765 153.224 42.0055 153.224 84.4268C153.224 102.42 147.026 118.974 136.622 132.034C122.282 150.138 100.367 161 76.6507 161C52.7759 161 30.9882 150.059 16.6633 132.034C6.25961 118.974 0.0616455 102.42 0.0616455 84.4268Z"
fill="#EEF2FF"
/>
<path
d="M96.8189 0.632498L96.8189 0.632384L96.8083 0.630954C96.2034 0.549581 95.5931 0.5 94.9787 0.5H29.338C22.7112 0.5 17.3394 5.84455 17.3394 12.4473V142.715C17.3394 149.318 22.7112 154.662 29.338 154.662H123.948C130.591 154.662 135.946 149.317 135.946 142.715V38.9309C135.946 38.0244 135.847 37.1334 135.648 36.2586L135.648 36.2584C135.117 33.9309 133.874 31.7686 132.066 30.1333C132.066 30.1331 132.065 30.1329 132.065 30.1327L103.068 3.65203C103.068 3.6519 103.067 3.65177 103.067 3.65164C101.311 2.03526 99.1396 0.995552 96.8189 0.632498Z"
fill="white"
stroke="#E5E7EB"
/>
<ellipse cx="80.0618" cy="81" rx="28.0342" ry="28.0342" fill="#EEF2FF" />
<path
d="M99.2393 61.3061L99.2391 61.3058C88.498 50.5808 71.1092 50.5804 60.3835 61.3061C49.6423 72.0316 49.6422 89.4361 60.3832 100.162C71.109 110.903 88.4982 110.903 99.2393 100.162C109.965 89.4363 109.965 72.0317 99.2393 61.3061ZM105.863 54.6832C120.249 69.0695 120.249 92.3985 105.863 106.785C91.4605 121.171 68.1468 121.171 53.7446 106.785C39.3582 92.3987 39.3582 69.0693 53.7446 54.683C68.1468 40.2965 91.4605 40.2966 105.863 54.6832Z"
stroke="#E5E7EB"
/>
<path d="M110.782 119.267L102.016 110.492C104.888 108.267 107.476 105.651 109.564 102.955L118.329 111.729L110.782 119.267Z" stroke="#E5E7EB" />
<path
d="M139.122 125.781L139.122 125.78L123.313 109.988C123.313 109.987 123.313 109.987 123.312 109.986C121.996 108.653 119.849 108.657 118.521 109.985L118.871 110.335L118.521 109.985L109.047 119.459C107.731 120.775 107.735 122.918 109.044 124.247L109.047 124.249L124.858 140.06C128.789 143.992 135.191 143.992 139.122 140.06C143.069 136.113 143.069 129.728 139.122 125.781Z"
fill="#A5B4FC"
stroke="#818CF8"
/>
<path
d="M83.185 87.2285C82.5387 87.2285 82.0027 86.6926 82.0027 86.0305C82.0027 83.3821 77.9987 83.3821 77.9987 86.0305C77.9987 86.6926 77.4627 87.2285 76.8006 87.2285C76.1543 87.2285 75.6183 86.6926 75.6183 86.0305C75.6183 80.2294 84.3831 80.2451 84.3831 86.0305C84.3831 86.6926 83.8471 87.2285 83.185 87.2285Z"
fill="#4F46E5"
/>
<path
d="M93.3528 77.0926H88.403C87.7409 77.0926 87.2049 76.5567 87.2049 75.8946C87.2049 75.2483 87.7409 74.7123 88.403 74.7123H93.3528C94.0149 74.7123 94.5509 75.2483 94.5509 75.8946C94.5509 76.5567 94.0149 77.0926 93.3528 77.0926Z"
fill="#4F46E5"
/>
<path
d="M71.5987 77.0925H66.6488C65.9867 77.0925 65.4507 76.5565 65.4507 75.8945C65.4507 75.2481 65.9867 74.7122 66.6488 74.7122H71.5987C72.245 74.7122 72.781 75.2481 72.781 75.8945C72.781 76.5565 72.245 77.0925 71.5987 77.0925Z"
fill="#4F46E5"
/>
<rect x="38.3522" y="21.5128" width="41.0256" height="2.73504" rx="1.36752" fill="#4F46E5" />
<rect x="38.3522" y="133.65" width="54.7009" height="5.47009" rx="2.73504" fill="#A5B4FC" />
<rect x="38.3522" y="29.7179" width="13.6752" height="2.73504" rx="1.36752" fill="#4F46E5" />
<circle cx="56.13" cy="31.0854" r="1.36752" fill="#4F46E5" />
<circle cx="61.6001" cy="31.0854" r="1.36752" fill="#4F46E5" />
<circle cx="67.0702" cy="31.0854" r="1.36752" fill="#4F46E5" />
</svg>
<div>
<h2 class="text-center text-black text-xl font-semibold leading-loose pb-2">Theres no listing here</h2>
<p class="text-center text-black text-base font-normal leading-relaxed pb-4">Try changing your filters to <br />see listings</p>
<div class="flex gap-3">
<button (click)="clearAllFilters()" class="w-full px-3 py-2 rounded-full border border-gray-300 text-gray-900 text-xs font-semibold leading-4">Clear Filter</button>
<button (click)="openFilterModal()" class="w-full px-3 py-2 bg-indigo-600 hover:bg-indigo-700 transition-all duration-500 rounded-full text-white text-xs font-semibold leading-4">Change Filter</button>
</div>
</div>
</div>
</div>
}
</div>
@if(pageCount>1){
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator>
}

View File

@ -0,0 +1,32 @@
// #sky-line {
// background-image: url(../../../../assets/images/bw-sky.jpg);
// height: 204px;
// background-position: bottom;
// background-size: cover;
// margin-bottom: -1px;
// }
// .search {
// background-color: #343f69;
// }
// ::ng-deep p-paginator div {
// background-color: var(--surface-200) !important;
// }
// .icon-pos {
// position: absolute;
// bottom: 1.5rem;
// right: 1.5rem;
// }
// .rounded-image {
// border-radius: 6px;
// max-width: 100px;
// height: 35px;
// border: 1px solid rgba(0, 0, 0, 0.2);
// padding: 1px 1px;
// object-fit: contain;
// }
// ::ng-deep span.p-button-label {
// font-weight: 500;
// }
:host {
width: 100%;
}

View File

@ -0,0 +1,118 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import dayjs from 'dayjs';
import { BusinessListing } from '../../../../../bizmatch-server/src/models/db.model';
import { BusinessListingCriteria, emailToDirName, LISTINGS_PER_PAGE, ListingType } from '../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../environments/environment';
import { PaginatorComponent } from '../../components/paginator/paginator.component';
import { CriteriaChangeService } from '../../services/criteria-change.service';
import { ListingsService } from '../../services/listings.service';
import { ModalService } from '../../services/modal.service';
import { SearchService } from '../../services/search.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { assignProperties, getCriteriaProxy, resetBusinessListingCriteria } from '../../utils/utils';
@UntilDestroy()
@Component({
selector: 'app-business-listings',
standalone: true,
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent],
templateUrl: './business-listings.component.html',
styleUrls: ['./business-listings.component.scss'],
})
export class BusinessListingsComponent {
environment = environment;
listings: Array<BusinessListing>;
filteredListings: Array<BusinessListing>;
criteria: BusinessListingCriteria;
realEstateChecked: boolean;
maxPrice: string;
minPrice: string;
type: string;
state: string;
totalRecords: number = 0;
ts = new Date().getTime();
first: number = 0;
rows: number = 12;
env = environment;
public category: 'business' | 'commercialProperty' | 'professionals_brokers' | undefined;
page = 1;
pageCount = 1;
emailToDirName = emailToDirName;
constructor(
public selectOptions: SelectOptionsService,
private listingsService: ListingsService,
private activatedRoute: ActivatedRoute,
private router: Router,
private cdRef: ChangeDetectorRef,
private route: ActivatedRoute,
private searchService: SearchService,
private modalService: ModalService,
private criteriaChangeService: CriteriaChangeService,
) {
this.criteria = getCriteriaProxy('businessListings', this) as BusinessListingCriteria;
this.init();
this.searchService.currentCriteria.pipe(untilDestroyed(this)).subscribe(criteria => {
if (criteria && criteria.criteriaType === 'businessListings') {
this.criteria = criteria as BusinessListingCriteria;
this.search();
}
});
}
async ngOnInit() {
this.search();
}
async init() {
this.reset();
}
async search() {
const listingReponse = await this.listingsService.getListings(this.criteria);
this.listings = listingReponse.results;
this.totalRecords = listingReponse.totalCount;
this.pageCount = this.totalRecords % LISTINGS_PER_PAGE === 0 ? this.totalRecords / LISTINGS_PER_PAGE : Math.floor(this.totalRecords / LISTINGS_PER_PAGE) + 1;
this.page = this.criteria.page ? this.criteria.page : 1;
this.cdRef.markForCheck();
this.cdRef.detectChanges();
}
onPageChange(page: any) {
this.criteria.start = (page - 1) * LISTINGS_PER_PAGE;
this.criteria.length = LISTINGS_PER_PAGE;
this.criteria.page = page;
this.search();
}
imageErrorHandler(listing: ListingType) {}
reset() {
this.criteria.title = null;
}
getDaysListed(listing: BusinessListing) {
return dayjs().diff(listing.created, 'day');
}
// New methods for filter actions
clearAllFilters() {
// Reset criteria to default values
resetBusinessListingCriteria(this.criteria);
// Reset pagination
this.criteria.page = 1;
this.criteria.start = 0;
this.criteriaChangeService.notifyCriteriaChange();
// Search with cleared filters
this.searchService.search(this.criteria);
}
async openFilterModal() {
// Open the search modal with current criteria
const modalResult = await this.modalService.showModal(this.criteria);
if (modalResult.accepted) {
this.searchService.search(this.criteria);
} else {
this.criteria = assignProperties(this.criteria, modalResult.criteria);
}
}
}

View File

@ -0,0 +1,96 @@
<div class="container mx-auto p-4">
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg overflow-hidden relative">
<button
(click)="historyService.goBack()"
class="absolute top-4 right-4 bg-red-500 text-white rounded-full w-8 h-8 flex items-center justify-center hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 print:hidden"
>
<i class="fas fa-times"></i>
</button>
@if(listing){
<div class="p-6 flex flex-col lg:flex-row">
<!-- Left column -->
<div class="w-full lg:w-1/2 pr-0 lg:pr-6">
<h1 class="text-2xl font-bold mb-4">{{ listing.title }}</h1>
<p class="mb-4" [innerHTML]="description"></p>
<div class="space-y-2">
<div *ngFor="let detail of listingDetails; let i = index" class="flex flex-col sm:flex-row" [ngClass]="{ 'bg-gray-100': i % 2 === 0 }">
<div class="w-full sm:w-1/3 font-semibold p-2">{{ detail.label }}</div>
<div class="w-full sm:w-2/3 p-2" *ngIf="!detail.isHtml && !detail.isListingBy">{{ detail.value }}</div>
<div class="w-full sm:w-2/3 p-2 flex space-x-2" [innerHTML]="detail.value" *ngIf="detail.isHtml && !detail.isListingBy"></div>
<div class="w-full sm:w-2/3 p-2 flex space-x-2" *ngIf="detail.isListingBy">
<a routerLink="/details-user/{{ listingUser.id }}" class="text-blue-600 dark:text-blue-500 hover:underline">{{ listingUser.firstname }} {{ listingUser.lastname }}</a>
<img src="{{ env.imageBaseUrl }}/pictures/logo/{{ listing.imageName }}.avif?_ts={{ ts }}" class="mr-5 lg:mb-0" style="max-height: 30px; max-width: 100px" />
</div>
</div>
</div>
<div class="py-4 print:hidden">
@if(listing && listingUser && (listingUser?.email===user?.email || (authService.isAdmin() | async))){
<div class="inline">
<button class="share share-edit text-white font-bold text-xs py-1.5 px-2 inline-flex items-center" [routerLink]="['/editBusinessListing', listing.id]">
<i class="fa-regular fa-pen-to-square"></i>
<span class="ml-2">Edit</span>
</button>
</div>
} @if(user){
<div class="inline">
<button class="share share-save text-white font-bold text-xs py-1.5 px-2 inline-flex items-center" (click)="save()" [disabled]="listing.favoritesForUser.includes(user.email)">
<i class="fa-regular fa-heart"></i>
@if(listing.favoritesForUser.includes(user.email)){
<span class="ml-2">Saved ...</span>
}@else {
<span class="ml-2">Save</span>
}
</button>
</div>
}
<share-button button="print" showText="true" (click)="createEvent('print')"></share-button>
<!-- <share-button button="email" showText="true"></share-button> -->
<div class="inline">
<button class="share share-email text-white font-bold text-xs py-1.5 px-2 inline-flex items-center" (click)="showShareByEMail()">
<i class="fa-solid fa-envelope"></i>
<span class="ml-2">Email</span>
</button>
</div>
<share-button button="facebook" showText="true" (click)="createEvent('facebook')"></share-button>
<share-button button="x" showText="true" (click)="createEvent('x')"></share-button>
<share-button button="linkedin" showText="true" (click)="createEvent('linkedin')"></share-button>
</div>
<!-- Karte hinzufügen, wenn Straße vorhanden ist -->
<!-- <div *ngIf="listing.location.street" class="mt-6">
<h2 class="text-lg font-semibold mb-2">Location Map</h2>
<div style="height: 400px" leaflet [leafletOptions]="mapOptions" [leafletLayers]="mapLayers" [leafletCenter]="mapCenter" [leafletZoom]="mapZoom" (leafletMapReady)="onMapReady($event)"></div>
</div> -->
</div>
<!-- Right column -->
<div class="w-full lg:w-1/2 mt-6 lg:mt-0 print:hidden">
<!-- <h2 class="text-lg font-semibold my-4">Contact the Author of this Listing</h2> -->
<div class="md:mt-8 mb-4 text-2xl font-bold mb-4">Contact the Author of this Listing</div>
<p class="text-sm mb-4">Please include your contact info below</p>
<form class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<app-validated-input label="Your Name" name="name" [(ngModel)]="mailinfo.sender.name"></app-validated-input>
<app-validated-input label="Your Email" name="email" [(ngModel)]="mailinfo.sender.email" kind="email"></app-validated-input>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<app-validated-input label="Phone Number" name="phoneNumber" [(ngModel)]="mailinfo.sender.phoneNumber" mask="(000) 000-0000"></app-validated-input>
<!-- <app-validated-input label="Country/State" name="state" [(ngModel)]="mailinfo.sender.state"></app-validated-input> -->
<app-validated-ng-select label="State" name="state" [(ngModel)]="mailinfo.sender.state" [items]="selectOptions?.states"></app-validated-ng-select>
</div>
<div>
<app-validated-textarea label="Questions/Comments" name="comments" [(ngModel)]="mailinfo.sender.comments"></app-validated-textarea>
</div>
<button (click)="mail()" class="w-full sm:w-auto px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50">Submit</button>
</form>
</div>
</div>
}
</div>
</div>

View File

@ -0,0 +1,212 @@
import { ChangeDetectorRef, Component } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ActivatedRoute, NavigationEnd, Router, RouterModule } from '@angular/router';
import { ShareButton } from 'ngx-sharebuttons/button';
import { lastValueFrom } from 'rxjs';
// Import für Leaflet
// Benannte Importe für Leaflet
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import dayjs from 'dayjs';
import { BusinessListing, EventTypeEnum, ShareByEMail, User } from '../../../../../bizmatch-server/src/models/db.model';
import { KeycloakUser, MailInfo } from '../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../environments/environment';
import { EMailService } from '../../components/email/email.service';
import { ValidatedInputComponent } from '../../components/validated-input/validated-input.component';
import { ValidatedNgSelectComponent } from '../../components/validated-ng-select/validated-ng-select.component';
import { ValidatedTextareaComponent } from '../../components/validated-textarea/validated-textarea.component';
import { ValidationMessagesService } from '../../components/validation-messages.service';
import { AuditService } from '../../services/audit.service';
import { AuthService } from '../../services/auth.service';
import { GeoService } from '../../services/geo.service';
import { HistoryService } from '../../services/history.service';
import { ListingsService } from '../../services/listings.service';
import { MailService } from '../../services/mail.service';
import { MessageService } from '../../services/message.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { UserService } from '../../services/user.service';
import { createMailInfo, map2User } from '../../utils/utils';
@Component({
selector: 'app-details-business-listing',
standalone: true,
imports: [CommonModule, FormsModule, RouterModule, FontAwesomeModule, ValidatedInputComponent, ValidatedTextareaComponent, ShareButton, ValidatedNgSelectComponent],
providers: [],
templateUrl: './details-business-listing.component.html',
})
export class DetailsBusinessListingComponent {
// listings: Array<BusinessListing>;
responsiveOptions = [
{
breakpoint: '1199px',
numVisible: 1,
numScroll: 1,
},
{
breakpoint: '991px',
numVisible: 2,
numScroll: 1,
},
{
breakpoint: '767px',
numVisible: 1,
numScroll: 1,
},
];
private id: string | undefined;
listing: BusinessListing;
mailinfo: MailInfo;
environment = environment;
keycloakUser: KeycloakUser;
user: User;
listingUser: User;
description: SafeHtml;
private history: string[] = [];
ts = new Date().getTime();
env = environment;
constructor(
private activatedRoute: ActivatedRoute,
private listingsService: ListingsService,
private router: Router,
private userService: UserService,
public selectOptions: SelectOptionsService,
private mailService: MailService,
private sanitizer: DomSanitizer,
public historyService: HistoryService,
private validationMessagesService: ValidationMessagesService,
private messageService: MessageService,
private auditService: AuditService,
public emailService: EMailService,
private geoService: GeoService,
public authService: AuthService,
private cdref: ChangeDetectorRef,
) {
this.router.events.subscribe(event => {
if (event instanceof NavigationEnd) {
this.history.push(event.urlAfterRedirects);
}
});
this.mailinfo = { sender: {}, email: '', url: environment.mailinfoUrl };
this.id = this.activatedRoute.snapshot.params['id'] as string | undefined;
}
async ngOnInit() {
const token = await this.authService.getToken();
this.keycloakUser = map2User(token);
if (this.keycloakUser) {
this.user = await this.userService.getByMail(this.keycloakUser.email);
this.mailinfo = createMailInfo(this.user);
}
try {
this.listing = await lastValueFrom(this.listingsService.getListingById(this.id));
this.auditService.createEvent(this.listing.id, 'view', this.user?.email);
this.listingUser = await this.userService.getByMail(this.listing.email);
this.description = this.sanitizer.bypassSecurityTrustHtml(this.listing.description);
} catch (error) {
this.auditService.log({ severity: 'error', text: error.error.message });
this.router.navigate(['notfound']);
}
}
ngOnDestroy() {
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
}
async mail() {
try {
this.mailinfo.email = this.listingUser.email;
this.mailinfo.listing = this.listing;
await this.mailService.mail(this.mailinfo);
this.validationMessagesService.clearMessages();
this.auditService.createEvent(this.listing.id, 'contact', this.user?.email, this.mailinfo.sender);
this.messageService.addMessage({ severity: 'success', text: 'Your message has been sent to the creator of the listing', duration: 3000 });
this.mailinfo = createMailInfo(this.user);
} catch (error) {
this.messageService.addMessage({
severity: 'danger',
text: 'An error occurred while sending the request - Please check your inputs',
duration: 5000,
});
if (error.error && Array.isArray(error.error?.message)) {
this.validationMessagesService.updateMessages(error.error.message);
}
}
}
get listingDetails() {
let typeOfRealEstate = '';
if (this.listing.realEstateIncluded) {
typeOfRealEstate = 'Real Estate Included';
} else if (this.listing.leasedLocation) {
typeOfRealEstate = 'Leased Location';
} else if (this.listing.franchiseResale) {
typeOfRealEstate = 'Franchise Re-Sale';
}
const result = [
{ label: 'Category', value: this.selectOptions.getBusiness(this.listing.type) },
{ label: 'Located in', value: `${this.listing.location.name ? this.listing.location.name : this.listing.location.county}, ${this.selectOptions.getState(this.listing.location.state)}` },
{ label: 'Asking Price', value: `${this.listing.price ? `$${this.listing.price.toLocaleString()}` : ''}` },
{ label: 'Sales revenue', value: `${this.listing.salesRevenue ? `$${this.listing.salesRevenue.toLocaleString()}` : ''}` },
{ label: 'Cash flow', value: `${this.listing.cashFlow ? `$${this.listing.cashFlow.toLocaleString()}` : ''}` },
{ label: 'Type of Real Estate', value: typeOfRealEstate },
{ label: 'Employees', value: this.listing.employees },
{ label: 'Established since', value: this.listing.established },
{ label: 'Support & Training', value: this.listing.supportAndTraining },
{ label: 'Reason for Sale', value: this.listing.reasonForSale },
{ label: 'Broker licensing', value: this.listing.brokerLicencing },
{ label: 'Listed since', value: `${this.dateInserted()} - ${this.getDaysListed()} days` },
{
label: 'Listing by',
value: null, // Wird nicht verwendet
isHtml: true,
isListingBy: true, // Flag für den speziellen Fall
user: this.listingUser, // Übergebe das User-Objekt
imagePath: this.listing.imageName,
imageBaseUrl: this.env.imageBaseUrl,
ts: this.ts,
},
];
if (this.listing.draft) {
result.push({ label: 'Draft', value: this.listing.draft ? 'Yes' : 'No' });
}
return result;
}
save() {
this.listing.favoritesForUser.push(this.user.email);
this.listingsService.save(this.listing);
this.auditService.createEvent(this.listing.id, 'favorite', this.user?.email);
}
isAlreadyFavorite() {
return this.listing.favoritesForUser.includes(this.user.email);
}
async showShareByEMail() {
const result = await this.emailService.showShareByEMail({
yourEmail: this.user ? this.user.email : null,
yourName: this.user ? `${this.user.firstname} ${this.user.lastname}` : null,
url: environment.mailinfoUrl,
listingTitle: this.listing.title,
id: this.listing.id,
type: 'business',
});
if (result) {
this.auditService.createEvent(this.listing.id, 'email', this.user?.email, <ShareByEMail>result);
this.messageService.addMessage({
severity: 'success',
text: 'Your Email has beend sent',
duration: 5000,
});
}
}
createEvent(eventType: EventTypeEnum) {
this.auditService.createEvent(this.listing.id, eventType, this.user?.email);
}
getDaysListed() {
return dayjs().diff(this.listing.created, 'day');
}
dateInserted() {
return dayjs(this.listing.created).format('DD/MM/YYYY');
}
}

View File

@ -0,0 +1,382 @@
<div class="container mx-auto p-4">
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6">
<h1 class="text-2xl font-semibold mb-6">Edit Listing</h1>
@if(listing){
<form #listingForm="ngForm" class="space-y-4">
<div class="mb-4">
<label for="listingsCategory" class="block text-sm font-bold text-gray-700 mb-1">Listing category</label>
<ng-select
[readonly]="true"
[items]="selectOptions?.listingCategories"
bindLabel="name"
bindValue="value"
[(ngModel)]="listing.listingsCategory"
(ngModelChange)="changeListingCategory($event)"
name="listingsCategory"
>
</ng-select>
</div>
<!-- <div class="mb-4">
<label for="title" class="block text-sm font-bold text-gray-700 mb-1">Title of Listing</label>
<input type="text" id="title" [(ngModel)]="listing.title" name="title" class="w-full p-2 border border-gray-300 rounded-md" />
</div> -->
<div>
<app-validated-input label="Title of Listing" name="title" [(ngModel)]="listing.title"></app-validated-input>
</div>
<!-- <div class="mb-4">
<label for="description" class="block text-sm font-bold text-gray-700 mb-1">Description</label>
<quill-editor [(ngModel)]="listing.description" name="description" [modules]="quillModules"></quill-editor>
</div> -->
<div>
<app-validated-quill label="Description" name="description" [(ngModel)]="listing.description"></app-validated-quill>
</div>
<!-- <div class="mb-4">
<label for="type" class="block text-sm font-bold text-gray-700 mb-1">Type of business</label>
<ng-select [items]="typesOfBusiness" bindLabel="name" bindValue="value" [(ngModel)]="listing.type" name="type"> </ng-select>
</div> -->
<div>
<app-validated-ng-select label="Type of business" name="type" [(ngModel)]="listing.type" [items]="typesOfBusiness"></app-validated-ng-select>
</div>
<!-- <div class="flex mb-4 space-x-4">
<div class="w-1/2">
<label for="state" class="block text-sm font-bold text-gray-700 mb-1">State</label>
<ng-select [items]="selectOptions?.states" bindLabel="name" bindValue="value" [(ngModel)]="listing.state" name="state"> </ng-select>
</div>
<div class="w-1/2">
<label for="city" class="block text-sm font-bold text-gray-700 mb-1">City</label>
<input type="text" id="city" [(ngModel)]="listing.city" name="city" class="w-full p-2 border border-gray-300 rounded-md" />
</div>
</div> -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- <app-validated-ng-select label="State" name="state" [(ngModel)]="listing.location.state" [items]="selectOptions?.states"></app-validated-ng-select>
<app-validated-input label="City" name="city" [(ngModel)]="listing.location.city"></app-validated-input> -->
<!-- <app-validated-city label="Location" name="location" [(ngModel)]="listing.location"></app-validated-city> -->
<app-validated-location label="Location" name="location" [(ngModel)]="listing.location"></app-validated-location>
<app-validated-price label="Price" name="price" [(ngModel)]="listing.price"></app-validated-price>
</div>
<!-- <div class="flex mb-4 space-x-4">
<div class="w-1/2">
<label for="price" class="block text-sm font-bold text-gray-700 mb-1">Price</label>
<input
type="text"
id="price"
[(ngModel)]="listing.price"
name="price"
class="w-full p-2 border border-gray-300 rounded-md"
[options]="{ prefix: '$', thousands: ',', decimal: '.', precision: 0, align: 'left' }"
currencyMask
/>
</div>
<div class="w-1/2">
<label for="salesRevenue" class="block text-sm font-bold text-gray-700 mb-1">Sales Revenue</label>
<input
type="text"
id="salesRevenue"
[(ngModel)]="listing.salesRevenue"
name="salesRevenue"
class="w-full p-2 border border-gray-300 rounded-md"
[options]="{ prefix: '$', thousands: ',', decimal: '.', precision: 0, align: 'left' }"
currencyMask
/>
</div>
</div> -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<app-validated-price label="Sales Revenue" name="salesRevenue" [(ngModel)]="listing.salesRevenue"></app-validated-price>
<app-validated-price label="Cash Flow" name="cashFlow" [(ngModel)]="listing.cashFlow"></app-validated-price>
</div>
<!-- <div class="mb-4">
<label for="cashFlow" class="block text-sm font-bold text-gray-700 mb-1">Cash Flow</label>
<input
type="text"
id="cashFlow"
[(ngModel)]="listing.cashFlow"
name="cashFlow"
class="w-full p-2 border border-gray-300 rounded-md"
[options]="{ prefix: '$', thousands: ',', decimal: '.', precision: 0, align: 'left' }"
currencyMask
/>
</div> -->
<!-- <div>
</div> -->
<!-- <div class="flex mb-4 space-x-4">
<div class="w-1/2">
<label for="established" class="block text-sm font-bold text-gray-700 mb-1">Years Established Since</label>
<input type="number" id="established" [(ngModel)]="listing.established" name="established" class="w-full p-2 border border-gray-300 rounded-md" />
</div>
<div class="w-1/2">
<label for="employees" class="block text-sm font-bold text-gray-700 mb-1">Employees</label>
<input type="number" id="employees" [(ngModel)]="listing.employees" name="employees" class="w-full p-2 border border-gray-300 rounded-md" />
</div>
</div> -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<app-validated-input label="Established In" name="established" [(ngModel)]="listing.established" mask="0000" kind="number"></app-validated-input>
<app-validated-input label="Employees" name="employees" [(ngModel)]="listing.employees" mask="0000" kind="number"></app-validated-input>
</div>
<div class="flex mb-4 space-x-4">
<div class="flex items-center">
<input
type="checkbox"
id="realEstateIncluded"
[(ngModel)]="listing.realEstateIncluded"
(ngModelChange)="onCheckboxChange('realEstateIncluded', $event)"
name="realEstateIncluded"
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 mr-2"
/>
<label for="realEstateIncluded" class="text-sm font-bold text-gray-700">Real Estate Included</label>
</div>
<div class="flex items-center">
<input
type="checkbox"
id="leasedLocation"
[(ngModel)]="listing.leasedLocation"
(ngModelChange)="onCheckboxChange('leasedLocation', $event)"
name="leasedLocation"
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 mr-2"
/>
<label for="leasedLocation" class="text-sm font-bold text-gray-700">Leased Location</label>
</div>
<div class="flex items-center">
<input
type="checkbox"
id="franchiseResale"
[(ngModel)]="listing.franchiseResale"
(ngModelChange)="onCheckboxChange('franchiseResale', $event)"
name="franchiseResale"
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 mr-2"
/>
<label for="franchiseResale" class="text-sm font-bold text-gray-700">Franchise Re-Sale</label>
</div>
</div>
<!-- <div class="mb-4">
<label for="supportAndTraining" class="block text-sm font-bold text-gray-700 mb-1">Support & Training</label>
<input type="text" id="supportAndTraining" [(ngModel)]="listing.supportAndTraining" name="supportAndTraining" class="w-full p-2 border border-gray-300 rounded-md" />
</div>
<div class="mb-4">
<label for="reasonForSale" class="block text-sm font-bold text-gray-700 mb-1">Reason for Sale</label>
<input type="text" id="reasonForSale" [(ngModel)]="listing.reasonForSale" name="reasonForSale" class="w-full p-2 border border-gray-300 rounded-md" />
</div>-->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<app-validated-input label="Support & Training" name="supportAndTraining" [(ngModel)]="listing.supportAndTraining"></app-validated-input>
<app-validated-input label="Reason for Sale" name="reasonForSale" [(ngModel)]="listing.reasonForSale"></app-validated-input>
</div>
<!-- <div class="flex mb-4 space-x-4">
<div class="w-1/2">
<label for="brokerLicencing" class="block text-sm font-bold text-gray-700 mb-1">Broker Licensing</label>
<input type="text" id="brokerLicencing" [(ngModel)]="listing.brokerLicencing" name="brokerLicencing" class="w-full p-2 border border-gray-300 rounded-md" />
</div>
<div class="w-1/2">
<label for="internalListingNumber" class="block text-sm font-bold text-gray-700 mb-1">Internal Listing Number</label>
<input type="number" id="internalListingNumber" [(ngModel)]="listing.internalListingNumber" name="internalListingNumber" class="w-full p-2 border border-gray-300 rounded-md" />
</div>
</div> -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<!-- <app-validated-input label="Broker Licensing" name="brokerLicencing" [(ngModel)]="listing.brokerLicencing"></app-validated-input> -->
<label for="brokerLicencing" class="block text-sm font-bold text-gray-700 mb-1">Broker Licensing (please maintain your license in your account)</label>
<!-- @if(listingUser){ -->
<ng-select [(ngModel)]="listing.brokerLicencing" name="brokerLicencing">
@for (licensedIn of listingUser?.licensedIn; track listingUser?.licensedIn) {
<ng-option [value]="licensedIn.registerNo">{{ licensedIn.state }} {{ licensedIn.registerNo }}</ng-option>
}
</ng-select>
</div>
<!-- } -->
<app-validated-input label="Internal Listing Number" name="internalListingNumber" [(ngModel)]="listing.internalListingNumber" kind="number" mask="00000000000000000000"></app-validated-input>
</div>
<!-- <div class="mb-4">
<label for="internals" class="block text-sm font-bold text-gray-700 mb-1">Internal Notes (Will not be shown on the listing, for your records only.)</label>
<textarea id="internals" [(ngModel)]="listing.internals" name="internals" class="w-full p-2 border border-gray-300 rounded-md" rows="3"></textarea>
</div> -->
<div>
<app-validated-textarea label="Internal Notes (Will not be shown on the listing, for your records only.)" name="internals" [(ngModel)]="listing.internals"></app-validated-textarea>
</div>
<div class="flex items-center mb-4">
<label class="flex items-center cursor-pointer">
<div class="relative">
<input type="checkbox" [(ngModel)]="listing.draft" name="draft" class="hidden" />
<div class="toggle-bg block w-12 h-6 rounded-full bg-gray-600 transition"></div>
</div>
<div class="ml-3 text-gray-700 font-medium">Draft Mode (Will not be shown as public listing)</div>
</label>
</div>
@if (mode==='create'){
<button (click)="save()" class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600">Post Listing</button>
} @else {
<button (click)="save()" class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600">Update Listing</button>
}
</form>
}
</div>
</div>
<!-- <div class="surface-ground px-4 py-8 md:px-6 lg:px-8">
<div class="p-fluid flex flex-column lg:flex-row">
<menu-account></menu-account>
<p-toast></p-toast>
<div *ngIf="listing" class="surface-card p-5 shadow-2 border-round flex-auto">
<div class="text-900 font-semibold text-lg mt-3">{{ mode === 'create' ? 'New' : 'Edit' }} Listing</div>
<p-divider></p-divider>
<div class="flex gap-5 flex-column-reverse md:flex-row">
<div class="flex-auto p-fluid">
<div class="mb-4">
<label for="listingCategory" class="block font-medium text-900 mb-2">Listing category</label>
<p-dropdown
id="listingCategory"
[options]="selectOptions?.listingCategories"
[ngModel]="listingsCategory"
optionLabel="name"
optionValue="value"
(ngModelChange)="changeListingCategory($event)"
placeholder="Listing category"
[disabled]="mode === 'edit'"
[style]="{ width: '100%' }"
></p-dropdown>
</div>
<div class="mb-4">
<label for="email" class="block font-medium text-900 mb-2">Title of Listing</label>
<input id="email" type="text" pInputText [(ngModel)]="listing.title" />
</div>
<div>
<div class="mb-4">
<label for="description" class="block font-medium text-900 mb-2">Description</label>
<p-editor [(ngModel)]="listing.description" [style]="{ height: '320px' }" [modules]="editorModules">
<ng-template pTemplate="header"></ng-template>
</p-editor>
</div>
</div>
<div class="mb-4">
<label for="type" class="block font-medium text-900 mb-2">Type of business</label>
<p-dropdown
id="type"
[filter]="true"
filterBy="name"
[options]="typesOfBusiness"
[(ngModel)]="listing.type"
optionLabel="name"
optionValue="value"
[showClear]="true"
placeholder="Type of business"
[style]="{ width: '100%' }"
></p-dropdown>
</div>
<div class="grid">
<div class="mb-4 col-12 md:col-6">
<label for="listingCategory" class="block font-medium text-900 mb-2">State</label>
<p-dropdown
id="listingCategory"
[filter]="true"
filterBy="name"
[options]="selectOptions?.states"
[(ngModel)]="listing.state"
optionLabel="name"
optionValue="value"
[showClear]="true"
placeholder="State"
[style]="{ width: '100%' }"
></p-dropdown>
</div>
<div class="mb-4 col-12 md:col-6">
<label for="listingCategory" class="block font-medium text-900 mb-2">City</label>
<p-autoComplete [(ngModel)]="listing.city" [suggestions]="suggestions" (completeMethod)="search($event)"></p-autoComplete>
</div>
</div>
</div>
</div>
<p-divider></p-divider>
<div class="flex gap-5 flex-column-reverse md:flex-row">
<div class="flex-auto p-fluid">
<div class="grid">
<div class="mb-4 col-12 md:col-6">
<label for="price" class="block font-medium text-900 mb-2">Price</label>
<p-inputNumber mode="currency" currency="USD" locale="en-US" inputId="price" [(ngModel)]="listing.price"></p-inputNumber>
</div>
<div class="mb-4 col-12 md:col-6">
<label for="salesRevenue" class="block font-medium text-900 mb-2">Sales Revenue</label>
<p-inputNumber mode="currency" currency="USD" inputId="salesRevenue" [(ngModel)]="listing.salesRevenue"></p-inputNumber>
</div>
</div>
<div class="grid">
<div class="mb-4 col-12 md:col-6">
<label for="cashFlow" class="block font-medium text-900 mb-2">Cash Flow</label>
<p-inputNumber mode="currency" currency="USD" inputId="cashFlow" [(ngModel)]="listing.cashFlow"></p-inputNumber>
</div>
</div>
<div class="grid">
<div class="mb-4 col-12 md:col-6">
<label for="established" class="block font-medium text-900 mb-2">Years Established Since</label>
<p-inputNumber mode="decimal" inputId="established" [(ngModel)]="listing.established" [useGrouping]="false"></p-inputNumber>
</div>
<div class="mb-4 col-12 md:col-6">
<label for="employees" class="block font-medium text-900 mb-2">Employees</label>
<p-inputNumber mode="decimal" inputId="employees" [(ngModel)]="listing.employees" [useGrouping]="false"></p-inputNumber>
</div>
</div>
<div class="grid">
<div class="mb-4 col-12 md:col-4">
<p-checkbox [binary]="true" [(ngModel)]="listing.realEstateIncluded"></p-checkbox>
<span class="ml-2 text-900">Real Estate Included</span>
</div>
<div class="mb-4 col-12 md:col-4">
<p-checkbox [binary]="true" [(ngModel)]="listing.leasedLocation"></p-checkbox>
<span class="ml-2 text-900">Leased Location</span>
</div>
<div class="mb-4 col-12 md:col-4">
<p-checkbox [binary]="true" [(ngModel)]="listing.franchiseResale"></p-checkbox>
<span class="ml-2 text-900">Franchise Re-Sale</span>
</div>
</div>
<div class="mb-4">
<label for="supportAndTraining" class="block font-medium text-900 mb-2">Support & Training</label>
<input id="supportAndTraining" type="text" pInputText [(ngModel)]="listing.supportAndTraining" />
</div>
<div class="mb-4">
<label for="reasonForSale" class="block font-medium text-900 mb-2">Reason for Sale</label>
<textarea id="reasonForSale" type="text" pInputTextarea rows="5" [autoResize]="true" [(ngModel)]="listing.reasonForSale"></textarea>
</div>
<div class="grid">
<div class="mb-4 col-12 md:col-6">
<label for="brokerLicensing" class="block font-medium text-900 mb-2">Broker Licensing</label>
<input id="brokerLicensing" type="text" pInputText [(ngModel)]="listing.brokerLicencing" />
</div>
<div class="mb-4 col-12 md:col-6">
<label for="internalListingNumber" class="block font-medium text-900 mb-2">Internal Listing Number</label>
<p-inputNumber mode="decimal" inputId="internalListingNumber" type="text" [(ngModel)]="listing.internalListingNumber" [useGrouping]="false"></p-inputNumber>
</div>
</div>
<div class="mb-4">
<label for="internalListing" class="block font-medium text-900 mb-2">Internal Notes (Will not be shown on the listing, for your records only.)</label>
<input id="internalListing" type="text" pInputText [(ngModel)]="listing.internals" />
</div>
<div class="grid">
<div class="mb-4 col-12 md:col-6">
<p-inputSwitch inputId="draft" [(ngModel)]="listing.draft"></p-inputSwitch>
<span class="ml-2 text-900 absolute translate-y-5">Draft Mode (Will not be shown as public listing)</span>
</div>
</div>
<div>
@if (mode==='create'){
<button pButton pRipple label="Post Listing" class="w-auto" (click)="save()"></button>
} @else {
<button pButton pRipple label="Update Listing" class="w-auto" (click)="save()"></button>
}
</div>
</div>
</div>
</div>
</div>
</div>
<p-toast></p-toast>
<p-confirmDialog></p-confirmDialog> -->

View File

@ -0,0 +1,34 @@
:host {
display: block;
font-family: 'Inter', sans-serif;
}
// .container {
// max-width: 100%;
// margin: 0 auto;
// }
:host ::ng-deep quill-editor {
.ql-container {
min-height: 200px;
}
}
// input[type='checkbox'] {
// &:checked + .dot {
// transform: translateX(100%);
// }
// &:checked + .block {
// background-color: #4299e1;
// }
// }
.dot {
transition: all 0.3s ease-in-out;
}
::ng-deep .ng-select.ng-select-single .ng-select-container {
height: 42px;
}
quill-editor {
width: 100%;
}

View File

@ -0,0 +1,179 @@
import { Component } from '@angular/core';
import { ActivatedRoute, NavigationEnd, Router, RouterModule } from '@angular/router';
import { lastValueFrom } from 'rxjs';
import { DragDropModule } from '@angular/cdk/drag-drop';
import { faTrash } from '@fortawesome/free-solid-svg-icons';
import { QuillModule } from 'ngx-quill';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { NgSelectModule } from '@ng-select/ng-select';
import { NgxCurrencyDirective } from 'ngx-currency';
import { BusinessListing, CommercialPropertyListing, User } from '../../../../../bizmatch-server/src/models/db.model';
import { AutoCompleteCompleteEvent, createDefaultBusinessListing, emailToDirName, ImageProperty } from '../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../environments/environment';
import { ValidatedCityComponent } from '../../components/validated-city/validated-city.component';
import { ValidatedInputComponent } from '../../components/validated-input/validated-input.component';
import { ValidatedLocationComponent } from '../../components/validated-location/validated-location.component';
import { ValidatedNgSelectComponent } from '../../components/validated-ng-select/validated-ng-select.component';
import { ValidatedPriceComponent } from '../../components/validated-price/validated-price.component';
import { ValidatedQuillComponent } from '../../components/validated-quill/validated-quill.component';
import { ValidatedTextareaComponent } from '../../components/validated-textarea/validated-textarea.component';
import { ValidationMessagesService } from '../../components/validation-messages.service';
import { ArrayToStringPipe } from '../../pipes/array-to-string.pipe';
import { AuthService } from '../../services/auth.service';
import { GeoService } from '../../services/geo.service';
import { ImageService } from '../../services/image.service';
import { ListingsService } from '../../services/listings.service';
import { LoadingService } from '../../services/loading.service';
import { MessageService } from '../../services/message.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { UserService } from '../../services/user.service';
import { map2User, routeListingWithState, TOOLBAR_OPTIONS } from '../../utils/utils';
@Component({
selector: 'business-listing',
standalone: true,
imports: [
CommonModule,
FormsModule,
RouterModule,
FontAwesomeModule,
ArrayToStringPipe,
DragDropModule,
QuillModule,
NgxCurrencyDirective,
NgSelectModule,
ValidatedInputComponent,
ValidatedQuillComponent,
ValidatedNgSelectComponent,
ValidatedPriceComponent,
ValidatedTextareaComponent,
ValidatedCityComponent,
ValidatedLocationComponent,
],
providers: [],
templateUrl: './edit-business-listing.component.html',
styleUrl: './edit-business-listing.component.scss',
})
export class EditBusinessListingComponent {
listingsCategory = 'business';
category: string;
location: string;
mode: 'edit' | 'create';
separator: '\n\n';
listing: BusinessListing;
private id: string | undefined;
user: User;
environment = environment;
config = { aspectRatio: 16 / 9 };
editorModules = TOOLBAR_OPTIONS;
draggedImage: ImageProperty;
faTrash = faTrash;
data: CommercialPropertyListing;
typesOfBusiness = [];
quillModules = {
toolbar: [['bold', 'italic', 'underline', 'strike'], [{ list: 'ordered' }, { list: 'bullet' }], [{ header: [1, 2, 3, 4, 5, 6, false] }], [{ color: [] }, { background: [] }], ['clean']],
};
listingUser: User;
constructor(
public selectOptions: SelectOptionsService,
private router: Router,
private activatedRoute: ActivatedRoute,
private listingsService: ListingsService,
public userService: UserService,
private geoService: GeoService,
private imageService: ImageService,
private loadingService: LoadingService,
private messageService: MessageService,
private route: ActivatedRoute,
private validationMessagesService: ValidationMessagesService,
private authService: AuthService,
) {
this.router.events.subscribe(event => {
if (event instanceof NavigationEnd) {
this.mode = event.url.startsWith('/createBusinessListing') ? 'create' : 'edit';
}
});
this.route.data.subscribe(async () => {
if (this.router.getCurrentNavigation().extras.state) {
this.data = this.router.getCurrentNavigation().extras.state['data'];
}
});
this.typesOfBusiness = selectOptions.typesOfBusiness.map(e => {
return { name: e.name, value: e.value };
});
this.id = this.activatedRoute.snapshot.params['id'] as string | undefined;
}
async ngOnInit() {
const token = await this.authService.getToken();
const keycloakUser = map2User(token);
this.listingUser = await this.userService.getByMail(keycloakUser.email);
if (this.mode === 'edit') {
this.listing = await lastValueFrom(this.listingsService.getListingById(this.id));
} else {
this.listing = createDefaultBusinessListing();
this.listing.email = this.listingUser.email;
this.listing.imageName = emailToDirName(keycloakUser.email);
if (this.data) {
this.listing.title = this.data?.title;
this.listing.description = this.data?.description;
}
}
}
ngOnDestroy() {
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
}
async save() {
try {
this.listing = await this.listingsService.save(this.listing);
this.router.navigate(['editBusinessListing', this.listing.id]);
this.messageService.addMessage({ severity: 'success', text: 'Listing changes have been persisted', duration: 3000 });
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
} catch (error) {
this.messageService.addMessage({
severity: 'danger',
text: 'An error occurred while saving the profile - Please check your inputs',
duration: 5000,
});
if (error.error && Array.isArray(error.error?.message)) {
this.validationMessagesService.updateMessages(error.error.message);
}
}
}
suggestions: string[] | undefined;
async search(event: AutoCompleteCompleteEvent) {
const result = await lastValueFrom(this.geoService.findCitiesStartingWith(event.query));
this.suggestions = result.map(r => r.name).slice(0, 5);
}
changeListingCategory(value: 'business' | 'commercialProperty') {
routeListingWithState(this.router, value, this.listing);
}
onNumericInputChange(value: string, modelProperty: string): void {
const newValue = value === '' ? null : +value;
this.setPropertyByPath(this, modelProperty, newValue);
}
private setPropertyByPath(obj: any, path: string, value: any): void {
const keys = path.split('.');
let target = obj;
for (let i = 0; i < keys.length - 1; i++) {
target = target[keys[i]];
}
target[keys[keys.length - 1]] = value;
}
onCheckboxChange(checkbox: string, value: boolean) {
// Deaktivieren Sie alle Checkboxes
this.listing.realEstateIncluded = false;
this.listing.leasedLocation = false;
this.listing.franchiseResale = false;
// Aktivieren Sie nur die aktuell ausgewählte Checkbox
this.listing[checkbox] = value;
}
}

View File

@ -0,0 +1,35 @@
<div class="container mx-auto py-8 px-4 max-w-md">
<div class="bg-white p-6 rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg text-center">
<!-- Loading state -->
<ng-container *ngIf="verificationStatus === 'pending'">
<div class="flex justify-center mb-4">
<div class="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<p class="text-gray-700">Verifying your email address...</p>
</ng-container>
<!-- Success state -->
<ng-container *ngIf="verificationStatus === 'success'">
<div class="flex justify-center mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 class="text-2xl font-bold text-green-600 mb-5">Your email has been verified</h2>
<p class="text-gray-700 mb-4">You will be redirected to your account page in 5 seconds</p>
<a routerLink="/account" class="inline-block px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"> Go to Account Page Now </a>
</ng-container>
<!-- Error state -->
<ng-container *ngIf="verificationStatus === 'error'">
<div class="flex justify-center mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h2 class="text-2xl font-bold text-red-600 mb-3">Verification Failed</h2>
<p class="text-gray-700 mb-4">{{ errorMessage }}</p>
<a routerLink="/login" class="inline-block px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"> Return to Login </a>
</ng-container>
</div>
</div>

View File

@ -0,0 +1,68 @@
import { CommonModule } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { environment } from '../../../environments/environment';
import { AuthService } from '../../services/auth.service';
import { UserService } from '../../services/user.service';
@Component({
selector: 'app-email-authorized',
standalone: true,
imports: [CommonModule, RouterModule],
templateUrl: './email-authorized.component.html',
})
export class EmailAuthorizedComponent implements OnInit {
verificationStatus: 'pending' | 'success' | 'error' = 'pending';
errorMessage: string | null = null;
constructor(private route: ActivatedRoute, private router: Router, private http: HttpClient, private authService: AuthService, private userService: UserService) {}
ngOnInit(): void {
const oobCode = this.route.snapshot.queryParamMap.get('oobCode');
const email = this.route.snapshot.queryParamMap.get('email');
const mode = this.route.snapshot.queryParamMap.get('mode');
if (mode === 'verifyEmail' && oobCode && email) {
this.verifyEmail(oobCode, email);
} else {
this.verificationStatus = 'error';
this.errorMessage = 'Invalid verification link';
}
}
private verifyEmail(oobCode: string, email: string): void {
this.http.post<{ message: string; token: string }>(`${environment.apiBaseUrl}/bizmatch/auth/verify-email`, { oobCode, email }).subscribe({
next: async response => {
this.verificationStatus = 'success';
try {
// Use the custom token from the server to sign in with Firebase
await this.authService.signInWithCustomToken(response.token);
// Try to get user info
try {
const user = await this.userService.getByMail(email);
console.log('User retrieved:', user);
} catch (userError) {
console.error('Error getting user:', userError);
// Don't change verification status - it's still a success
}
// Redirect to dashboard after a short delay
setTimeout(() => {
this.router.navigate(['/account']);
}, 5000);
} catch (authError) {
console.error('Error signing in with custom token:', authError);
// Keep success status for verification, but add warning about login
this.errorMessage = 'Email verified, but there was an issue signing you in. Please try logging in manually.';
}
},
error: err => {
this.verificationStatus = 'error';
this.errorMessage = err.error?.message || 'Verification failed';
},
});
}
}

View File

@ -0,0 +1,29 @@
<div class="container mx-auto p-4">
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6">
<h2 class="text-2xl font-semibold mb-6">Contact Us</h2>
<form #contactForm="ngForm" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<app-validated-input label="Your Name" name="name" [(ngModel)]="mailinfo.sender.name"></app-validated-input>
<app-validated-input label="Your Email" name="email" [(ngModel)]="mailinfo.sender.email" kind="email"></app-validated-input>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<app-validated-input label="Phone Number" name="phoneNumber" [(ngModel)]="mailinfo.sender.phoneNumber" mask="(000) 000-0000"></app-validated-input>
<div>
<app-validated-ng-select label="State" name="state" [(ngModel)]="mailinfo.sender.state" [items]="selectOptions?.states"></app-validated-ng-select>
</div>
</div>
<div>
<app-validated-textarea label="Questions/Comments" name="comments" [(ngModel)]="mailinfo.sender.comments"></app-validated-textarea>
</div>
<div>
<button
type="submit"
class="w-full md:w-auto px-6 py-2 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
(click)="mail()"
>
Submit
</button>
</div>
</form>
</div>
</div>

View File

@ -0,0 +1,7 @@
:host ::ng-deep .ng-select-container {
height: 42px !important;
border-radius: 0.5rem;
.ng-value-container .ng-input {
top: 10px;
}
}

View File

@ -0,0 +1,77 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { User } from '../../../../../bizmatch-server/src/models/db.model';
import { ErrorResponse, KeycloakUser, MailInfo } from '../../../../../bizmatch-server/src/models/main.model';
import { ValidatedInputComponent } from '../../components/validated-input/validated-input.component';
import { ValidatedNgSelectComponent } from '../../components/validated-ng-select/validated-ng-select.component';
import { ValidatedTextareaComponent } from '../../components/validated-textarea/validated-textarea.component';
import { ValidationMessagesService } from '../../components/validation-messages.service';
import { AuditService } from '../../services/audit.service';
import { AuthService } from '../../services/auth.service';
import { MailService } from '../../services/mail.service';
import { MessageService } from '../../services/message.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { UserService } from '../../services/user.service';
import { createMailInfo, map2User } from '../../utils/utils';
@Component({
selector: 'app-email-us',
standalone: true,
imports: [CommonModule, FormsModule, RouterModule, FontAwesomeModule, ValidatedInputComponent, ValidatedTextareaComponent, ValidatedNgSelectComponent],
providers: [],
templateUrl: './email-us.component.html',
styleUrl: './email-us.component.scss',
})
export class EmailUsComponent {
mailinfo: MailInfo;
keycloakUser: KeycloakUser;
user: User;
errorResponse: ErrorResponse;
constructor(
private mailService: MailService,
private userService: UserService,
private validationMessagesService: ValidationMessagesService,
private messageService: MessageService,
public selectOptions: SelectOptionsService,
private auditService: AuditService,
private authService: AuthService,
) {
this.mailinfo = createMailInfo();
}
async ngOnInit() {
const token = await this.authService.getToken();
this.keycloakUser = map2User(token);
if (this.keycloakUser) {
this.user = await this.userService.getByMail(this.keycloakUser.email);
this.mailinfo = createMailInfo(this.user);
}
}
ngOnDestroy() {
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
}
async mail() {
try {
this.validationMessagesService.updateMessages([]);
this.mailinfo.email = 'support@bizmatch.net';
await this.mailService.mail(this.mailinfo);
this.messageService.addMessage({ severity: 'success', text: 'Your request has been forwarded to the support team of bizmatch.', duration: 3000 });
this.auditService.createEvent(null, 'emailus', this.mailinfo.email, this.mailinfo);
this.mailinfo = createMailInfo(this.user);
} catch (error) {
this.messageService.addMessage({
severity: 'danger',
text: 'Please check your inputs',
duration: 5000,
});
if (error.error && Array.isArray(error.error?.message)) {
this.validationMessagesService.updateMessages(error.error.message);
}
}
}
containsError(fieldname: string) {
return this.errorResponse?.fields.map(f => f.fieldname).includes(fieldname);
}
}

View File

@ -0,0 +1,7 @@
<div class="flex flex-col items-center justify-center min-h-screen bg-gray-100">
<div class="bg-white p-8 rounded drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg w-full max-w-md text-center">
<h2 class="text-2xl font-bold mb-4">Email Verification</h2>
<p class="mb-4">A verification email has been sent to your email address. Please check your inbox and click the link to verify your account.</p>
<p>Once verified, please return to the application.</p>
</div>
</div>

View File

@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-email-verification',
standalone: true,
imports: [],
templateUrl: './email-verification.component.html',
})
export class EmailVerificationComponent {}

View File

@ -0,0 +1,85 @@
<div class="container mx-auto p-4">
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6">
<h1 class="text-2xl font-bold md:mb-4">My Favorites</h1>
<!-- Desktop view -->
<div class="hidden md:block">
<table class="w-full bg-white drop-shadow-inner-faint rounded-lg overflow-hidden">
<thead class="bg-gray-100">
<tr>
<th class="py-2 px-4 text-left">Title</th>
<th class="py-2 px-4 text-left">Category</th>
<th class="py-2 px-4 text-left">Located in</th>
<th class="py-2 px-4 text-left">Price</th>
<th class="py-2 px-4 text-left">Action</th>
</tr>
</thead>
<tbody>
@for(listing of favorites; track listing){
<tr class="border-b">
<td class="py-2 px-4">{{ listing.title }}</td>
<td class="py-2 px-4">{{ listing.listingsCategory === 'commercialProperty' ? 'Commercial Property' : 'Business' }}</td>
<td class="py-2 px-4">{{ listing.location.name ? listing.location.name : listing.location.county }}, {{ listing.location.state }}</td>
<td class="py-2 px-4">${{ listing.price.toLocaleString() }}</td>
<td class="py-2 px-4 flex">
@if(listing.listingsCategory==='business'){
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2" [routerLink]="['/details-business-listing', listing.id]">
<i class="fa-regular fa-eye"></i>
</button>
} @if(listing.listingsCategory==='commercialProperty'){
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2" [routerLink]="['/details-commercial-property-listing', listing.id]">
<i class="fa-regular fa-eye"></i>
</button>
}
<button class="bg-red-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2" (click)="confirmDelete(listing)">
<i class="fa-solid fa-trash"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Mobile view -->
<div class="md:hidden">
<div *ngFor="let listing of favorites" class="bg-white drop-shadow-inner-faint rounded-lg p-4 mb-4">
<h2 class="text-xl font-semibold mb-2">{{ listing.title }}</h2>
<p class="text-gray-600 mb-2">Category: {{ listing.listingsCategory === 'commercialProperty' ? 'Commercial Property' : 'Business' }}</p>
<p class="text-gray-600 mb-2">Located in: {{ listing.location.name ? listing.location.name : listing.location.county }}, {{ listing.location.state }}</p>
<p class="text-gray-600 mb-2">Price: ${{ listing.price.toLocaleString() }}</p>
<div class="flex justify-start">
@if(listing.listingsCategory==='business'){
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2" [routerLink]="['/details-business-listing', listing.id]">
<i class="fa-regular fa-eye"></i>
</button>
} @if(listing.listingsCategory==='commercialProperty'){
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2" [routerLink]="['/details-commercial-property-listing', listing.id]">
<i class="fa-regular fa-eye"></i>
</button>
}
<button class="bg-red-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2" (click)="confirmDelete(listing)">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>
</div>
<!-- <div class="flex items-center justify-between mt-4">
<p class="text-sm text-gray-600">Showing 1 to 2 of 2 entries</p>
<div class="flex items-center">
<button class="px-2 py-1 border rounded-l-md bg-gray-100">&lt;&lt;</button>
<button class="px-2 py-1 border-t border-b bg-gray-100">&lt;</button>
<button class="px-2 py-1 border bg-blue-500 text-white">1</button>
<button class="px-2 py-1 border-t border-b bg-gray-100">&gt;</button>
<button class="px-2 py-1 border rounded-r-md bg-gray-100">&gt;&gt;</button>
<select class="ml-2 border rounded-md px-2 py-1">
<option>10</option>
</select>
</div>
</div> -->
</div>
</div>
<app-confirmation></app-confirmation>

View File

@ -0,0 +1,3 @@
.wide-column{
width: 40%;
}

View File

@ -0,0 +1,43 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { BusinessListing } from '../../../../../bizmatch-server/src/models/db.model';
import { KeycloakUser } from '../../../../../bizmatch-server/src/models/main.model';
import { ConfirmationComponent } from '../../components/confirmation.component';
import { AuthService } from '../../services/auth.service';
import { ConfirmationService } from '../../services/confirmation.service';
import { ListingsService } from '../../services/listings.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { map2User } from '../../utils/utils';
@Component({
selector: 'app-favorites',
standalone: true,
imports: [CommonModule, FormsModule, RouterModule, FontAwesomeModule, ConfirmationComponent],
templateUrl: './favorites.component.html',
styleUrl: './favorites.component.scss',
})
export class FavoritesComponent {
user: KeycloakUser;
// listings: Array<ListingType> = []; //= dataListings as unknown as Array<BusinessListing>;
favorites: Array<BusinessListing>;
constructor(private listingsService: ListingsService, public selectOptions: SelectOptionsService, private confirmationService: ConfirmationService, private authService: AuthService) {}
async ngOnInit() {
const token = await this.authService.getToken();
this.user = map2User(token);
this.favorites = await this.listingsService.getFavoriteListings();
}
async confirmDelete(listing: BusinessListing) {
const confirmed = await this.confirmationService.showConfirmation({ message: `Are you sure you want to remove this listing from your Favorites?` });
if (confirmed) {
// this.messageService.showMessage('Listing has been deleted');
this.deleteListing(listing);
}
}
async deleteListing(listing: BusinessListing) {
await this.listingsService.removeFavorite(listing.id);
this.favorites = await this.listingsService.getFavoriteListings();
}
}

View File

@ -0,0 +1,248 @@
<!-- Navigation -->
<nav class="bg-white">
<div class="container mx-auto px-6 py-3 flex justify-between items-center">
<div class="flex items-center">
<a href="#" class="text-2xl font-bold text-blue-800">
<img src="assets/images/header-logo.png" alt="BizMatch.net" class="h-10" />
</a>
</div>
<div class="hidden md:flex items-center space-x-8">
<a href="#" class="text-gray-800 hover:text-blue-600">Home</a>
<a routerLink="/businessListings" class="text-blue-700 hover:font-bold">Businesses</a>
<a href="#services" class="text-gray-800 hover:text-blue-600">Services</a>
<a href="#location" class="text-gray-800 hover:text-blue-600">Location</a>
<a href="#contact" class="text-gray-800 hover:text-blue-600">Contact</a>
@if(user){
<a routerLink="/logout" class="text-gray-800 hover:text-blue-600">Logout</a>
}@else{
<a routerLink="/login" [queryParams]="{ mode: 'login' }" class="text-gray-800 hover:text-blue-600">Log In</a>
}
</div>
<div class="md:hidden">
<button class="text-gray-800 focus:outline-none" (click)="toggleMobileMenu()">
<svg class="h-6 w-6 fill-current" viewBox="0 0 24 24">
<path d="M4 5h16a1 1 0 0 1 0 2H4a1 1 0 1 1 0-2zm0 6h16a1 1 0 0 1 0 2H4a1 1 0 0 1 0-2zm0 6h16a1 1 0 0 1 0 2H4a1 1 0 0 1 0-2z"></path>
</svg>
</button>
</div>
</div>
<!-- Mobile menu (only shows when toggleMobileMenu is true) -->
<div *ngIf="showMobileMenu" class="md:hidden bg-white py-2 px-4">
<a href="#" class="block py-2 text-gray-800 hover:text-blue-600">Home</a>
<a href="#services" class="block py-2 text-gray-800 hover:text-blue-600">Services</a>
<a href="#location" class="block py-2 text-gray-800 hover:text-blue-600">Location</a>
<a href="#contact" class="block py-2 text-gray-800 hover:text-blue-600">Contact</a>
@if(user){
<a routerLink="/logout" class="block py-2 text-gray-800 hover:text-blue-600">Logout</a>
}@else{
<a routerLink="/login" [queryParams]="{ mode: 'login' }" class="block py-2 text-gray-800 hover:text-blue-600">Log In</a>
}
</div>
</nav>
<!-- Hero Section (made narrower) -->
<section class="hero-section flex items-center px-[2rem] py-[5rem]">
<div class="container mx-auto px-6 flex flex-col">
<!-- max-w-5xl makes it narrower -->
<div class="flex flex-col md:flex-row items-center">
<div class="md:w-1/2 text-white">
<h1 class="text-4xl md:text-5xl lg:text-6xl font-bold leading-tight mb-4">Connect with Your Ideal Business Opportunity</h1>
<p class="text-xl mb-8">BizMatch is your trusted partner in buying, selling, and valuing businesses in Texas.</p>
</div>
<div class="md:w-1/2 flex justify-center">
<img src="assets/images/corpusChristiSkyline.jpg" alt="Business handshake" class="rounded-lg shadow-2xl" />
</div>
</div>
<div class="flex justify-center mt-10">
<a routerLink="/businessListings" class="bg-green-500 hover:bg-green-700 md:text-2xl text-lg text-white font-semibold px-8 py-4 rounded-full shadow-lg transition duration-300"> View Available Businesses </a>
</div>
</div>
</section>
<!-- Services Section -->
<section id="services" class="py-20 bg-gray-50">
<div class="container mx-auto px-6">
<div class="text-center mb-16">
<h2 class="text-3xl font-bold text-blue-800 mb-4">Our Services</h2>
<p class="text-gray-600 max-w-2xl mx-auto">We offer comprehensive business brokerage services to help you navigate the complex process of buying or selling a business.</p>
</div>
<div class="flex flex-wrap -mx-4">
<!-- Service 1 -->
<div class="w-full md:w-1/3 px-4 mb-8">
<div class="service-card bg-white rounded-lg filter md:drop-shadow-custom-bg drop-shadow-custom-bg-mobile p-8 h-full">
<div class="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mb-6 mx-auto">
<svg class="w-8 h-8 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path
d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z"
></path>
</svg>
</div>
<h3 class="text-xl font-semibold text-blue-800 mb-4 text-center">Business Sales</h3>
<p class="text-gray-600 text-center">We help business owners prepare and market their businesses to qualified buyers, ensuring confidentiality throughout the process.</p>
</div>
</div>
<!-- Service 2 -->
<div class="w-full md:w-1/3 px-4 mb-8">
<div class="service-card bg-white rounded-lg filter md:drop-shadow-custom-bg drop-shadow-custom-bg-mobile p-8 h-full">
<div class="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mb-6 mx-auto">
<svg class="w-8 h-8 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 4a2 2 0 00-2 2v4a2 2 0 002 2V6h10a2 2 0 00-2-2H4zm2 6a2 2 0 012-2h8a2 2 0 012 2v4a2 2 0 01-2 2H8a2 2 0 01-2-2v-4zm6 4a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd"></path>
</svg>
</div>
<h3 class="text-xl font-semibold text-blue-800 mb-4 text-center">Business Acquisitions</h3>
<p class="text-gray-600 text-center">We assist buyers in finding the right business opportunity, perform due diligence, and negotiate favorable terms for acquisition.</p>
</div>
</div>
<!-- Service 3 -->
<div class="w-full md:w-1/3 px-4 mb-8">
<div class="service-card bg-white rounded-lg filter md:drop-shadow-custom-bg drop-shadow-custom-bg-mobile p-8 h-full">
<div class="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mb-6 mx-auto">
<svg class="w-8 h-8 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 110 2H6v1a1 1 0 11-2 0v-1H3a1 1 0 110-2h1v-1a1 1 0 011-1zM12 2a1 1 0 01.967.744L14.146 7.2 17.5 9.134a1 1 0 010 1.732l-3.354 1.935-1.18 4.455a1 1 0 01-1.933 0L9.854 12.8 6.5 10.866a1 1 0 010-1.732l3.354-1.935 1.18-4.455A1 1 0 0112 2z"
clip-rule="evenodd"
></path>
</svg>
</div>
<h3 class="text-xl font-semibold text-blue-800 mb-4 text-center">Business Valuation</h3>
<p class="text-gray-600 text-center">Our expert team provides accurate business valuations based on industry standards, financial performance, and market conditions.</p>
</div>
</div>
</div>
<!-- Video Section -->
<div class="mt-16 text-center">
<h3 class="text-2xl font-semibold text-blue-800 mb-8">See How We Work</h3>
<div class="max-w-4xl mx-auto">
<video controls class="w-full rounded-lg shadow-xl" poster="assets/images/video-poster.png">
<source src="assets/videos/Bizmatch30Spot.mp4" type="video/mp4" />
Your browser does not support the video tag.
</video>
</div>
</div>
</div>
</section>
<!-- Why Choose Us Section -->
<section class="py-20 bg-white">
<div class="container mx-auto px-6">
<div class="text-center mb-16">
<h2 class="text-3xl font-bold text-blue-800 mb-4">Why Choose BizMatch</h2>
<p class="text-gray-600 max-w-2xl mx-auto">With decades of experience in the business brokerage industry, we provide unparalleled service to our clients.</p>
</div>
<div class="flex flex-wrap -mx-4">
<!-- Feature 1 -->
<div class="w-full md:w-1/4 px-4 mb-8">
<div class="text-center">
<div class="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mb-6 mx-auto">
<svg class="w-8 h-8 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
></path>
</svg>
</div>
<h3 class="text-xl font-semibold text-blue-800 mb-2">Experience</h3>
<p class="text-gray-600">Over 25 years of combined experience in business brokerage.</p>
</div>
</div>
<!-- Feature 2 -->
<div class="w-full md:w-1/4 px-4 mb-8">
<div class="text-center">
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-6 mx-auto">
<svg class="w-8 h-8 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
</div>
<h3 class="text-xl font-semibold text-blue-800 mb-2">Confidentiality</h3>
<p class="text-gray-600">We maintain strict confidentiality throughout the entire transaction process.</p>
</div>
</div>
<!-- Feature 3 -->
<div class="w-full md:w-1/4 px-4 mb-8">
<div class="text-center">
<div class="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center mb-6 mx-auto">
<svg class="w-8 h-8 text-purple-600" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z"></path>
</svg>
</div>
<h3 class="text-xl font-semibold text-blue-800 mb-2">Network</h3>
<p class="text-gray-600">Extensive network of qualified buyers and business owners throughout Texas.</p>
</div>
</div>
<!-- Feature 4 -->
<div class="w-full md:w-1/4 px-4 mb-8">
<div class="text-center">
<div class="w-16 h-16 bg-yellow-100 rounded-full flex items-center justify-center mb-6 mx-auto">
<svg class="w-8 h-8 text-yellow-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd"></path>
</svg>
</div>
<h3 class="text-xl font-semibold text-blue-800 mb-2">Personalized Approach</h3>
<p class="text-gray-600">Customized strategy for each client based on their unique business goals.</p>
</div>
</div>
</div>
</div>
</section>
<!-- Location Section -->
<section id="location" class="py-20 bg-gray-50">
<div class="container mx-auto px-6">
<div class="flex flex-wrap items-stretch">
<!-- Changed from items-center to items-stretch -->
<div class="w-full lg:w-2/5 mb-12 lg:mb-0">
<div class="h-full flex flex-col">
<!-- Added flex container with h-full -->
<h2 class="text-3xl font-bold text-blue-800 mb-6">Visit Our Office</h2>
<p class="text-gray-600 mb-8 text-lg">Our team of business brokers is ready to assist you at our Corpus Christi location.</p>
<div class="bg-white p-6 rounded-lg shadow-lg flex-grow">
<!-- Added flex-grow to make it fill available space -->
<h3 class="text-xl font-semibold text-blue-800 mb-4">BizMatch Headquarters</h3>
<p class="text-gray-600 mb-2">1001 Blucher Street</p>
<p class="text-gray-600 mb-2">Corpus Christi, TX 78401</p>
<p class="text-gray-600 mb-6">United States</p>
<p class="text-gray-600 mb-2"><strong>Phone:</strong> (555) 123-4567</p>
<p class="text-gray-600"><strong>Email:</strong> info&#64;bizmatch.net</p>
</div>
</div>
</div>
<div class="w-full lg:w-3/5">
<div class="rounded-lg overflow-hidden shadow-xl h-full min-h-[384px]">
<!-- Changed h-96 to h-full with min-height -->
<iframe
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3533.7894679685755!2d-97.38527228476843!3d27.773756032788047!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x866c1e3b8a9d0c0b%3A0x8f2c1d4c1a5c5b2c!2s1001%20Blucher%20St%2C%20Corpus%20Christi%2C%20TX%2078401%2C%20USA!5e0!3m2!1sen!2sde!4v1672531192743!5m2!1sen!2sde"
width="100%"
height="100%"
class="rounded-lg border-0"
style="min-height: 384px; display: block"
allowfullscreen=""
loading="lazy"
referrerpolicy="no-referrer-when-downgrade"
>
</iframe>
</div>
</div>
</div>
</div>
</section>
<!-- Contact Section -->
<section id="contact" class="py-20 bg-blue-700">
<div class="container mx-auto px-6 text-center">
<h2 class="text-3xl font-bold text-white mb-8">Ready to Get Started?</h2>
<p class="text-white text-xl mb-12 max-w-3xl mx-auto">Contact our team of experienced business brokers today for a confidential consultation about buying or selling a business.</p>
<a routerLink="/emailUs" class="bg-white text-blue-700 font-bold px-8 py-4 rounded-lg shadow-lg hover:bg-gray-100 transition duration-300 text-lg">Contact Us Now</a>
</div>
</section>
<!-- Footer -->

View File

@ -0,0 +1,85 @@
// Hero section styles
.hero-section {
background: linear-gradient(135deg, #0046b5 0%, #00a0e9 100%);
// height: 70vh; // Made shorter as requested
// min-height: 500px; // Reduced from 600px
}
// Button hover effects
.btn-primary {
background-color: #0046b5;
transition: all 0.3s ease;
&:hover {
background-color: #003492;
}
}
// Service card animation
.service-card {
transition: all 0.3s ease;
&:hover {
transform: translateY(-10px);
}
}
// Responsive adjustments
@media (max-width: 768px) {
.hero-section {
height: auto;
padding: 4rem 0;
}
}
// Make sure the Google Map is responsive
google-map {
display: block;
width: 100%;
}
// Override Tailwind default styling for video
video {
max-width: 100%;
object-fit: cover;
}
// Zusätzliche Styles für den Location-Bereich
// Verbesserte Map-Container Styles
#location {
.rounded-lg.overflow-hidden {
position: relative;
height: 100%;
min-height: 384px;
}
iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
}
// Stellen Sie sicher, dass der Kartencontainer im mobilen Layout
// eine angemessene Höhe hat
@media (max-width: 1023px) {
.rounded-lg.overflow-hidden {
height: 400px;
}
}
}
// Adressbox-Styling verbessern
.bg-white.p-6.rounded-lg.shadow-lg.flex-grow {
display: flex;
flex-direction: column;
justify-content: space-between;
// Sicherstellen, dass der untere Bereich sichtbar bleibt
.contact-info {
margin-top: auto;
}
}

View File

@ -0,0 +1,51 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
import { User } from '../../../../../bizmatch-server/src/models/db.model';
import { KeycloakUser } from '../../../../../bizmatch-server/src/models/main.model';
import { AuthService } from '../../services/auth.service';
import { UserService } from '../../services/user.service';
import { map2User } from '../../utils/utils';
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.scss'],
standalone: true,
imports: [CommonModule, RouterOutlet, RouterLink],
})
export class HomeComponent implements OnInit {
showMobileMenu = false;
keycloakUser: KeycloakUser;
user: User;
constructor(private authService: AuthService, private userService: UserService) {}
async ngOnInit() {
// Add smooth scrolling for anchor links
this.setupSmoothScrolling();
const token = await this.authService.getToken();
this.keycloakUser = map2User(token);
if (this.keycloakUser) {
this.user = await this.userService.getByMail(this.keycloakUser.email);
this.userService.changeUser(this.user);
}
}
toggleMobileMenu(): void {
this.showMobileMenu = !this.showMobileMenu;
}
private setupSmoothScrolling(): void {
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
e.preventDefault();
const target = document.querySelector((this as HTMLAnchorElement).getAttribute('href') || '');
if (target) {
target.scrollIntoView({
behavior: 'smooth',
});
}
});
});
}
}

View File

@ -0,0 +1,98 @@
<div class="flex flex-col items-center justify-center min-h-screen">
<div class="bg-white p-8 rounded-lg drop-shadow-custom-bg w-full max-w-md">
<h2 class="text-2xl font-bold mb-6 text-center text-gray-800">
{{ isLoginMode ? 'Login' : 'Sign Up' }}
</h2>
<!-- Toggle Switch mit Flowbite -->
<div class="flex items-center justify-center mb-6">
<span class="mr-3 text-gray-700 font-medium">Login</span>
<label for="toggle-switch" class="inline-flex relative items-center cursor-pointer">
<input type="checkbox" id="toggle-switch" class="sr-only peer" [checked]="!isLoginMode" (change)="toggleMode()" />
<div
class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:bg-gray-700 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"
></div>
</label>
<span class="ml-3 text-gray-700 font-medium">Sign Up</span>
</div>
<!-- E-Mail Eingabe -->
<div class="mb-4">
<label for="email" class="block text-gray-700 mb-2 font-medium">E-Mail</label>
<div class="relative">
<input
id="email"
type="email"
[(ngModel)]="email"
placeholder="Please enter E-Mail Address"
class="w-full px-3 py-2 pl-10 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<fa-icon [icon]="envelope" class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></fa-icon>
</div>
</div>
<!-- Passwort Eingabe -->
<div class="mb-4">
<label for="password" class="block text-gray-700 mb-2 font-medium">Password</label>
<div class="relative">
<input
id="password"
type="password"
[(ngModel)]="password"
placeholder="Please enter Password"
class="w-full px-3 py-2 pl-10 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<fa-icon [icon]="lock" class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></fa-icon>
</div>
</div>
<!-- Passwort-Bestätigung nur im Registrierungsmodus -->
<div *ngIf="!isLoginMode" class="mb-6">
<label for="confirmPassword" class="block text-gray-700 mb-2 font-medium">Confirm Password</label>
<div class="relative">
<input
id="confirmPassword"
type="password"
[(ngModel)]="confirmPassword"
placeholder="Repeat Password"
class="w-full px-3 py-2 pl-10 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<fa-icon [icon]="lock" class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></fa-icon>
</div>
</div>
<!-- Fehlermeldung -->
<div *ngIf="errorMessage" class="text-red-500 text-center mb-4 text-sm">
{{ errorMessage }}
</div>
<!-- Submit Button -->
<button (click)="onSubmit()" class="w-full flex items-center justify-center bg-blue-600 hover:bg-blue-700 text-white py-2.5 rounded-lg mb-4 transition-colors duration-200">
<!-- <fa-icon [icon]="isLoginMode ? 'fas fas-user-plus' : 'userplus'" class="mr-2"></fa-icon> -->
<i *ngIf="isLoginMode" class="fa-solid fa-user-plus mr-2"></i>
<i *ngIf="!isLoginMode" class="fa-solid fa-arrow-right mr-2"></i>
{{ isLoginMode ? 'Sign in with Email' : 'Register' }}
</button>
<!-- Trennlinie -->
<div class="flex items-center justify-center my-4">
<span class="border-b w-1/5 md:w-1/4 border-gray-300"></span>
<span class="text-xs text-gray-500 uppercase mx-2">or</span>
<span class="border-b w-1/5 md:w-1/4 border-gray-300"></span>
</div>
<!-- Google Button -->
<button (click)="loginWithGoogle()" class="w-full flex items-center justify-center bg-white border border-gray-300 hover:bg-gray-50 text-gray-700 py-2.5 rounded-lg transition-colors duration-200">
<svg class="w-6 h-6 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<path
fill="#FFC107"
d="M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 12.955 4 4 12.955 4 24s8.955 20 20 20 20-8.955 20-20c0-1.341-.138-2.65-.389-3.917z"
/>
<path fill="#FF3D00" d="M6.306 14.691l6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 16.318 4 9.656 8.337 6.306 14.691z" />
<path fill="#4CAF50" d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238A11.91 11.91 0 0124 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44z" />
<path fill="#1976D2" d="M43.611 20.083H42V20H24v8h11.303a12.04 12.04 0 01-4.087 5.571l.003-.002 6.19 5.238C36.971 39.205 44 34 44 24c0-1.341-.138-2.65-.389-3.917z" />
</svg>
Continue with Google
</button>
</div>
</div>

View File

@ -0,0 +1,98 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { faArrowRight, faEnvelope, faLock, faUserPlus } from '@fortawesome/free-solid-svg-icons';
import { AuthService } from '../../services/auth.service';
import { LoadingService } from '../../services/loading.service';
@Component({
selector: 'app-login-register',
standalone: true,
imports: [CommonModule, FormsModule, FontAwesomeModule],
templateUrl: './login-register.component.html',
})
export class LoginRegisterComponent {
email: string = '';
password: string = '';
confirmPassword: string = '';
isLoginMode: boolean = true; // true: Login, false: Registration
errorMessage: string = '';
envelope = faEnvelope;
lock = faLock;
arrowRight = faArrowRight;
userplus = faUserPlus;
constructor(private authService: AuthService, private route: ActivatedRoute, private router: Router, private loadingService: LoadingService) {}
ngOnInit(): void {
// Set mode based on query parameter "mode"
this.route.queryParamMap.subscribe(params => {
const mode = params.get('mode');
this.isLoginMode = mode !== 'register';
});
}
toggleMode(): void {
this.isLoginMode = !this.isLoginMode;
this.errorMessage = '';
}
// Login with Email
onSubmit(): void {
this.errorMessage = '';
if (this.isLoginMode) {
this.authService.clearRoleCache();
this.authService
.loginWithEmail(this.email, this.password)
.then(userCredential => {
console.log('Successfully logged in:', userCredential);
this.router.navigate([`home`]);
})
.catch(error => {
console.error('Error during email login:', error);
this.errorMessage = error.message;
});
} else {
// Registration mode: also check if passwords match
if (this.password !== this.confirmPassword) {
console.error('Passwords do not match');
this.errorMessage = 'Passwords do not match.';
return;
}
this.loadingService.startLoading('googleAuth');
this.authService
.registerWithEmail(this.email, this.password)
.then(userCredential => {
console.log('Successfully registered:', userCredential);
this.loadingService.stopLoading('googleAuth');
this.router.navigate(['emailVerification']);
})
.catch(error => {
this.loadingService.stopLoading('googleAuth');
console.error('Error during registration:', error);
if (error.code === 'auth/email-already-in-use') {
this.errorMessage = 'This email address is already in use. Please try logging in.';
} else {
this.errorMessage = error.message;
}
});
}
}
// Login with Google
loginWithGoogle(): void {
this.errorMessage = '';
this.authService.clearRoleCache();
this.authService
.loginWithGoogle()
.then(userCredential => {
console.log('Successfully logged in with Google:', userCredential);
this.router.navigate([`home`]);
})
.catch(error => {
console.error('Error during Google login:', error);
this.errorMessage = error.message;
});
}
}

View File

@ -0,0 +1 @@
<p>logout works!</p>

View File

@ -0,0 +1,17 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { Router, RouterModule } from '@angular/router';
import { AuthService } from '../../services/auth.service';
@Component({
selector: 'logout',
standalone: true,
imports: [CommonModule, RouterModule],
template: ``,
})
export class LogoutComponent {
constructor(private authService: AuthService, private router: Router) {
this.authService.logout();
this.router.navigate(['/home']);
}
}

View File

@ -0,0 +1,113 @@
<div class="container mx-auto p-4">
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6">
<h1 class="text-2xl font-bold md:mb-4">My Listings</h1>
<!-- Desktop view -->
<div class="hidden md:block">
<table class="w-full bg-white drop-shadow-inner-faint rounded-lg overflow-hidden">
<thead class="bg-gray-100">
<tr>
<th class="py-2 px-4 text-left">Title</th>
<th class="py-2 px-4 text-left">Category</th>
<th class="py-2 px-4 text-left">Located in</th>
<th class="py-2 px-4 text-left">Price</th>
<th class="py-2 px-4 text-left">Publication Status</th>
<th class="py-2 px-4 text-left">Actions</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let listing of myListings" class="border-b">
<td class="py-2 px-4">{{ listing.title }}</td>
<td class="py-2 px-4">{{ listing.listingsCategory === 'commercialProperty' ? 'Commercial Property' : 'Business' }}</td>
<td class="py-2 px-4">{{ listing.location.name ? listing.location.name : listing.location.county }}, {{ listing.location.state }}</td>
<td class="py-2 px-4">${{ listing.price.toLocaleString() }}</td>
<td class="py-2 px-4">
<span class="{{ listing.draft ? 'bg-yellow-100 text-yellow-800' : 'bg-green-100 text-green-800' }} py-1 px-2 rounded-full text-xs font-medium">
{{ listing.draft ? 'Draft' : 'Published' }}
</span>
</td>
<td class="py-2 px-4">
@if(listing.listingsCategory==='business'){
<button class="bg-green-500 text-white p-2 rounded-full mr-2" [routerLink]="['/editBusinessListing', listing.id]">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
</button>
} @if(listing.listingsCategory==='commercialProperty'){
<button class="bg-green-500 text-white p-2 rounded-full mr-2" [routerLink]="['/editCommercialPropertyListing', listing.id]">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
</button>
}
<button class="bg-orange-500 text-white p-2 rounded-full" (click)="confirm(listing)">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Mobile view -->
<div class="md:hidden">
<div *ngFor="let listing of myListings" class="bg-white drop-shadow-inner-faint rounded-lg p-4 mb-4">
<h2 class="text-xl font-semibold mb-2">{{ listing.title }}</h2>
<p class="text-gray-600 mb-2">Category: {{ listing.listingsCategory === 'commercialProperty' ? 'Commercial Property' : 'Business' }}</p>
<p class="text-gray-600 mb-2">Located in: {{ listing.location.name ? listing.location.name : listing.location.county }} - {{ listing.location.state }}</p>
<p class="text-gray-600 mb-2">Price: ${{ listing.price.toLocaleString() }}</p>
<div class="flex items-center gap-2 mb-2">
<span class="text-gray-600">Publication Status:</span>
<span class="{{ listing.draft ? 'bg-yellow-100 text-yellow-800' : 'bg-green-100 text-green-800' }} py-1 px-2 rounded-full text-xs font-medium">
{{ listing.draft ? 'Draft' : 'Published' }}
</span>
</div>
<div class="flex justify-start">
@if(listing.listingsCategory==='business'){
<button class="bg-green-500 text-white p-2 rounded-full mr-2" [routerLink]="['/editBusinessListing', listing.id]">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
</button>
} @if(listing.listingsCategory==='commercialProperty'){
<button class="bg-green-500 text-white p-2 rounded-full mr-2" [routerLink]="['/editCommercialPropertyListing', listing.id]">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
</button>
}
<button class="bg-orange-500 text-white p-2 rounded-full" (click)="confirm(listing)">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
</div>
</div>
<!-- <div class="flex items-center justify-between mt-4">
<p class="text-sm text-gray-600">Showing 1 to 2 of 2 entries</p>
<div class="flex items-center">
<button class="px-2 py-1 border rounded-l-md bg-gray-100">&lt;&lt;</button>
<button class="px-2 py-1 border-t border-b bg-gray-100">&lt;</button>
<button class="px-2 py-1 border bg-blue-500 text-white">1</button>
<button class="px-2 py-1 border-t border-b bg-gray-100">&gt;</button>
<button class="px-2 py-1 border rounded-r-md bg-gray-100">&gt;&gt;</button>
<select class="ml-2 border rounded-md px-2 py-1">
<option>10</option>
</select>
</div>
</div> -->
</div>
</div>
<app-confirmation></app-confirmation>

View File

@ -0,0 +1,3 @@
.wide-column{
width: 40%;
}

View File

@ -0,0 +1,59 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { User } from '../../../../../bizmatch-server/src/models/db.model';
import { ListingType } from '../../../../../bizmatch-server/src/models/main.model';
import { ConfirmationComponent } from '../../components/confirmation.component';
import { MessageComponent } from '../../components/message/message.component';
import { AuthService } from '../../services/auth.service';
import { ConfirmationService } from '../../services/confirmation.service';
import { ListingsService } from '../../services/listings.service';
import { MessageService } from '../../services/message.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { UserService } from '../../services/user.service';
import { map2User } from '../../utils/utils';
@Component({
selector: 'app-my-listing',
standalone: true,
imports: [CommonModule, FormsModule, RouterModule, FontAwesomeModule, ConfirmationComponent, MessageComponent],
providers: [],
templateUrl: './my-listing.component.html',
styleUrl: './my-listing.component.scss',
})
export class MyListingComponent {
listings: Array<ListingType> = []; //dataListings as unknown as Array<BusinessListing>;
myListings: Array<ListingType>;
user: User;
constructor(
public userService: UserService,
private listingsService: ListingsService,
private cdRef: ChangeDetectorRef,
public selectOptions: SelectOptionsService,
private messageService: MessageService,
private confirmationService: ConfirmationService,
private authService: AuthService,
) {}
async ngOnInit() {
const token = await this.authService.getToken();
const keycloakUser = map2User(token);
const email = keycloakUser.email;
this.user = await this.userService.getByMail(email);
this.myListings = await this.listingsService.getListingsByEmail(this.user.email);
}
async deleteListing(listing: ListingType) {
await this.listingsService.deleteBusinessListing(listing.id);
this.myListings = await this.listingsService.getListingsByEmail(this.user.email);
}
async confirm(listing: ListingType) {
const confirmed = await this.confirmationService.showConfirmation({ message: `Are you sure you want to delete this listing?` });
if (confirmed) {
// this.messageService.showMessage('Listing has been deleted');
this.deleteListing(listing);
}
}
}

Some files were not shown because too many files have changed in this diff Show More