This commit is contained in:
2026-02-03 12:10:14 +01:00
parent 0bbfc3f4fb
commit 27aebcab38
119 changed files with 19593 additions and 19565 deletions

View File

@@ -1,275 +1,275 @@
# BizMatch SSR - Schritt-für-Schritt-Anleitung
## Problem: SSR startet nicht auf neuem Laptop?
Diese Anleitung hilft Ihnen, BizMatch mit Server-Side Rendering (SSR) auf einem neuen Rechner zum Laufen zu bringen.
---
## Voraussetzungen prüfen
```bash
# Node.js Version prüfen (mind. v18 erforderlich)
node --version
# npm Version prüfen
npm --version
# Falls Node.js fehlt oder veraltet ist:
# https://nodejs.org/ → LTS Version herunterladen
```
---
## Schritt 1: Repository klonen (falls noch nicht geschehen)
```bash
git clone https://gitea.bizmatch.net/aknuth/bizmatch-project.git
cd bizmatch-project/bizmatch
```
---
## Schritt 2: Dependencies installieren
**WICHTIG:** Dieser Schritt ist essentiell und wird oft vergessen!
```bash
cd ~/bizmatch-project/bizmatch
npm install
```
> **Tipp:** Bei Problemen versuchen Sie: `rm -rf node_modules package-lock.json && npm install`
---
## ⚠️ WICHTIG: Erstes Setup auf neuem Laptop
**Wenn Sie das Projekt zum ersten Mal auf einem neuen Rechner klonen, müssen Sie ZUERST einen Build erstellen!**
```bash
cd ~/bizmatch-project/bizmatch
# 1. Dependencies installieren
npm install
# 2. Build erstellen (erstellt dist/bizmatch/server/index.server.html)
npm run build:ssr
```
**Warum?**
- Die `dist/` Folder werden NICHT ins Git eingecheckt (`.gitignore`)
- Die Datei `dist/bizmatch/server/index.server.html` fehlt nach `git clone`
- Ohne Build → `npm run serve:ssr` crasht mit "Cannot find index.server.html"
**Nach dem ersten Build** können Sie dann Development-Befehle nutzen.
---
## Schritt 3: Umgebung wählen
### Option A: Entwicklung (OHNE SSR)
Schnellster Weg für lokale Entwicklung:
```bash
npm start
```
- Öffnet automatisch: http://localhost:4200
- Hot-Reload aktiv (Code-Änderungen werden sofort sichtbar)
- **Kein SSR** (schneller für Entwicklung)
### Option B: Development mit SSR
Für SSR-Testing während der Entwicklung:
```bash
npm run dev:ssr
```
- Öffnet: http://localhost:4200
- Hot-Reload aktiv
- **SSR aktiv** (simuliert Production)
- Nutzt DOM-Polyfills via `ssr-dom-preload.mjs`
### Option C: Production Build mit SSR
Für finalen Production-Test:
```bash
# 1. Build erstellen
npm run build:ssr
# 2. Server starten
npm run serve:ssr
```
- Server läuft auf: http://localhost:4200
- **Vollständiges SSR** (wie in Production)
- Kein Hot-Reload (für Änderungen erneut builden)
---
## Schritt 4: Testen
Öffnen Sie http://localhost:4200 im Browser.
### SSR funktioniert, wenn:
1. **Seitenquelltext ansehen** (Rechtsklick → "Seitenquelltext anzeigen"):
- HTML-Inhalt ist bereits vorhanden (nicht nur `<app-root></app-root>`)
- Meta-Tags sind sichtbar
2. **JavaScript deaktivieren** (Chrome DevTools → Settings → Disable JavaScript):
- Seite zeigt Inhalt an (wenn auch nicht interaktiv)
3. **Network-Tab** (Chrome DevTools → Network → Doc):
- HTML-Response enthält bereits gerenderten Content
---
## Häufige Probleme und Lösungen
### Problem 1: `npm: command not found`
**Lösung:** Node.js installieren
```bash
# Ubuntu/Debian
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo apt-get install -y nodejs
# macOS
brew install node
# Windows
# https://nodejs.org/ → Installer herunterladen
```
### Problem 2: `Cannot find module '@angular/ssr'`
**Lösung:** Dependencies neu installieren
```bash
rm -rf node_modules package-lock.json
npm install
```
### Problem 3: `Error: EADDRINUSE: address already in use :::4200`
**Lösung:** Port ist bereits belegt
```bash
# Prozess finden und beenden
lsof -i :4200
kill -9 <PID>
# Oder anderen Port nutzen
PORT=4300 npm run serve:ssr
```
### Problem 4: `Error loading @angular/platform-server` oder "Cannot find index.server.html"
**Lösung:** Build fehlt oder ist veraltet
```bash
# dist-Ordner löschen und neu builden
rm -rf dist
npm run build:ssr
# Dann starten
npm run serve:ssr
```
**Häufiger Fehler auf neuem Laptop:**
- Nach `git pull` fehlt der `dist/` Ordner komplett
- `index.server.html` wird beim Build erstellt, nicht ins Git eingecheckt
- **Lösung:** Immer erst `npm run build:ssr` ausführen!
### Problem 5: "Seite lädt nicht" oder "White Screen"
**Lösung:**
1. Browser-Cache leeren (Strg+Shift+R / Cmd+Shift+R)
2. DevTools öffnen → Console-Tab → Fehler prüfen
3. Sicherstellen, dass Backend läuft (falls API-Calls)
### Problem 6: "Module not found: Error: Can't resolve 'window'"
**Lösung:** Browser-spezifischer Code wird im SSR-Build verwendet
- Prüfen Sie `ssr-dom-polyfill.ts` - DOM-Mocks sollten vorhanden sein
- Code mit `isPlatformBrowser()` schützen:
```typescript
import { isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID } from '@angular/core';
constructor(@Inject(PLATFORM_ID) private platformId: Object) {}
ngOnInit() {
if (isPlatformBrowser(this.platformId)) {
// Nur im Browser ausführen
window.scrollTo(0, 0);
}
}
```
---
## Production Deployment mit PM2
Für dauerhaften Betrieb (Server-Umgebung):
```bash
# PM2 global installieren
npm install -g pm2
# Production Build
npm run build:ssr
# Server mit PM2 starten
pm2 start dist/bizmatch/server/server.mjs --name "bizmatch"
# Auto-Start bei Server-Neustart
pm2 startup
pm2 save
# Logs anzeigen
pm2 logs bizmatch
# Server neustarten nach Updates
npm run build:ssr && pm2 restart bizmatch
```
---
## Unterschiede der Befehle
| Befehl | SSR | Hot-Reload | Verwendung |
|--------|-----|-----------|------------|
| `npm start` | ❌ | ✅ | Entwicklung (schnell) |
| `npm run dev:ssr` | ✅ | ✅ | Entwicklung mit SSR |
| `npm run build:ssr` | ✅ Build | ❌ | Production Build erstellen |
| `npm run serve:ssr` | ✅ | ❌ | Production Server starten |
---
## Nächste Schritte
1. Für normale Entwicklung: **`npm start`** verwenden
2. Vor Production-Deployment: **`npm run build:ssr`** testen
3. SSR-Funktionalität prüfen (siehe "Schritt 4: Testen")
4. Bei Problemen: Logs prüfen und obige Lösungen durchgehen
---
## Support
Bei weiteren Problemen:
1. **Logs prüfen:** `npm run serve:ssr` zeigt Fehler in der Konsole
2. **Browser DevTools:** Console + Network Tab
3. **Build-Output:** `npm run build:ssr` zeigt Build-Fehler
4. **Node-Version:** `node --version` (sollte ≥ v18 sein)
# BizMatch SSR - Schritt-für-Schritt-Anleitung
## Problem: SSR startet nicht auf neuem Laptop?
Diese Anleitung hilft Ihnen, BizMatch mit Server-Side Rendering (SSR) auf einem neuen Rechner zum Laufen zu bringen.
---
## Voraussetzungen prüfen
```bash
# Node.js Version prüfen (mind. v18 erforderlich)
node --version
# npm Version prüfen
npm --version
# Falls Node.js fehlt oder veraltet ist:
# https://nodejs.org/ → LTS Version herunterladen
```
---
## Schritt 1: Repository klonen (falls noch nicht geschehen)
```bash
git clone https://gitea.bizmatch.net/aknuth/bizmatch-project.git
cd bizmatch-project/bizmatch
```
---
## Schritt 2: Dependencies installieren
**WICHTIG:** Dieser Schritt ist essentiell und wird oft vergessen!
```bash
cd ~/bizmatch-project/bizmatch
npm install
```
> **Tipp:** Bei Problemen versuchen Sie: `rm -rf node_modules package-lock.json && npm install`
---
## ⚠️ WICHTIG: Erstes Setup auf neuem Laptop
**Wenn Sie das Projekt zum ersten Mal auf einem neuen Rechner klonen, müssen Sie ZUERST einen Build erstellen!**
```bash
cd ~/bizmatch-project/bizmatch
# 1. Dependencies installieren
npm install
# 2. Build erstellen (erstellt dist/bizmatch/server/index.server.html)
npm run build:ssr
```
**Warum?**
- Die `dist/` Folder werden NICHT ins Git eingecheckt (`.gitignore`)
- Die Datei `dist/bizmatch/server/index.server.html` fehlt nach `git clone`
- Ohne Build → `npm run serve:ssr` crasht mit "Cannot find index.server.html"
**Nach dem ersten Build** können Sie dann Development-Befehle nutzen.
---
## Schritt 3: Umgebung wählen
### Option A: Entwicklung (OHNE SSR)
Schnellster Weg für lokale Entwicklung:
```bash
npm start
```
- Öffnet automatisch: http://localhost:4200
- Hot-Reload aktiv (Code-Änderungen werden sofort sichtbar)
- **Kein SSR** (schneller für Entwicklung)
### Option B: Development mit SSR
Für SSR-Testing während der Entwicklung:
```bash
npm run dev:ssr
```
- Öffnet: http://localhost:4200
- Hot-Reload aktiv
- **SSR aktiv** (simuliert Production)
- Nutzt DOM-Polyfills via `ssr-dom-preload.mjs`
### Option C: Production Build mit SSR
Für finalen Production-Test:
```bash
# 1. Build erstellen
npm run build:ssr
# 2. Server starten
npm run serve:ssr
```
- Server läuft auf: http://localhost:4200
- **Vollständiges SSR** (wie in Production)
- Kein Hot-Reload (für Änderungen erneut builden)
---
## Schritt 4: Testen
Öffnen Sie http://localhost:4200 im Browser.
### SSR funktioniert, wenn:
1. **Seitenquelltext ansehen** (Rechtsklick → "Seitenquelltext anzeigen"):
- HTML-Inhalt ist bereits vorhanden (nicht nur `<app-root></app-root>`)
- Meta-Tags sind sichtbar
2. **JavaScript deaktivieren** (Chrome DevTools → Settings → Disable JavaScript):
- Seite zeigt Inhalt an (wenn auch nicht interaktiv)
3. **Network-Tab** (Chrome DevTools → Network → Doc):
- HTML-Response enthält bereits gerenderten Content
---
## Häufige Probleme und Lösungen
### Problem 1: `npm: command not found`
**Lösung:** Node.js installieren
```bash
# Ubuntu/Debian
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo apt-get install -y nodejs
# macOS
brew install node
# Windows
# https://nodejs.org/ → Installer herunterladen
```
### Problem 2: `Cannot find module '@angular/ssr'`
**Lösung:** Dependencies neu installieren
```bash
rm -rf node_modules package-lock.json
npm install
```
### Problem 3: `Error: EADDRINUSE: address already in use :::4200`
**Lösung:** Port ist bereits belegt
```bash
# Prozess finden und beenden
lsof -i :4200
kill -9 <PID>
# Oder anderen Port nutzen
PORT=4300 npm run serve:ssr
```
### Problem 4: `Error loading @angular/platform-server` oder "Cannot find index.server.html"
**Lösung:** Build fehlt oder ist veraltet
```bash
# dist-Ordner löschen und neu builden
rm -rf dist
npm run build:ssr
# Dann starten
npm run serve:ssr
```
**Häufiger Fehler auf neuem Laptop:**
- Nach `git pull` fehlt der `dist/` Ordner komplett
- `index.server.html` wird beim Build erstellt, nicht ins Git eingecheckt
- **Lösung:** Immer erst `npm run build:ssr` ausführen!
### Problem 5: "Seite lädt nicht" oder "White Screen"
**Lösung:**
1. Browser-Cache leeren (Strg+Shift+R / Cmd+Shift+R)
2. DevTools öffnen → Console-Tab → Fehler prüfen
3. Sicherstellen, dass Backend läuft (falls API-Calls)
### Problem 6: "Module not found: Error: Can't resolve 'window'"
**Lösung:** Browser-spezifischer Code wird im SSR-Build verwendet
- Prüfen Sie `ssr-dom-polyfill.ts` - DOM-Mocks sollten vorhanden sein
- Code mit `isPlatformBrowser()` schützen:
```typescript
import { isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID } from '@angular/core';
constructor(@Inject(PLATFORM_ID) private platformId: Object) {}
ngOnInit() {
if (isPlatformBrowser(this.platformId)) {
// Nur im Browser ausführen
window.scrollTo(0, 0);
}
}
```
---
## Production Deployment mit PM2
Für dauerhaften Betrieb (Server-Umgebung):
```bash
# PM2 global installieren
npm install -g pm2
# Production Build
npm run build:ssr
# Server mit PM2 starten
pm2 start dist/bizmatch/server/server.mjs --name "bizmatch"
# Auto-Start bei Server-Neustart
pm2 startup
pm2 save
# Logs anzeigen
pm2 logs bizmatch
# Server neustarten nach Updates
npm run build:ssr && pm2 restart bizmatch
```
---
## Unterschiede der Befehle
| Befehl | SSR | Hot-Reload | Verwendung |
|--------|-----|-----------|------------|
| `npm start` | ❌ | ✅ | Entwicklung (schnell) |
| `npm run dev:ssr` | ✅ | ✅ | Entwicklung mit SSR |
| `npm run build:ssr` | ✅ Build | ❌ | Production Build erstellen |
| `npm run serve:ssr` | ✅ | ❌ | Production Server starten |
---
## Nächste Schritte
1. Für normale Entwicklung: **`npm start`** verwenden
2. Vor Production-Deployment: **`npm run build:ssr`** testen
3. SSR-Funktionalität prüfen (siehe "Schritt 4: Testen")
4. Bei Problemen: Logs prüfen und obige Lösungen durchgehen
---
## Support
Bei weiteren Problemen:
1. **Logs prüfen:** `npm run serve:ssr` zeigt Fehler in der Konsole
2. **Browser DevTools:** Console + Network Tab
3. **Build-Output:** `npm run build:ssr` zeigt Build-Fehler
4. **Node-Version:** `node --version` (sollte ≥ v18 sein)

File diff suppressed because it is too large Load Diff

View File

@@ -1,162 +1,162 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"bizmatch": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss",
"skipTests": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/bizmatch",
"index": "src/index.html",
"browser": "src/main.ts",
"server": "src/main.server.ts",
"prerender": false,
"ssr": {
"entry": "server.ts"
},
"allowedCommonJsDependencies": [
"quill-delta",
"leaflet",
"dayjs",
"qs"
],
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
},
"src/favicon.ico",
"src/assets",
"src/robots.txt",
{
"glob": "**/*",
"input": "node_modules/leaflet/dist/images",
"output": "assets/leaflet/"
}
],
"styles": [
"src/styles.scss",
"src/styles/lazy-load.css",
"node_modules/quill/dist/quill.snow.css",
"node_modules/leaflet/dist/leaflet.css",
"node_modules/ngx-sharebuttons/themes/default.scss"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"ssr": false
},
"dev": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.dev.ts"
}
],
"optimization": false,
"extractLicenses": false,
"sourceMap": true
},
"prod": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "bizmatch:build:production"
},
"development": {
"buildTarget": "bizmatch:build:development"
}
},
"defaultConfiguration": "development",
"options": {
"proxyConfig": "proxy.conf.json"
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"buildTarget": "bizmatch:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
"src/assets",
"cropped-Favicon-32x32.png",
"cropped-Favicon-180x180.png",
"cropped-Favicon-191x192.png",
{
"glob": "**/*",
"input": "./node_modules/leaflet/dist/images",
"output": "assets/"
}
],
"styles": [
"src/styles.scss"
],
"scripts": []
}
}
}
}
},
"cli": {
"analytics": false
}
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"bizmatch": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss",
"skipTests": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/bizmatch",
"index": "src/index.html",
"browser": "src/main.ts",
"server": "src/main.server.ts",
"prerender": false,
"ssr": {
"entry": "server.ts"
},
"allowedCommonJsDependencies": [
"quill-delta",
"leaflet",
"dayjs",
"qs"
],
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
},
"src/favicon.ico",
"src/assets",
"src/robots.txt",
{
"glob": "**/*",
"input": "node_modules/leaflet/dist/images",
"output": "assets/leaflet/"
}
],
"styles": [
"src/styles.scss",
"src/styles/lazy-load.css",
"node_modules/quill/dist/quill.snow.css",
"node_modules/leaflet/dist/leaflet.css",
"node_modules/ngx-sharebuttons/themes/default.scss"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"ssr": false
},
"dev": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.dev.ts"
}
],
"optimization": false,
"extractLicenses": false,
"sourceMap": true
},
"prod": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "bizmatch:build:production"
},
"development": {
"buildTarget": "bizmatch:build:development"
}
},
"defaultConfiguration": "development",
"options": {
"proxyConfig": "proxy.conf.json"
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"buildTarget": "bizmatch:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
"src/assets",
"cropped-Favicon-32x32.png",
"cropped-Favicon-180x180.png",
"cropped-Favicon-191x192.png",
{
"glob": "**/*",
"input": "./node_modules/leaflet/dist/images",
"output": "assets/"
}
],
"styles": [
"src/styles.scss"
],
"scripts": []
}
}
}
}
},
"cli": {
"analytics": false
}
}

View File

@@ -1,86 +1,86 @@
{
"name": "bizmatch",
"version": "0.0.1",
"scripts": {
"ng": "ng",
"start": "ng serve --host 0.0.0.0 & http-server ../bizmatch-server",
"prebuild": "node version.js",
"build": "node version.js && ng build",
"build.dev": "node version.js && ng build --configuration dev --output-hashing=all",
"build.prod": "node version.js && ng build --configuration prod --output-hashing=all",
"build:ssr": "node version.js && ng build --configuration prod",
"build:ssr:dev": "node version.js && ng build --configuration dev",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"serve:ssr": "node dist/bizmatch/server/server.mjs",
"serve:ssr:bizmatch": "node dist/bizmatch/server/server.mjs",
"dev:ssr": "NODE_OPTIONS='--import ./ssr-dom-preload.mjs' ng serve"
},
"private": true,
"dependencies": {
"@angular/animations": "^19.2.16",
"@angular/cdk": "^19.1.5",
"@angular/common": "^19.2.16",
"@angular/compiler": "^19.2.16",
"@angular/core": "^19.2.16",
"@angular/fire": "^19.2.0",
"@angular/forms": "^19.2.16",
"@angular/platform-browser": "^19.2.16",
"@angular/platform-browser-dynamic": "^19.2.16",
"@angular/platform-server": "^19.2.16",
"@angular/router": "^19.2.16",
"@angular/ssr": "^19.2.16",
"@bluehalo/ngx-leaflet": "^19.0.0",
"@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.9.0",
"@ngneat/until-destroy": "^10.0.0",
"@types/cropperjs": "^1.3.0",
"@types/leaflet": "^1.9.12",
"@types/uuid": "^10.0.0",
"browser-bunyan": "^1.8.0",
"dayjs": "^1.11.11",
"express": "^4.18.2",
"flowbite": "^2.4.1",
"jwt-decode": "^4.0.0",
"leaflet": "^1.9.4",
"memoize-one": "^6.0.0",
"ng-gallery": "^11.0.0",
"ngx-currency": "^19.0.0",
"ngx-image-cropper": "^8.0.0",
"ngx-mask": "^18.0.0",
"ngx-quill": "^27.1.2",
"ngx-sharebuttons": "^15.0.3",
"on-change": "^5.0.1",
"posthog-js": "^1.259.0",
"quill": "2.0.2",
"rxjs": "~7.8.1",
"tslib": "^2.6.3",
"urlcat": "^3.1.0",
"uuid": "^10.0.0",
"zone.js": "~0.15.0",
"zod": "^4.1.12"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.16",
"@angular/cli": "^19.2.16",
"@angular/compiler-cli": "^19.2.16",
"@types/express": "^4.17.21",
"@types/jasmine": "~5.1.4",
"@types/node": "^20.14.9",
"autoprefixer": "^10.4.19",
"http-server": "^14.1.1",
"jasmine-core": "~5.1.2",
"karma": "~6.4.2",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.1",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.4",
"typescript": "~5.7.2"
}
{
"name": "bizmatch",
"version": "0.0.1",
"scripts": {
"ng": "ng",
"start": "ng serve --host 0.0.0.0 & http-server ../bizmatch-server",
"prebuild": "node version.js",
"build": "node version.js && ng build",
"build.dev": "node version.js && ng build --configuration dev --output-hashing=all",
"build.prod": "node version.js && ng build --configuration prod --output-hashing=all",
"build:ssr": "node version.js && ng build --configuration prod",
"build:ssr:dev": "node version.js && ng build --configuration dev",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"serve:ssr": "node dist/bizmatch/server/server.mjs",
"serve:ssr:bizmatch": "node dist/bizmatch/server/server.mjs",
"dev:ssr": "NODE_OPTIONS='--import ./ssr-dom-preload.mjs' ng serve"
},
"private": true,
"dependencies": {
"@angular/animations": "^19.2.16",
"@angular/cdk": "^19.1.5",
"@angular/common": "^19.2.16",
"@angular/compiler": "^19.2.16",
"@angular/core": "^19.2.16",
"@angular/fire": "^19.2.0",
"@angular/forms": "^19.2.16",
"@angular/platform-browser": "^19.2.16",
"@angular/platform-browser-dynamic": "^19.2.16",
"@angular/platform-server": "^19.2.16",
"@angular/router": "^19.2.16",
"@angular/ssr": "^19.2.16",
"@bluehalo/ngx-leaflet": "^19.0.0",
"@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.9.0",
"@ngneat/until-destroy": "^10.0.0",
"@types/cropperjs": "^1.3.0",
"@types/leaflet": "^1.9.12",
"@types/uuid": "^10.0.0",
"browser-bunyan": "^1.8.0",
"dayjs": "^1.11.11",
"express": "^4.18.2",
"flowbite": "^2.4.1",
"jwt-decode": "^4.0.0",
"leaflet": "^1.9.4",
"memoize-one": "^6.0.0",
"ng-gallery": "^11.0.0",
"ngx-currency": "^19.0.0",
"ngx-image-cropper": "^8.0.0",
"ngx-mask": "^18.0.0",
"ngx-quill": "^27.1.2",
"ngx-sharebuttons": "^15.0.3",
"on-change": "^5.0.1",
"posthog-js": "^1.259.0",
"quill": "2.0.2",
"rxjs": "~7.8.1",
"tslib": "^2.6.3",
"urlcat": "^3.1.0",
"uuid": "^10.0.0",
"zone.js": "~0.15.0",
"zod": "^4.1.12"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.16",
"@angular/cli": "^19.2.16",
"@angular/compiler-cli": "^19.2.16",
"@types/express": "^4.17.21",
"@types/jasmine": "~5.1.4",
"@types/node": "^20.14.9",
"autoprefixer": "^10.4.19",
"http-server": "^14.1.1",
"jasmine-core": "~5.1.2",
"karma": "~6.4.2",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.1",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.4",
"typescript": "~5.7.2"
}
}

View File

@@ -1,28 +1,28 @@
{
"/bizmatch": {
"target": "http://localhost:3001",
"secure": false,
"changeOrigin": true,
"logLevel": "debug"
},
"/pictures": {
"target": "http://localhost:8081",
"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": ""
}
}
{
"/bizmatch": {
"target": "http://localhost:3001",
"secure": false,
"changeOrigin": true,
"logLevel": "debug"
},
"/pictures": {
"target": "http://localhost:8081",
"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

@@ -1,82 +1,82 @@
// IMPORTANT: DOM polyfill must be imported FIRST, before any browser-dependent libraries
import './src/ssr-dom-polyfill';
import { APP_BASE_HREF } from '@angular/common';
import { AngularNodeAppEngine, createNodeRequestHandler, writeResponseToNodeResponse } from '@angular/ssr/node';
import { ɵsetAngularAppEngineManifest as setAngularAppEngineManifest } from '@angular/ssr';
import express from 'express';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
// The Express app is exported so that it can be used by serverless Functions.
export async function app(): Promise<express.Express> {
const server = express();
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');
const indexHtml = join(serverDistFolder, 'index.server.html');
// Explicitly load and set the Angular app engine manifest
// This is required for environments where the manifest is not auto-loaded
const manifestPath = join(serverDistFolder, 'angular-app-engine-manifest.mjs');
const manifest = await import(manifestPath);
setAngularAppEngineManifest(manifest.default);
const angularApp = new AngularNodeAppEngine();
server.set('view engine', 'html');
server.set('views', browserDistFolder);
// Example Express Rest API endpoints
// server.get('/api/**', (req, res) => { });
// Serve static files from /browser
server.get('*.*', express.static(browserDistFolder, {
maxAge: '1y'
}));
// All regular routes use the Angular engine
server.get('*', async (req, res, next) => {
console.log(`[SSR] Handling request: ${req.method} ${req.url}`);
try {
const response = await angularApp.handle(req);
if (response) {
console.log(`[SSR] Response received for ${req.url}, status: ${response.status}`);
writeResponseToNodeResponse(response, res);
} else {
console.log(`[SSR] No response for ${req.url} - Angular engine returned null`);
console.log(`[SSR] This usually means the route couldn't be rendered. Check for:
1. Browser API usage in components
2. Missing platform checks
3. Errors during component initialization`);
res.sendStatus(404);
}
} catch (err) {
console.error(`[SSR] Error handling ${req.url}:`, err);
console.error(`[SSR] Stack trace:`, err.stack);
next(err);
}
});
return server;
}
// Global error handlers for debugging
process.on('unhandledRejection', (reason, promise) => {
console.error('[SSR] Unhandled Rejection at:', promise, 'reason:', reason);
});
process.on('uncaughtException', (error) => {
console.error('[SSR] Uncaught Exception:', error);
console.error('[SSR] Stack:', error.stack);
});
async function run(): Promise<void> {
const port = process.env['PORT'] || 4200;
// Start up the Node server
const server = await app();
server.listen(port, () => {
console.log(`Node Express server listening on http://localhost:${port}`);
});
}
run();
// IMPORTANT: DOM polyfill must be imported FIRST, before any browser-dependent libraries
import './src/ssr-dom-polyfill';
import { APP_BASE_HREF } from '@angular/common';
import { AngularNodeAppEngine, createNodeRequestHandler, writeResponseToNodeResponse } from '@angular/ssr/node';
import { ɵsetAngularAppEngineManifest as setAngularAppEngineManifest } from '@angular/ssr';
import express from 'express';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
// The Express app is exported so that it can be used by serverless Functions.
export async function app(): Promise<express.Express> {
const server = express();
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');
const indexHtml = join(serverDistFolder, 'index.server.html');
// Explicitly load and set the Angular app engine manifest
// This is required for environments where the manifest is not auto-loaded
const manifestPath = join(serverDistFolder, 'angular-app-engine-manifest.mjs');
const manifest = await import(manifestPath);
setAngularAppEngineManifest(manifest.default);
const angularApp = new AngularNodeAppEngine();
server.set('view engine', 'html');
server.set('views', browserDistFolder);
// Example Express Rest API endpoints
// server.get('/api/**', (req, res) => { });
// Serve static files from /browser
server.get('*.*', express.static(browserDistFolder, {
maxAge: '1y'
}));
// All regular routes use the Angular engine
server.get('*', async (req, res, next) => {
console.log(`[SSR] Handling request: ${req.method} ${req.url}`);
try {
const response = await angularApp.handle(req);
if (response) {
console.log(`[SSR] Response received for ${req.url}, status: ${response.status}`);
writeResponseToNodeResponse(response, res);
} else {
console.log(`[SSR] No response for ${req.url} - Angular engine returned null`);
console.log(`[SSR] This usually means the route couldn't be rendered. Check for:
1. Browser API usage in components
2. Missing platform checks
3. Errors during component initialization`);
res.sendStatus(404);
}
} catch (err) {
console.error(`[SSR] Error handling ${req.url}:`, err);
console.error(`[SSR] Stack trace:`, err.stack);
next(err);
}
});
return server;
}
// Global error handlers for debugging
process.on('unhandledRejection', (reason, promise) => {
console.error('[SSR] Unhandled Rejection at:', promise, 'reason:', reason);
});
process.on('uncaughtException', (error) => {
console.error('[SSR] Uncaught Exception:', error);
console.error('[SSR] Stack:', error.stack);
});
async function run(): Promise<void> {
const port = process.env['PORT'] || 4200;
// Start up the Node server
const server = await app();
server.listen(port, () => {
console.log(`Node Express server listening on http://localhost:${port}`);
});
}
run();

View File

@@ -1,85 +1,85 @@
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { AfterViewInit, Component, HostListener, PLATFORM_ID, inject } from '@angular/core';
import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/router';
import { initFlowbite } from 'flowbite';
import { filter } from 'rxjs/operators';
import build from '../build';
import { ConfirmationComponent } from './components/confirmation/confirmation.component';
import { ConfirmationService } from './components/confirmation/confirmation.service';
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 { SearchModalCommercialComponent } from './components/search-modal/search-modal-commercial.component';
import { SearchModalComponent } from './components/search-modal/search-modal.component';
import { AuditService } from './services/audit.service';
import { GeoService } from './services/geo.service';
import { LoadingService } from './services/loading.service';
import { UserService } from './services/user.service';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet, HeaderComponent, FooterComponent, MessageContainerComponent, SearchModalComponent, SearchModalCommercialComponent, ConfirmationComponent, EMailComponent],
providers: [],
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
})
export class AppComponent implements AfterViewInit {
build = build;
title = 'bizmatch';
actualRoute = '';
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
public constructor(
public loadingService: LoadingService,
private router: Router,
private activatedRoute: ActivatedRoute,
private userService: UserService,
private confirmationService: ConfirmationService,
private auditService: AuditService,
private geoService: GeoService,
) {
this.router.events.pipe(filter(event => event instanceof NavigationEnd)).subscribe(() => {
let currentRoute = this.activatedRoute.root;
while (currentRoute.children[0] !== undefined) {
currentRoute = currentRoute.children[0];
}
// Hier haben Sie Zugriff auf den aktuellen Route-Pfad
this.actualRoute = currentRoute.snapshot.url[0].path;
// Re-initialize Flowbite after navigation to ensure all components are ready
if (this.isBrowser) {
setTimeout(() => {
initFlowbite();
}, 50);
}
});
}
ngOnInit() {
// Navigation tracking moved from constructor
}
ngAfterViewInit() {
// Initialize Flowbite for dropdowns, modals, and other interactive components
// Note: Drawers work automatically with data-drawer-target attributes
if (this.isBrowser) {
initFlowbite();
}
}
@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' });
}
isFilterRoute(): boolean {
const filterRoutes = ['/businessListings', '/commercialPropertyListings', '/brokerListings'];
return filterRoutes.includes(this.actualRoute);
}
}
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { AfterViewInit, Component, HostListener, PLATFORM_ID, inject } from '@angular/core';
import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/router';
import { initFlowbite } from 'flowbite';
import { filter } from 'rxjs/operators';
import build from '../build';
import { ConfirmationComponent } from './components/confirmation/confirmation.component';
import { ConfirmationService } from './components/confirmation/confirmation.service';
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 { SearchModalCommercialComponent } from './components/search-modal/search-modal-commercial.component';
import { SearchModalComponent } from './components/search-modal/search-modal.component';
import { AuditService } from './services/audit.service';
import { GeoService } from './services/geo.service';
import { LoadingService } from './services/loading.service';
import { UserService } from './services/user.service';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet, HeaderComponent, FooterComponent, MessageContainerComponent, SearchModalComponent, SearchModalCommercialComponent, ConfirmationComponent, EMailComponent],
providers: [],
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
})
export class AppComponent implements AfterViewInit {
build = build;
title = 'bizmatch';
actualRoute = '';
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
public constructor(
public loadingService: LoadingService,
private router: Router,
private activatedRoute: ActivatedRoute,
private userService: UserService,
private confirmationService: ConfirmationService,
private auditService: AuditService,
private geoService: GeoService,
) {
this.router.events.pipe(filter(event => event instanceof NavigationEnd)).subscribe(() => {
let currentRoute = this.activatedRoute.root;
while (currentRoute.children[0] !== undefined) {
currentRoute = currentRoute.children[0];
}
// Hier haben Sie Zugriff auf den aktuellen Route-Pfad
this.actualRoute = currentRoute.snapshot.url[0].path;
// Re-initialize Flowbite after navigation to ensure all components are ready
if (this.isBrowser) {
setTimeout(() => {
initFlowbite();
}, 50);
}
});
}
ngOnInit() {
// Navigation tracking moved from constructor
}
ngAfterViewInit() {
// Initialize Flowbite for dropdowns, modals, and other interactive components
// Note: Drawers work automatically with data-drawer-target attributes
if (this.isBrowser) {
initFlowbite();
}
}
@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' });
}
isFilterRoute(): boolean {
const filterRoutes = ['/businessListings', '/commercialPropertyListings', '/brokerListings'];
return filterRoutes.includes(this.actualRoute);
}
}

View File

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

View File

@@ -1,102 +1,102 @@
import { IMAGE_CONFIG, isPlatformBrowser } from '@angular/common';
import { APP_INITIALIZER, ApplicationConfig, ErrorHandler, PLATFORM_ID, inject } from '@angular/core';
import { provideClientHydration } from '@angular/platform-browser';
import { provideRouter, withEnabledBlockingInitialNavigation, withInMemoryScrolling } from '@angular/router';
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { initializeApp, provideFirebaseApp } from '@angular/fire/app';
import { getAuth, provideAuth } from '@angular/fire/auth';
import { provideAnimations } from '@angular/platform-browser/animations';
import { GALLERY_CONFIG, GalleryConfig } from 'ng-gallery';
import { provideQuillConfig } from 'ngx-quill';
import { provideShareButtonsOptions, SharerMethods, withConfig } from 'ngx-sharebuttons';
import { shareIcons } from 'ngx-sharebuttons/icons';
import { environment } from '../environments/environment';
import { routes } from './app.routes';
import { AuthInterceptor } from './interceptors/auth.interceptor';
import { LoadingInterceptor } from './interceptors/loading.interceptor';
import { TimeoutInterceptor } from './interceptors/timeout.interceptor';
import { GlobalErrorHandler } from './services/globalErrorHandler';
import { POSTHOG_INIT_PROVIDER } from './services/posthog.factory';
import { SelectOptionsService } from './services/select-options.service';
import { createLogger } from './utils/utils';
const logger = createLogger('ApplicationConfig');
export const appConfig: ApplicationConfig = {
providers: [
// Temporarily disabled for SSR debugging
// provideClientHydration(),
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,
},
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
{
provide: 'TIMEOUT_DURATION',
useValue: 5000, // Standard-Timeout von 5 Sekunden
},
{
provide: GALLERY_CONFIG,
useValue: {
autoHeight: true,
imageSize: 'cover',
} as GalleryConfig,
},
{ provide: ErrorHandler, useClass: GlobalErrorHandler }, // Registriere den globalen ErrorHandler
{
provide: IMAGE_CONFIG,
useValue: {
disableImageSizeWarning: true,
},
},
provideShareButtonsOptions(
shareIcons(),
withConfig({
debug: true,
sharerMethod: SharerMethods.Anchor,
}),
),
provideRouter(
routes,
withEnabledBlockingInitialNavigation(),
withInMemoryScrolling({
scrollPositionRestoration: 'enabled',
anchorScrolling: 'enabled',
}),
),
...(environment.production ? [POSTHOG_INIT_PROVIDER] : []),
provideAnimations(),
provideQuillConfig({
modules: {
syntax: true,
toolbar: [
['bold', 'italic', 'underline'], // Einige Standardoptionen
[{ header: [1, 2, 3, false] }], // Benutzerdefinierte Header
[{ list: 'ordered' }, { list: 'bullet' }],
[{ color: [] }], // Dropdown mit Standardfarben
['clean'], // Entfernt Formatierungen
],
},
}),
provideFirebaseApp(() => initializeApp(environment.firebaseConfig)),
provideAuth(() => getAuth()),
],
};
function initServices(selectOptions: SelectOptionsService) {
return async () => {
await selectOptions.init();
};
}
import { IMAGE_CONFIG, isPlatformBrowser } from '@angular/common';
import { APP_INITIALIZER, ApplicationConfig, ErrorHandler, PLATFORM_ID, inject } from '@angular/core';
import { provideClientHydration } from '@angular/platform-browser';
import { provideRouter, withEnabledBlockingInitialNavigation, withInMemoryScrolling } from '@angular/router';
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { initializeApp, provideFirebaseApp } from '@angular/fire/app';
import { getAuth, provideAuth } from '@angular/fire/auth';
import { provideAnimations } from '@angular/platform-browser/animations';
import { GALLERY_CONFIG, GalleryConfig } from 'ng-gallery';
import { provideQuillConfig } from 'ngx-quill';
import { provideShareButtonsOptions, SharerMethods, withConfig } from 'ngx-sharebuttons';
import { shareIcons } from 'ngx-sharebuttons/icons';
import { environment } from '../environments/environment';
import { routes } from './app.routes';
import { AuthInterceptor } from './interceptors/auth.interceptor';
import { LoadingInterceptor } from './interceptors/loading.interceptor';
import { TimeoutInterceptor } from './interceptors/timeout.interceptor';
import { GlobalErrorHandler } from './services/globalErrorHandler';
import { POSTHOG_INIT_PROVIDER } from './services/posthog.factory';
import { SelectOptionsService } from './services/select-options.service';
import { createLogger } from './utils/utils';
const logger = createLogger('ApplicationConfig');
export const appConfig: ApplicationConfig = {
providers: [
// Temporarily disabled for SSR debugging
// provideClientHydration(),
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,
},
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
{
provide: 'TIMEOUT_DURATION',
useValue: 5000, // Standard-Timeout von 5 Sekunden
},
{
provide: GALLERY_CONFIG,
useValue: {
autoHeight: true,
imageSize: 'cover',
} as GalleryConfig,
},
{ provide: ErrorHandler, useClass: GlobalErrorHandler }, // Registriere den globalen ErrorHandler
{
provide: IMAGE_CONFIG,
useValue: {
disableImageSizeWarning: true,
},
},
provideShareButtonsOptions(
shareIcons(),
withConfig({
debug: true,
sharerMethod: SharerMethods.Anchor,
}),
),
provideRouter(
routes,
withEnabledBlockingInitialNavigation(),
withInMemoryScrolling({
scrollPositionRestoration: 'enabled',
anchorScrolling: 'enabled',
}),
),
...(environment.production ? [POSTHOG_INIT_PROVIDER] : []),
provideAnimations(),
provideQuillConfig({
modules: {
syntax: true,
toolbar: [
['bold', 'italic', 'underline'], // Einige Standardoptionen
[{ header: [1, 2, 3, false] }], // Benutzerdefinierte Header
[{ list: 'ordered' }, { list: 'bullet' }],
[{ color: [] }], // Dropdown mit Standardfarben
['clean'], // Entfernt Formatierungen
],
},
}),
provideFirebaseApp(() => initializeApp(environment.firebaseConfig)),
provideAuth(() => getAuth()),
],
};
function initServices(selectOptions: SelectOptionsService) {
return async () => {
await selectOptions.init();
};
}

View File

@@ -1,193 +1,193 @@
import { Routes } from '@angular/router';
import { LogoutComponent } from './components/logout/logout.component';
import { NotFoundComponent } from './components/not-found/not-found.component';
import { TestSsrComponent } from './components/test-ssr/test-ssr.component';
import { EmailAuthorizedComponent } from './components/email-authorized/email-authorized.component';
import { EmailVerificationComponent } from './components/email-verification/email-verification.component';
import { LoginRegisterComponent } from './components/login-register/login-register.component';
import { AuthGuard } from './guards/auth.guard';
import { ListingCategoryGuard } from './guards/listing-category.guard';
import { UserListComponent } from './pages/admin/user-list/user-list.component';
import { DetailsBusinessListingComponent } from './pages/details/details-business-listing/details-business-listing.component';
import { DetailsCommercialPropertyListingComponent } from './pages/details/details-commercial-property-listing/details-commercial-property-listing.component';
import { DetailsUserComponent } from './pages/details/details-user/details-user.component';
import { HomeComponent } from './pages/home/home.component';
import { BrokerListingsComponent } from './pages/listings/broker-listings/broker-listings.component';
import { BusinessListingsComponent } from './pages/listings/business-listings/business-listings.component';
import { CommercialPropertyListingsComponent } from './pages/listings/commercial-property-listings/commercial-property-listings.component';
import { AccountComponent } from './pages/subscription/account/account.component';
import { EditBusinessListingComponent } from './pages/subscription/edit-business-listing/edit-business-listing.component';
import { EditCommercialPropertyListingComponent } from './pages/subscription/edit-commercial-property-listing/edit-commercial-property-listing.component';
import { EmailUsComponent } from './pages/subscription/email-us/email-us.component';
import { FavoritesComponent } from './pages/subscription/favorites/favorites.component';
import { MyListingComponent } from './pages/subscription/my-listing/my-listing.component';
import { SuccessComponent } from './pages/success/success.component';
import { TermsOfUseComponent } from './pages/legal/terms-of-use.component';
import { PrivacyStatementComponent } from './pages/legal/privacy-statement.component';
export const routes: Routes = [
{
path: 'test-ssr',
component: TestSsrComponent,
},
{
path: 'businessListings',
component: BusinessListingsComponent,
runGuardsAndResolvers: 'always',
},
{
path: 'commercialPropertyListings',
component: CommercialPropertyListingsComponent,
runGuardsAndResolvers: 'always',
},
{
path: 'brokerListings',
component: BrokerListingsComponent,
runGuardsAndResolvers: 'always',
},
{
path: 'home',
component: HomeComponent,
},
// #########
// Listings Details - New SEO-friendly slug-based URLs
{
path: 'business/:slug',
component: DetailsBusinessListingComponent,
},
{
path: 'commercial-property/:slug',
component: DetailsCommercialPropertyListingComponent,
},
// Backward compatibility redirects for old UUID-based URLs
{
path: 'details-business-listing/:id',
redirectTo: 'business/:id',
pathMatch: 'full',
},
{
path: 'details-commercial-property-listing/:id',
redirectTo: 'commercial-property/:id',
pathMatch: 'full',
},
{
path: 'listing/:id',
canActivate: [ListingCategoryGuard],
component: NotFoundComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet
},
// {
// path: 'login/:page',
// component: LoginComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet
// },
{
path: 'login/:page',
component: LoginRegisterComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet
},
{
path: 'login',
component: LoginRegisterComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet
},
{
path: 'notfound',
component: NotFoundComponent,
},
// #########
// User Details
{
path: 'details-user/:id',
component: DetailsUserComponent,
},
// #########
// User edit
{
path: 'account',
component: AccountComponent,
canActivate: [AuthGuard],
},
{
path: 'account/:id',
component: AccountComponent,
canActivate: [AuthGuard],
},
// #########
// Create, Update Listings
{
path: 'editBusinessListing/:id',
component: EditBusinessListingComponent,
canActivate: [AuthGuard],
},
{
path: 'createBusinessListing',
component: EditBusinessListingComponent,
canActivate: [AuthGuard],
},
{
path: 'editCommercialPropertyListing/:id',
component: EditCommercialPropertyListingComponent,
canActivate: [AuthGuard],
},
{
path: 'createCommercialPropertyListing',
component: EditCommercialPropertyListingComponent,
canActivate: [AuthGuard],
},
// #########
// My Listings
{
path: 'myListings',
component: MyListingComponent,
canActivate: [AuthGuard],
},
// #########
// My Favorites
{
path: 'myFavorites',
component: FavoritesComponent,
canActivate: [AuthGuard],
},
// #########
// EMAil Us
{
path: 'emailUs',
component: EmailUsComponent,
// canActivate: [AuthGuard],
},
// #########
// Logout
{
path: 'logout',
component: LogoutComponent,
canActivate: [AuthGuard],
},
// #########
// Email Verification
{
path: 'emailVerification',
component: EmailVerificationComponent,
},
{
path: 'email-authorized',
component: EmailAuthorizedComponent,
},
{
path: 'success',
component: SuccessComponent,
},
{
path: 'admin/users',
component: UserListComponent,
canActivate: [AuthGuard],
},
// #########
// Legal Pages
{
path: 'terms-of-use',
component: TermsOfUseComponent,
},
{
path: 'privacy-statement',
component: PrivacyStatementComponent,
},
{ path: '**', redirectTo: 'home' },
];
import { Routes } from '@angular/router';
import { LogoutComponent } from './components/logout/logout.component';
import { NotFoundComponent } from './components/not-found/not-found.component';
import { TestSsrComponent } from './components/test-ssr/test-ssr.component';
import { EmailAuthorizedComponent } from './components/email-authorized/email-authorized.component';
import { EmailVerificationComponent } from './components/email-verification/email-verification.component';
import { LoginRegisterComponent } from './components/login-register/login-register.component';
import { AuthGuard } from './guards/auth.guard';
import { ListingCategoryGuard } from './guards/listing-category.guard';
import { UserListComponent } from './pages/admin/user-list/user-list.component';
import { DetailsBusinessListingComponent } from './pages/details/details-business-listing/details-business-listing.component';
import { DetailsCommercialPropertyListingComponent } from './pages/details/details-commercial-property-listing/details-commercial-property-listing.component';
import { DetailsUserComponent } from './pages/details/details-user/details-user.component';
import { HomeComponent } from './pages/home/home.component';
import { BrokerListingsComponent } from './pages/listings/broker-listings/broker-listings.component';
import { BusinessListingsComponent } from './pages/listings/business-listings/business-listings.component';
import { CommercialPropertyListingsComponent } from './pages/listings/commercial-property-listings/commercial-property-listings.component';
import { AccountComponent } from './pages/subscription/account/account.component';
import { EditBusinessListingComponent } from './pages/subscription/edit-business-listing/edit-business-listing.component';
import { EditCommercialPropertyListingComponent } from './pages/subscription/edit-commercial-property-listing/edit-commercial-property-listing.component';
import { EmailUsComponent } from './pages/subscription/email-us/email-us.component';
import { FavoritesComponent } from './pages/subscription/favorites/favorites.component';
import { MyListingComponent } from './pages/subscription/my-listing/my-listing.component';
import { SuccessComponent } from './pages/success/success.component';
import { TermsOfUseComponent } from './pages/legal/terms-of-use.component';
import { PrivacyStatementComponent } from './pages/legal/privacy-statement.component';
export const routes: Routes = [
{
path: 'test-ssr',
component: TestSsrComponent,
},
{
path: 'businessListings',
component: BusinessListingsComponent,
runGuardsAndResolvers: 'always',
},
{
path: 'commercialPropertyListings',
component: CommercialPropertyListingsComponent,
runGuardsAndResolvers: 'always',
},
{
path: 'brokerListings',
component: BrokerListingsComponent,
runGuardsAndResolvers: 'always',
},
{
path: 'home',
component: HomeComponent,
},
// #########
// Listings Details - New SEO-friendly slug-based URLs
{
path: 'business/:slug',
component: DetailsBusinessListingComponent,
},
{
path: 'commercial-property/:slug',
component: DetailsCommercialPropertyListingComponent,
},
// Backward compatibility redirects for old UUID-based URLs
{
path: 'details-business-listing/:id',
redirectTo: 'business/:id',
pathMatch: 'full',
},
{
path: 'details-commercial-property-listing/:id',
redirectTo: 'commercial-property/:id',
pathMatch: 'full',
},
{
path: 'listing/:id',
canActivate: [ListingCategoryGuard],
component: NotFoundComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet
},
// {
// path: 'login/:page',
// component: LoginComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet
// },
{
path: 'login/:page',
component: LoginRegisterComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet
},
{
path: 'login',
component: LoginRegisterComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet
},
{
path: 'notfound',
component: NotFoundComponent,
},
// #########
// User Details
{
path: 'details-user/:id',
component: DetailsUserComponent,
},
// #########
// User edit
{
path: 'account',
component: AccountComponent,
canActivate: [AuthGuard],
},
{
path: 'account/:id',
component: AccountComponent,
canActivate: [AuthGuard],
},
// #########
// Create, Update Listings
{
path: 'editBusinessListing/:id',
component: EditBusinessListingComponent,
canActivate: [AuthGuard],
},
{
path: 'createBusinessListing',
component: EditBusinessListingComponent,
canActivate: [AuthGuard],
},
{
path: 'editCommercialPropertyListing/:id',
component: EditCommercialPropertyListingComponent,
canActivate: [AuthGuard],
},
{
path: 'createCommercialPropertyListing',
component: EditCommercialPropertyListingComponent,
canActivate: [AuthGuard],
},
// #########
// My Listings
{
path: 'myListings',
component: MyListingComponent,
canActivate: [AuthGuard],
},
// #########
// My Favorites
{
path: 'myFavorites',
component: FavoritesComponent,
canActivate: [AuthGuard],
},
// #########
// EMAil Us
{
path: 'emailUs',
component: EmailUsComponent,
// canActivate: [AuthGuard],
},
// #########
// Logout
{
path: 'logout',
component: LogoutComponent,
canActivate: [AuthGuard],
},
// #########
// Email Verification
{
path: 'emailVerification',
component: EmailVerificationComponent,
},
{
path: 'email-authorized',
component: EmailAuthorizedComponent,
},
{
path: 'success',
component: SuccessComponent,
},
{
path: 'admin/users',
component: UserListComponent,
canActivate: [AuthGuard],
},
// #########
// Legal Pages
{
path: 'terms-of-use',
component: TermsOfUseComponent,
},
{
path: 'privacy-statement',
component: PrivacyStatementComponent,
},
{ path: '**', redirectTo: 'home' },
];

View File

@@ -1,57 +1,57 @@
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: ``,
standalone: true,
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();
});
// Flowbite is now initialized once in AppComponent
}
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;
}
}
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: ``,
standalone: true,
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();
});
// Flowbite is now initialized once in AppComponent
}
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

@@ -1,68 +1,68 @@
import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
import { RouterModule } from '@angular/router';
export interface BreadcrumbItem {
label: string;
url?: string;
icon?: string;
}
@Component({
selector: 'app-breadcrumbs',
standalone: true,
imports: [CommonModule, RouterModule],
template: `
<nav aria-label="Breadcrumb" class="mb-4">
<ol
class="flex flex-wrap items-center text-sm text-neutral-600"
itemscope
itemtype="https://schema.org/BreadcrumbList"
>
@for (item of breadcrumbs; track $index) {
<li
class="inline-flex items-center"
itemprop="itemListElement"
itemscope
itemtype="https://schema.org/ListItem"
>
@if ($index > 0) {
<span class="inline-flex items-center mx-2 text-neutral-400 select-none">
<i class="fas fa-chevron-right text-xs"></i>
</span>
}
@if (item.url && $index < breadcrumbs.length - 1) {
<a
[routerLink]="item.url"
class="inline-flex items-center hover:text-blue-600 transition-colors"
itemprop="item"
>
@if (item.icon) {
<i [class]="item.icon + ' mr-1'"></i>
}
<span itemprop="name">{{ item.label }}</span>
</a>
} @else {
<span
class="inline-flex items-center font-semibold text-neutral-900"
itemprop="item"
>
@if (item.icon) {
<i [class]="item.icon + ' mr-1'"></i>
}
<span itemprop="name">{{ item.label }}</span>
</span>
}
<meta itemprop="position" [content]="($index + 1).toString()" />
</li>
}
</ol>
</nav>
`,
styles: []
})
export class BreadcrumbsComponent {
@Input() breadcrumbs: BreadcrumbItem[] = [];
}
import { CommonModule } from '@angular/common';
import { Component, Input } from '@angular/core';
import { RouterModule } from '@angular/router';
export interface BreadcrumbItem {
label: string;
url?: string;
icon?: string;
}
@Component({
selector: 'app-breadcrumbs',
standalone: true,
imports: [CommonModule, RouterModule],
template: `
<nav aria-label="Breadcrumb" class="mb-4">
<ol
class="flex flex-wrap items-center text-sm text-neutral-600"
itemscope
itemtype="https://schema.org/BreadcrumbList"
>
@for (item of breadcrumbs; track $index) {
<li
class="inline-flex items-center"
itemprop="itemListElement"
itemscope
itemtype="https://schema.org/ListItem"
>
@if ($index > 0) {
<span class="inline-flex items-center mx-2 text-neutral-400 select-none">
<i class="fas fa-chevron-right text-xs"></i>
</span>
}
@if (item.url && $index < breadcrumbs.length - 1) {
<a
[routerLink]="item.url"
class="inline-flex items-center hover:text-blue-600 transition-colors"
itemprop="item"
>
@if (item.icon) {
<i [class]="item.icon + ' mr-1'"></i>
}
<span itemprop="name">{{ item.label }}</span>
</a>
} @else {
<span
class="inline-flex items-center font-semibold text-neutral-900"
itemprop="item"
>
@if (item.icon) {
<i [class]="item.icon + ' mr-1'"></i>
}
<span itemprop="name">{{ item.label }}</span>
</span>
}
<meta itemprop="position" [content]="($index + 1).toString()" />
</li>
}
</ol>
</nav>
`,
styles: []
})
export class BreadcrumbsComponent {
@Input() breadcrumbs: BreadcrumbItem[] = [];
}

View File

@@ -1,138 +1,138 @@
import { AfterViewInit, Component, ElementRef, HostBinding, Input, OnDestroy, ViewChild, PLATFORM_ID, inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { createPopper, Instance as PopperInstance } from '@popperjs/core';
@Component({
selector: 'app-dropdown',
template: `
<div #targetEl [class.hidden]="!isVisible" class="z-50">
<ng-content></ng-content>
</div>
`,
standalone: true,
})
export class DropdownComponent implements AfterViewInit, OnDestroy {
@ViewChild('targetEl') targetEl!: ElementRef<HTMLElement>;
@Input() triggerEl!: HTMLElement;
@Input() placement: any = 'bottom';
@Input() triggerType: 'click' | 'hover' = 'click';
@Input() offsetSkidding: number = 0;
@Input() offsetDistance: number = 10;
@Input() delay: number = 300;
@Input() ignoreClickOutsideClass: string | false = false;
@HostBinding('class.hidden') isHidden: boolean = true;
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
private popperInstance: PopperInstance | null = null;
isVisible: boolean = false;
private clickOutsideListener: any;
private hoverShowListener: any;
private hoverHideListener: any;
ngAfterViewInit() {
if (!this.isBrowser) return;
if (!this.triggerEl) {
console.error('Trigger element is not provided to the dropdown component.');
return;
}
this.initializePopper();
this.setupEventListeners();
}
ngOnDestroy() {
this.destroyPopper();
this.removeEventListeners();
}
private initializePopper() {
this.popperInstance = createPopper(this.triggerEl, this.targetEl.nativeElement, {
placement: this.placement,
modifiers: [
{
name: 'offset',
options: {
offset: [this.offsetSkidding, this.offsetDistance],
},
},
],
});
}
private setupEventListeners() {
if (!this.isBrowser) return;
if (this.triggerType === 'click') {
this.triggerEl.addEventListener('click', () => this.toggle());
} else if (this.triggerType === 'hover') {
this.hoverShowListener = () => this.show();
this.hoverHideListener = () => this.hide();
this.triggerEl.addEventListener('mouseenter', this.hoverShowListener);
this.triggerEl.addEventListener('mouseleave', this.hoverHideListener);
this.targetEl.nativeElement.addEventListener('mouseenter', this.hoverShowListener);
this.targetEl.nativeElement.addEventListener('mouseleave', this.hoverHideListener);
}
this.clickOutsideListener = (event: MouseEvent) => this.handleClickOutside(event);
document.addEventListener('click', this.clickOutsideListener);
}
private removeEventListeners() {
if (!this.isBrowser) return;
if (this.triggerType === 'click') {
this.triggerEl.removeEventListener('click', () => this.toggle());
} else if (this.triggerType === 'hover') {
this.triggerEl.removeEventListener('mouseenter', this.hoverShowListener);
this.triggerEl.removeEventListener('mouseleave', this.hoverHideListener);
this.targetEl.nativeElement.removeEventListener('mouseenter', this.hoverShowListener);
this.targetEl.nativeElement.removeEventListener('mouseleave', this.hoverHideListener);
}
document.removeEventListener('click', this.clickOutsideListener);
}
toggle() {
this.isVisible ? this.hide() : this.show();
}
show() {
this.isVisible = true;
this.isHidden = false;
this.targetEl.nativeElement.classList.remove('hidden');
this.popperInstance?.update();
}
hide() {
this.isVisible = false;
this.isHidden = true;
this.targetEl.nativeElement.classList.add('hidden');
}
private handleClickOutside(event: MouseEvent) {
if (!this.isVisible || !this.isBrowser) return;
const clickedElement = event.target as HTMLElement;
if (this.ignoreClickOutsideClass) {
const ignoredElements = document.querySelectorAll(`.${this.ignoreClickOutsideClass}`);
const arr = Array.from(ignoredElements);
for (const el of arr) {
if (el.contains(clickedElement)) return;
}
}
if (!this.targetEl.nativeElement.contains(clickedElement) && !this.triggerEl.contains(clickedElement)) {
this.hide();
}
}
private destroyPopper() {
if (this.popperInstance) {
this.popperInstance.destroy();
this.popperInstance = null;
}
}
}
import { AfterViewInit, Component, ElementRef, HostBinding, Input, OnDestroy, ViewChild, PLATFORM_ID, inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { createPopper, Instance as PopperInstance } from '@popperjs/core';
@Component({
selector: 'app-dropdown',
template: `
<div #targetEl [class.hidden]="!isVisible" class="z-50">
<ng-content></ng-content>
</div>
`,
standalone: true,
})
export class DropdownComponent implements AfterViewInit, OnDestroy {
@ViewChild('targetEl') targetEl!: ElementRef<HTMLElement>;
@Input() triggerEl!: HTMLElement;
@Input() placement: any = 'bottom';
@Input() triggerType: 'click' | 'hover' = 'click';
@Input() offsetSkidding: number = 0;
@Input() offsetDistance: number = 10;
@Input() delay: number = 300;
@Input() ignoreClickOutsideClass: string | false = false;
@HostBinding('class.hidden') isHidden: boolean = true;
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
private popperInstance: PopperInstance | null = null;
isVisible: boolean = false;
private clickOutsideListener: any;
private hoverShowListener: any;
private hoverHideListener: any;
ngAfterViewInit() {
if (!this.isBrowser) return;
if (!this.triggerEl) {
console.error('Trigger element is not provided to the dropdown component.');
return;
}
this.initializePopper();
this.setupEventListeners();
}
ngOnDestroy() {
this.destroyPopper();
this.removeEventListeners();
}
private initializePopper() {
this.popperInstance = createPopper(this.triggerEl, this.targetEl.nativeElement, {
placement: this.placement,
modifiers: [
{
name: 'offset',
options: {
offset: [this.offsetSkidding, this.offsetDistance],
},
},
],
});
}
private setupEventListeners() {
if (!this.isBrowser) return;
if (this.triggerType === 'click') {
this.triggerEl.addEventListener('click', () => this.toggle());
} else if (this.triggerType === 'hover') {
this.hoverShowListener = () => this.show();
this.hoverHideListener = () => this.hide();
this.triggerEl.addEventListener('mouseenter', this.hoverShowListener);
this.triggerEl.addEventListener('mouseleave', this.hoverHideListener);
this.targetEl.nativeElement.addEventListener('mouseenter', this.hoverShowListener);
this.targetEl.nativeElement.addEventListener('mouseleave', this.hoverHideListener);
}
this.clickOutsideListener = (event: MouseEvent) => this.handleClickOutside(event);
document.addEventListener('click', this.clickOutsideListener);
}
private removeEventListeners() {
if (!this.isBrowser) return;
if (this.triggerType === 'click') {
this.triggerEl.removeEventListener('click', () => this.toggle());
} else if (this.triggerType === 'hover') {
this.triggerEl.removeEventListener('mouseenter', this.hoverShowListener);
this.triggerEl.removeEventListener('mouseleave', this.hoverHideListener);
this.targetEl.nativeElement.removeEventListener('mouseenter', this.hoverShowListener);
this.targetEl.nativeElement.removeEventListener('mouseleave', this.hoverHideListener);
}
document.removeEventListener('click', this.clickOutsideListener);
}
toggle() {
this.isVisible ? this.hide() : this.show();
}
show() {
this.isVisible = true;
this.isHidden = false;
this.targetEl.nativeElement.classList.remove('hidden');
this.popperInstance?.update();
}
hide() {
this.isVisible = false;
this.isHidden = true;
this.targetEl.nativeElement.classList.add('hidden');
}
private handleClickOutside(event: MouseEvent) {
if (!this.isVisible || !this.isBrowser) return;
const clickedElement = event.target as HTMLElement;
if (this.ignoreClickOutsideClass) {
const ignoredElements = document.querySelectorAll(`.${this.ignoreClickOutsideClass}`);
const arr = Array.from(ignoredElements);
for (const el of arr) {
if (el.contains(clickedElement)) return;
}
}
if (!this.targetEl.nativeElement.contains(clickedElement) && !this.triggerEl.contains(clickedElement)) {
this.hide();
}
}
private destroyPopper() {
if (this.popperInstance) {
this.popperInstance.destroy();
this.popperInstance = null;
}
}
}

View File

@@ -1,48 +1,48 @@
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 = {
yourName: '',
recipientEmail: '',
yourEmail: '',
type: 'business',
listingTitle: '',
url: '',
id: ''
};
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
}
}
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 = {
yourName: '',
recipientEmail: '',
yourEmail: '',
type: 'business',
listingTitle: '',
url: '',
id: ''
};
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

@@ -1,93 +1,93 @@
import { CommonModule } from '@angular/common';
import { Component, Input, OnInit } from '@angular/core';
import { SeoService } from '../../services/seo.service';
export interface FAQItem {
question: string;
answer: string;
}
@Component({
selector: 'app-faq',
standalone: true,
imports: [CommonModule],
template: `
<section class="bg-white rounded-lg shadow-lg p-6 md:p-8 my-8">
<h2 class="text-2xl md:text-3xl font-bold text-gray-900 mb-6">Frequently Asked Questions</h2>
<div class="space-y-4">
@for (item of faqItems; track $index) {
<div class="border-b border-gray-200 pb-4">
<button
(click)="toggle($index)"
class="w-full text-left flex justify-between items-center py-2 hover:text-blue-600 transition-colors"
[attr.aria-expanded]="openIndex === $index"
>
<h3 class="text-lg font-semibold text-gray-800">{{ item.question }}</h3>
<svg
class="w-5 h-5 transition-transform"
[class.rotate-180]="openIndex === $index"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
@if (openIndex === $index) {
<div class="mt-3 text-gray-600 leading-relaxed">
<p [innerHTML]="item.answer"></p>
</div>
}
</div>
}
</div>
</section>
`,
styles: [`
.rotate-180 {
transform: rotate(180deg);
}
`]
})
export class FaqComponent implements OnInit {
@Input() faqItems: FAQItem[] = [];
openIndex: number | null = null;
constructor(private seoService: SeoService) {}
ngOnInit() {
// Generate and inject FAQ Schema for rich snippets
if (this.faqItems.length > 0) {
const faqSchema = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
'mainEntity': this.faqItems.map(item => ({
'@type': 'Question',
'name': item.question,
'acceptedAnswer': {
'@type': 'Answer',
'text': this.stripHtml(item.answer)
}
}))
};
this.seoService.injectStructuredData(faqSchema);
}
}
toggle(index: number) {
this.openIndex = this.openIndex === index ? null : index;
}
private stripHtml(html: string): string {
const tmp = document.createElement('DIV');
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || '';
}
ngOnDestroy() {
this.seoService.clearStructuredData();
}
}
import { CommonModule } from '@angular/common';
import { Component, Input, OnInit } from '@angular/core';
import { SeoService } from '../../services/seo.service';
export interface FAQItem {
question: string;
answer: string;
}
@Component({
selector: 'app-faq',
standalone: true,
imports: [CommonModule],
template: `
<section class="bg-white rounded-lg shadow-lg p-6 md:p-8 my-8">
<h2 class="text-2xl md:text-3xl font-bold text-gray-900 mb-6">Frequently Asked Questions</h2>
<div class="space-y-4">
@for (item of faqItems; track $index) {
<div class="border-b border-gray-200 pb-4">
<button
(click)="toggle($index)"
class="w-full text-left flex justify-between items-center py-2 hover:text-blue-600 transition-colors"
[attr.aria-expanded]="openIndex === $index"
>
<h3 class="text-lg font-semibold text-gray-800">{{ item.question }}</h3>
<svg
class="w-5 h-5 transition-transform"
[class.rotate-180]="openIndex === $index"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
@if (openIndex === $index) {
<div class="mt-3 text-gray-600 leading-relaxed">
<p [innerHTML]="item.answer"></p>
</div>
}
</div>
}
</div>
</section>
`,
styles: [`
.rotate-180 {
transform: rotate(180deg);
}
`]
})
export class FaqComponent implements OnInit {
@Input() faqItems: FAQItem[] = [];
openIndex: number | null = null;
constructor(private seoService: SeoService) {}
ngOnInit() {
// Generate and inject FAQ Schema for rich snippets
if (this.faqItems.length > 0) {
const faqSchema = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
'mainEntity': this.faqItems.map(item => ({
'@type': 'Question',
'name': item.question,
'acceptedAnswer': {
'@type': 'Answer',
'text': this.stripHtml(item.answer)
}
}))
};
this.seoService.injectStructuredData(faqSchema);
}
}
toggle(index: number) {
this.openIndex = this.openIndex === index ? null : index;
}
private stripHtml(html: string): string {
const tmp = document.createElement('DIV');
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || '';
}
ngOnDestroy() {
this.seoService.clearStructuredData();
}
}

View File

@@ -1,33 +1,33 @@
<footer class="bg-white px-4 py-2 md:px-6 mt-auto w-full print:hidden">
<div class="container mx-auto flex flex-col lg:flex-row justify-between items-center">
<div class="flex flex-col lg:flex-row items-center mb-4 lg:mb-0">
<!-- <img src="/assets/images/header-logo.png" alt="BizMatch Logo" class="h-8 mb-2 lg:mb-0 lg:mr-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 mb-2 lg:mb-0 lg:mr-4" alt="BizMatch Logo" />
</a>
<p class="text-sm text-neutral-600 text-center lg:text-left">© {{ currentYear }} Bizmatch All rights reserved.</p>
</div>
<div class="flex flex-col lg:flex-row items-center order-3 lg:order-2">
<a class="text-sm text-primary-600 hover:underline hover:cursor-pointer mx-2" routerLink="/terms-of-use">Terms of
use</a>
<a class="text-sm text-primary-600 hover:underline hover:cursor-pointer mx-2"
routerLink="/privacy-statement">Privacy statement</a>
<!-- <a class="text-sm text-primary-600 hover:underline hover:cursor-pointer mx-2" routerLink="/pricingOverview">Pricing</a> -->
</div>
<div class="flex flex-col lg:flex-row items-center order-2 lg:order-3">
<div class="mb-4 lg:mb-0 lg:mr-6 text-center lg:text-right">
<p class="text-sm text-neutral-600 mb-1 lg:mb-2">BizMatch, Inc., 1001 Blucher Street, Corpus</p>
<p class="text-sm text-neutral-600">Christi, Texas 78401</p>
</div>
<div class="mb-4 lg:mb-0 flex flex-col items-center lg:items-end">
<a class="text-sm text-neutral-600 mb-1 lg:mb-2 hover:text-primary-600 w-full"> <i
class="fas fa-phone-alt mr-2"></i>1-800-840-6025 </a>
<a class="text-sm text-neutral-600 hover:text-primary-600"> <i
class="fas fa-envelope mr-2"></i>info&#64;bizmatch.net </a>
</div>
</div>
</div>
<footer class="bg-white px-4 py-2 md:px-6 mt-auto w-full print:hidden">
<div class="container mx-auto flex flex-col lg:flex-row justify-between items-center">
<div class="flex flex-col lg:flex-row items-center mb-4 lg:mb-0">
<!-- <img src="/assets/images/header-logo.png" alt="BizMatch Logo" class="h-8 mb-2 lg:mb-0 lg:mr-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 mb-2 lg:mb-0 lg:mr-4" alt="BizMatch Logo" />
</a>
<p class="text-sm text-neutral-600 text-center lg:text-left">© {{ currentYear }} Bizmatch All rights reserved.</p>
</div>
<div class="flex flex-col lg:flex-row items-center order-3 lg:order-2">
<a class="text-sm text-primary-600 hover:underline hover:cursor-pointer mx-2" routerLink="/terms-of-use">Terms of
use</a>
<a class="text-sm text-primary-600 hover:underline hover:cursor-pointer mx-2"
routerLink="/privacy-statement">Privacy statement</a>
<!-- <a class="text-sm text-primary-600 hover:underline hover:cursor-pointer mx-2" routerLink="/pricingOverview">Pricing</a> -->
</div>
<div class="flex flex-col lg:flex-row items-center order-2 lg:order-3">
<div class="mb-4 lg:mb-0 lg:mr-6 text-center lg:text-right">
<p class="text-sm text-neutral-600 mb-1 lg:mb-2">BizMatch, Inc., 1001 Blucher Street, Corpus</p>
<p class="text-sm text-neutral-600">Christi, Texas 78401</p>
</div>
<div class="mb-4 lg:mb-0 flex flex-col items-center lg:items-end">
<a class="text-sm text-neutral-600 mb-1 lg:mb-2 hover:text-primary-600 w-full"> <i
class="fas fa-phone-alt mr-2"></i>1-800-840-6025 </a>
<a class="text-sm text-neutral-600 hover:text-primary-600"> <i
class="fas fa-envelope mr-2"></i>info&#64;bizmatch.net </a>
</div>
</div>
</div>
</footer>

View File

@@ -1,22 +1,22 @@
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();
constructor(private router: Router) {}
ngOnInit() {
// Flowbite is now initialized once in AppComponent
}
}
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();
constructor(private router: Router) {}
ngOnInit() {
// Flowbite is now initialized once in AppComponent
}
}

View File

@@ -1,210 +1,210 @@
<nav class="bg-white border-neutral-200 dark:bg-neutral-900 print:hidden">
<div class="max-w-screen-xl 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-10 w-auto"
alt="BizMatch - Business Marketplace for Buying and Selling Businesses" />
</a>
<div class="flex items-center md:order-2 space-x-3 rtl:space-x-reverse">
<!-- Filter button -->
@if(isFilterUrl()){
<div class="relative">
<button type="button" id="sortDropdownButton"
class="max-sm:hidden px-4 py-2 text-sm font-medium bg-white border border-neutral-200 rounded-lg hover:bg-neutral-100 hover:text-primary-600 dark:bg-neutral-800 dark:text-neutral-400 dark:border-neutral-600 dark:hover:text-white dark:hover:bg-neutral-700"
(click)="toggleSortDropdown()"
[ngClass]="{ 'text-primary-500': selectOptions.getSortByOption(sortBy) !== 'Sort', 'text-neutral-900': selectOptions.getSortByOption(sortBy) === 'Sort' }">
<i class="fas fa-sort mr-2"></i>{{ selectOptions.getSortByOption(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-neutral-200 rounded-lg drop-shadow-custom-bg dark:bg-neutral-800 dark:border-neutral-600">
<ul class="py-1 text-sm text-neutral-700 dark:text-neutral-200">
@for(item of sortByOptions; track item){
<li (click)="sortByFct(item.value)"
class="block px-4 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-700 cursor-pointer">{{ item.selectName ?
item.selectName : item.name }}</li>
}
</ul>
</div>
</div>
}
<button type="button"
class="flex text-sm bg-neutral-400 rounded-full md:me-0 focus:ring-4 focus:ring-neutral-300 dark:focus:ring-neutral-600"
id="user-menu-button" aria-expanded="false" [attr.data-dropdown-toggle]="user ? 'user-login' : 'user-unknown'">
<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?.firstname }} {{ user?.lastname }} profile photo" width="32" height="32" />
} @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-neutral-100 rounded-lg shadow dark:bg-neutral-700 dark:divide-neutral-600"
id="user-login">
<div class="px-4 py-3">
<span class="block text-sm text-neutral-900 dark:text-white">Welcome, {{ user.firstname }} </span>
<span class="block text-sm text-neutral-500 truncate dark:text-neutral-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-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-600 dark:text-neutral-200 dark:hover:text-white">Account</a>
</li>
@if((user.customerType==='professional' && user.customerSubType==='broker') || user.customerType==='seller' ||
(authService.isAdmin() | async)){
<li>
@if(user.customerType==='professional'){
<a routerLink="/createBusinessListing" (click)="closeDropdown()"
class="block px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-600 dark:text-neutral-200 dark:hover:text-white">Create
Listing</a>
}@else {
<a routerLink="/createCommercialPropertyListing" (click)="closeDropdown()"
class="block px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-600 dark:text-neutral-200 dark:hover:text-white">Create
Listing</a>
}
</li>
<li>
<a routerLink="/myListings" (click)="closeDropdown()"
class="block px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-600 dark:text-neutral-200 dark:hover:text-white">My
Listings</a>
</li>
}
<li>
<a routerLink="/myFavorites" (click)="closeDropdown()"
class="block px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-600 dark:text-neutral-200 dark:hover:text-white">My
Favorites</a>
</li>
<li>
<a routerLink="/emailUs" (click)="closeDropdown()"
class="block px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-600 dark:text-neutral-200 dark:hover:text-white">EMail
Us</a>
</li>
<li>
<a routerLink="/logout" (click)="closeDropdown()"
class="block px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-600 dark:text-neutral-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-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-600 dark:text-neutral-200 dark:hover:text-white">Users
(Admin)</a>
</li>
</ul>
}
<ul class="py-2 md:hidden">
<li>
<a routerLink="/businessListings"
[ngClass]="{ 'text-primary-600': isActive('/businessListings'), 'text-neutral-700': !isActive('/businessListings') }"
class="block px-4 py-2 text-sm font-semibold"
(click)="closeMenusAndSetCriteria('businessListings')">Businesses</a>
</li>
@if ((numberOfCommercial$ | async) > 0) {
<li>
<a routerLink="/commercialPropertyListings"
[ngClass]="{ 'text-primary-600': isActive('/commercialPropertyListings'), 'text-neutral-700': !isActive('/commercialPropertyListings') }"
class="block px-4 py-2 text-sm font-semibold"
(click)="closeMenusAndSetCriteria('commercialPropertyListings')">Properties</a>
</li>
}
<li>
<a routerLink="/brokerListings"
[ngClass]="{ 'text-primary-600': isActive('/brokerListings'), 'text-neutral-700': !isActive('/brokerListings') }"
class="block px-4 py-2 text-sm font-semibold"
(click)="closeMenusAndSetCriteria('brokerListings')">Professionals</a>
</li>
</ul>
</div>
} @else {
<div
class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-neutral-100 rounded-lg shadow dark:bg-neutral-700 dark:divide-neutral-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-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-600 dark:text-neutral-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-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-600 dark:text-neutral-200 dark:hover:text-white">Sign
Up</a>
</li>
</ul>
<ul class="py-2 md:hidden">
<li>
<a routerLink="/businessListings"
[ngClass]="{ 'text-primary-600': isActive('/businessListings'), 'text-neutral-700': !isActive('/businessListings') }"
class="block px-4 py-2 text-sm font-bold"
(click)="closeMenusAndSetCriteria('businessListings')">Businesses</a>
</li>
@if ((numberOfCommercial$ | async) > 0) {
<li>
<a routerLink="/commercialPropertyListings"
[ngClass]="{ 'text-primary-600': isActive('/commercialPropertyListings'), 'text-neutral-700': !isActive('/commercialPropertyListings') }"
class="block px-4 py-2 text-sm font-bold"
(click)="closeMenusAndSetCriteria('commercialPropertyListings')">Properties</a>
</li>
}
<li>
<a routerLink="/brokerListings"
[ngClass]="{ 'text-primary-600': isActive('/brokerListings'), 'text-neutral-700': !isActive('/brokerListings') }"
class="block px-4 py-2 text-sm font-bold"
(click)="closeMenusAndSetCriteria('brokerListings')">Professionals</a>
</li>
</ul>
</div>
}
</div>
<div class="items-center justify-between hidden w-full md:flex md:w-auto md:order-1" id="navbar-user">
<ul
class="flex flex-col font-medium p-4 md:p-0 mt-4 border border-neutral-100 rounded-lg bg-neutral-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-neutral-800 md:dark:bg-neutral-900 dark:border-neutral-700">
<li>
<a routerLinkActive="active-link" routerLink="/businessListings"
[ngClass]="{ 'bg-primary-600 text-white md:text-primary-600 md:bg-transparent md:dark:text-primary-500': isActive('/businessListings') }"
class="block py-2 px-3 rounded hover:bg-neutral-100 md:hover:bg-transparent md:hover:text-primary-600 md:p-0 dark:text-white md:dark:hover:text-primary-500 dark:hover:bg-neutral-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-neutral-700 inline-flex items-center"
aria-current="page" (click)="closeMenusAndSetCriteria('businessListings')">
<img src="/assets/images/business_logo.png" alt="Business" class="w-5 h-5 mr-2 object-contain" width="20"
height="20" />
<span>Businesses</span>
</a>
</li>
@if ((numberOfCommercial$ | async) > 0) {
<li>
<a routerLinkActive="active-link" routerLink="/commercialPropertyListings"
[ngClass]="{ 'bg-primary-600 text-white md:text-primary-600 md:bg-transparent md:dark:text-primary-500': isActive('/commercialPropertyListings') }"
class="block py-2 px-3 rounded hover:bg-neutral-100 md:hover:bg-transparent md:hover:text-primary-600 md:p-0 dark:text-white md:dark:hover:text-primary-500 dark:hover:bg-neutral-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-neutral-700 inline-flex items-center"
(click)="closeMenusAndSetCriteria('commercialPropertyListings')">
<img src="/assets/images/properties_logo.png" alt="Properties" class="w-5 h-5 mr-2 object-contain"
width="20" height="20" />
<span>Properties</span>
</a>
</li>
}
<li>
<a routerLinkActive="active-link" routerLink="/brokerListings"
[ngClass]="{ 'bg-primary-600 text-white md:text-primary-600 md:bg-transparent md:dark:text-primary-500': isActive('/brokerListings') }"
class="inline-flex items-center py-2 px-3 rounded hover:bg-neutral-100 md:hover:bg-transparent md:hover:text-primary-600 md:p-0 dark:text-white md:dark:hover:text-primary-500 dark:hover:bg-neutral-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-neutral-700"
(click)="closeMenusAndSetCriteria('brokerListings')">
<img src="/assets/images/icon_professionals.png" alt="Professionals"
class="w-5 h-5 mr-2 object-contain bg-transparent" style="mix-blend-mode: darken;" />
<span>Professionals</span>
</a>
</li>
</ul>
</div>
</div>
<!-- Mobile filter button -->
<div class="md:hidden flex justify-center pb-4">
<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-neutral-200 rounded-lg hover:bg-neutral-100 hover:text-primary-600 focus:ring-2 focus:ring-primary-600 focus:text-primary-600 dark:bg-neutral-800 dark:text-neutral-400 dark:border-neutral-600 dark:hover:text-white dark:hover:bg-neutral-700"
[ngClass]="{ 'text-primary-500': selectOptions.getSortByOption(sortBy) !== 'Sort', 'text-neutral-900': selectOptions.getSortByOption(sortBy) === 'Sort' }">
<i class="fas fa-sort mr-2"></i>{{ selectOptions.getSortByOption(sortBy) }}
</button>
</div>
<nav class="bg-white border-neutral-200 dark:bg-neutral-900 print:hidden">
<div class="max-w-screen-xl 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-10 w-auto"
alt="BizMatch - Business Marketplace for Buying and Selling Businesses" />
</a>
<div class="flex items-center md:order-2 space-x-3 rtl:space-x-reverse">
<!-- Filter button -->
@if(isFilterUrl()){
<div class="relative">
<button type="button" id="sortDropdownButton"
class="max-sm:hidden px-4 py-2 text-sm font-medium bg-white border border-neutral-200 rounded-lg hover:bg-neutral-100 hover:text-primary-600 dark:bg-neutral-800 dark:text-neutral-400 dark:border-neutral-600 dark:hover:text-white dark:hover:bg-neutral-700"
(click)="toggleSortDropdown()"
[ngClass]="{ 'text-primary-500': selectOptions.getSortByOption(sortBy) !== 'Sort', 'text-neutral-900': selectOptions.getSortByOption(sortBy) === 'Sort' }">
<i class="fas fa-sort mr-2"></i>{{ selectOptions.getSortByOption(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-neutral-200 rounded-lg drop-shadow-custom-bg dark:bg-neutral-800 dark:border-neutral-600">
<ul class="py-1 text-sm text-neutral-700 dark:text-neutral-200">
@for(item of sortByOptions; track item){
<li (click)="sortByFct(item.value)"
class="block px-4 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-700 cursor-pointer">{{ item.selectName ?
item.selectName : item.name }}</li>
}
</ul>
</div>
</div>
}
<button type="button"
class="flex text-sm bg-neutral-400 rounded-full md:me-0 focus:ring-4 focus:ring-neutral-300 dark:focus:ring-neutral-600"
id="user-menu-button" aria-expanded="false" [attr.data-dropdown-toggle]="user ? 'user-login' : 'user-unknown'">
<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?.firstname }} {{ user?.lastname }} profile photo" width="32" height="32" />
} @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-neutral-100 rounded-lg shadow dark:bg-neutral-700 dark:divide-neutral-600"
id="user-login">
<div class="px-4 py-3">
<span class="block text-sm text-neutral-900 dark:text-white">Welcome, {{ user.firstname }} </span>
<span class="block text-sm text-neutral-500 truncate dark:text-neutral-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-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-600 dark:text-neutral-200 dark:hover:text-white">Account</a>
</li>
@if((user.customerType==='professional' && user.customerSubType==='broker') || user.customerType==='seller' ||
(authService.isAdmin() | async)){
<li>
@if(user.customerType==='professional'){
<a routerLink="/createBusinessListing" (click)="closeDropdown()"
class="block px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-600 dark:text-neutral-200 dark:hover:text-white">Create
Listing</a>
}@else {
<a routerLink="/createCommercialPropertyListing" (click)="closeDropdown()"
class="block px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-600 dark:text-neutral-200 dark:hover:text-white">Create
Listing</a>
}
</li>
<li>
<a routerLink="/myListings" (click)="closeDropdown()"
class="block px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-600 dark:text-neutral-200 dark:hover:text-white">My
Listings</a>
</li>
}
<li>
<a routerLink="/myFavorites" (click)="closeDropdown()"
class="block px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-600 dark:text-neutral-200 dark:hover:text-white">My
Favorites</a>
</li>
<li>
<a routerLink="/emailUs" (click)="closeDropdown()"
class="block px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-600 dark:text-neutral-200 dark:hover:text-white">EMail
Us</a>
</li>
<li>
<a routerLink="/logout" (click)="closeDropdown()"
class="block px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-600 dark:text-neutral-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-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-600 dark:text-neutral-200 dark:hover:text-white">Users
(Admin)</a>
</li>
</ul>
}
<ul class="py-2 md:hidden">
<li>
<a routerLink="/businessListings"
[ngClass]="{ 'text-primary-600': isActive('/businessListings'), 'text-neutral-700': !isActive('/businessListings') }"
class="block px-4 py-2 text-sm font-semibold"
(click)="closeMenusAndSetCriteria('businessListings')">Businesses</a>
</li>
@if ((numberOfCommercial$ | async) > 0) {
<li>
<a routerLink="/commercialPropertyListings"
[ngClass]="{ 'text-primary-600': isActive('/commercialPropertyListings'), 'text-neutral-700': !isActive('/commercialPropertyListings') }"
class="block px-4 py-2 text-sm font-semibold"
(click)="closeMenusAndSetCriteria('commercialPropertyListings')">Properties</a>
</li>
}
<li>
<a routerLink="/brokerListings"
[ngClass]="{ 'text-primary-600': isActive('/brokerListings'), 'text-neutral-700': !isActive('/brokerListings') }"
class="block px-4 py-2 text-sm font-semibold"
(click)="closeMenusAndSetCriteria('brokerListings')">Professionals</a>
</li>
</ul>
</div>
} @else {
<div
class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-neutral-100 rounded-lg shadow dark:bg-neutral-700 dark:divide-neutral-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-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-600 dark:text-neutral-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-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-600 dark:text-neutral-200 dark:hover:text-white">Sign
Up</a>
</li>
</ul>
<ul class="py-2 md:hidden">
<li>
<a routerLink="/businessListings"
[ngClass]="{ 'text-primary-600': isActive('/businessListings'), 'text-neutral-700': !isActive('/businessListings') }"
class="block px-4 py-2 text-sm font-bold"
(click)="closeMenusAndSetCriteria('businessListings')">Businesses</a>
</li>
@if ((numberOfCommercial$ | async) > 0) {
<li>
<a routerLink="/commercialPropertyListings"
[ngClass]="{ 'text-primary-600': isActive('/commercialPropertyListings'), 'text-neutral-700': !isActive('/commercialPropertyListings') }"
class="block px-4 py-2 text-sm font-bold"
(click)="closeMenusAndSetCriteria('commercialPropertyListings')">Properties</a>
</li>
}
<li>
<a routerLink="/brokerListings"
[ngClass]="{ 'text-primary-600': isActive('/brokerListings'), 'text-neutral-700': !isActive('/brokerListings') }"
class="block px-4 py-2 text-sm font-bold"
(click)="closeMenusAndSetCriteria('brokerListings')">Professionals</a>
</li>
</ul>
</div>
}
</div>
<div class="items-center justify-between hidden w-full md:flex md:w-auto md:order-1" id="navbar-user">
<ul
class="flex flex-col font-medium p-4 md:p-0 mt-4 border border-neutral-100 rounded-lg bg-neutral-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-neutral-800 md:dark:bg-neutral-900 dark:border-neutral-700">
<li>
<a routerLinkActive="active-link" routerLink="/businessListings"
[ngClass]="{ 'bg-primary-600 text-white md:text-primary-600 md:bg-transparent md:dark:text-primary-500': isActive('/businessListings') }"
class="block py-2 px-3 rounded hover:bg-neutral-100 md:hover:bg-transparent md:hover:text-primary-600 md:p-0 dark:text-white md:dark:hover:text-primary-500 dark:hover:bg-neutral-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-neutral-700 inline-flex items-center"
aria-current="page" (click)="closeMenusAndSetCriteria('businessListings')">
<img src="/assets/images/business_logo.png" alt="Business" class="w-5 h-5 mr-2 object-contain" width="20"
height="20" />
<span>Businesses</span>
</a>
</li>
@if ((numberOfCommercial$ | async) > 0) {
<li>
<a routerLinkActive="active-link" routerLink="/commercialPropertyListings"
[ngClass]="{ 'bg-primary-600 text-white md:text-primary-600 md:bg-transparent md:dark:text-primary-500': isActive('/commercialPropertyListings') }"
class="block py-2 px-3 rounded hover:bg-neutral-100 md:hover:bg-transparent md:hover:text-primary-600 md:p-0 dark:text-white md:dark:hover:text-primary-500 dark:hover:bg-neutral-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-neutral-700 inline-flex items-center"
(click)="closeMenusAndSetCriteria('commercialPropertyListings')">
<img src="/assets/images/properties_logo.png" alt="Properties" class="w-5 h-5 mr-2 object-contain"
width="20" height="20" />
<span>Properties</span>
</a>
</li>
}
<li>
<a routerLinkActive="active-link" routerLink="/brokerListings"
[ngClass]="{ 'bg-primary-600 text-white md:text-primary-600 md:bg-transparent md:dark:text-primary-500': isActive('/brokerListings') }"
class="inline-flex items-center py-2 px-3 rounded hover:bg-neutral-100 md:hover:bg-transparent md:hover:text-primary-600 md:p-0 dark:text-white md:dark:hover:text-primary-500 dark:hover:bg-neutral-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-neutral-700"
(click)="closeMenusAndSetCriteria('brokerListings')">
<img src="/assets/images/icon_professionals.png" alt="Professionals"
class="w-5 h-5 mr-2 object-contain bg-transparent" style="mix-blend-mode: darken;" />
<span>Professionals</span>
</a>
</li>
</ul>
</div>
</div>
<!-- Mobile filter button -->
<div class="md:hidden flex justify-center pb-4">
<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-neutral-200 rounded-lg hover:bg-neutral-100 hover:text-primary-600 focus:ring-2 focus:ring-primary-600 focus:text-primary-600 dark:bg-neutral-800 dark:text-neutral-400 dark:border-neutral-600 dark:hover:text-white dark:hover:bg-neutral-700"
[ngClass]="{ 'text-primary-500': selectOptions.getSortByOption(sortBy) !== 'Sort', 'text-neutral-900': selectOptions.getSortByOption(sortBy) === 'Sort' }">
<i class="fas fa-sort mr-2"></i>{{ selectOptions.getSortByOption(sortBy) }}
</button>
</div>
</nav>

View File

@@ -1,322 +1,322 @@
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { Component, HostListener, OnDestroy, OnInit, AfterViewInit, PLATFORM_ID, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NavigationEnd, Router, RouterModule } from '@angular/router';
import { faUserGear } from '@fortawesome/free-solid-svg-icons';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { Collapse, Dropdown, initFlowbite } from 'flowbite';
import { filter, Observable, Subject, takeUntil } from 'rxjs';
import { SortByOptions, User } from '../../../../../bizmatch-server/src/models/db.model';
import { emailToDirName, KeycloakUser, KeyValueAsSortBy, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../environments/environment';
import { AuthService } from '../../services/auth.service';
import { FilterStateService } from '../../services/filter-state.service';
import { ListingsService } from '../../services/listings.service';
import { SearchService } from '../../services/search.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { SharedService } from '../../services/shared.service';
import { UserService } from '../../services/user.service';
import { map2User } from '../../utils/utils';
import { ModalService } from '../search-modal/modal.service';
@UntilDestroy()
@Component({
selector: 'header',
standalone: true,
imports: [CommonModule, RouterModule, FormsModule],
templateUrl: './header.component.html',
styleUrl: './header.component.scss',
})
export class HeaderComponent implements OnInit, OnDestroy, AfterViewInit {
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 platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
// Aktueller Listing-Typ basierend auf Route
currentListingType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings' | null = null;
// Sortierung
sortDropdownVisible: boolean = false;
sortByOptions: KeyValueAsSortBy[] = [];
sortBy: SortByOptions = null;
// Observable für Anzahl der Listings
numberOfBroker$: Observable<number>;
numberOfCommercial$: Observable<number>;
constructor(
private router: Router,
private userService: UserService,
private sharedService: SharedService,
private modalService: ModalService,
private searchService: SearchService,
private filterStateService: FilterStateService,
public selectOptions: SelectOptionsService,
public authService: AuthService,
private listingService: ListingsService,
) { }
@HostListener('document:click', ['$event'])
handleGlobalClick(event: Event) {
const target = event.target as HTMLElement;
// Don't close sort dropdown when clicking on sort buttons or user menu button
const excludedIds = ['sortDropdownButton', 'sortDropdownMobileButton', 'user-menu-button'];
if (!excludedIds.includes(target.id) && !target.closest('#user-menu-button')) {
this.sortDropdownVisible = false;
// Close User Menu if clicked outside
// We check if the click was inside the menu containers
const userLogin = document.getElementById('user-login');
const userUnknown = document.getElementById('user-unknown');
const clickedInsideMenu = (userLogin && userLogin.contains(target)) || (userUnknown && userUnknown.contains(target));
if (!clickedInsideMenu) {
this.closeDropdown();
}
}
}
async ngOnInit() {
// User Setup
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`;
}
// Lade Anzahl der Listings
this.numberOfBroker$ = this.userService.getNumberOfBroker(this.createEmptyUserListingCriteria());
this.numberOfCommercial$ = this.listingService.getNumberOfListings('commercialProperty');
// Flowbite is now initialized once in AppComponent
// Profile Photo Updates
this.sharedService.currentProfilePhoto.pipe(untilDestroyed(this)).subscribe(photoUrl => {
this.profileUrl = photoUrl;
});
// User Updates - re-initialize Flowbite when user state changes
// This ensures the dropdown bindings are updated when the dropdown target changes
this.userService.currentUser.pipe(untilDestroyed(this)).subscribe(u => {
const previousUser = this.user;
this.user = u;
// Re-initialize Flowbite if user logged in/out state changed
if ((previousUser === null) !== (u === null) && this.isBrowser) {
setTimeout(() => initFlowbite(), 50);
}
});
// Router Events
this.router.events
.pipe(
filter(event => event instanceof NavigationEnd),
untilDestroyed(this),
)
.subscribe((event: NavigationEnd) => {
this.checkCurrentRoute(event.urlAfterRedirects);
});
// Initial Route Check
this.checkCurrentRoute(this.router.url);
}
private checkCurrentRoute(url: string): void {
const baseRoute = url.split('/')[1];
// Bestimme den aktuellen Listing-Typ
if (baseRoute === 'businessListings') {
this.currentListingType = 'businessListings';
} else if (baseRoute === 'commercialPropertyListings') {
this.currentListingType = 'commercialPropertyListings';
} else if (baseRoute === 'brokerListings') {
this.currentListingType = 'brokerListings';
} else {
this.currentListingType = null;
return; // Keine relevante Route für Filter/Sort
}
// Setup für diese Route
this.setupSortByOptions();
this.subscribeToStateChanges();
}
private subscribeToStateChanges(): void {
if (!this.currentListingType) return;
// Abonniere State-Änderungen für den aktuellen Listing-Typ
this.filterStateService
.getState$(this.currentListingType)
.pipe(takeUntil(this.destroy$))
.subscribe(state => {
this.sortBy = state.sortBy;
});
}
private setupSortByOptions(): void {
this.sortByOptions = [];
if (!this.currentListingType) return;
switch (this.currentListingType) {
case 'brokerListings':
this.sortByOptions = [...this.selectOptions.sortByOptions.filter(s => s.type === 'professional')];
break;
case 'businessListings':
this.sortByOptions = [...this.selectOptions.sortByOptions.filter(s => s.type === 'business' || s.type === 'listing')];
break;
case 'commercialPropertyListings':
this.sortByOptions = [...this.selectOptions.sortByOptions.filter(s => s.type === 'commercial' || s.type === 'listing')];
break;
}
// Füge generische Optionen hinzu (ohne type)
this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => !s.type)];
}
sortByFct(selectedSortBy: SortByOptions): void {
if (!this.currentListingType) return;
this.sortDropdownVisible = false;
// Update sortBy im State
this.filterStateService.updateSortBy(this.currentListingType, selectedSortBy);
// Trigger search
this.searchService.search(this.currentListingType);
}
async openModal() {
if (!this.currentListingType) return;
const criteria = this.filterStateService.getCriteria(this.currentListingType);
const modalResult = await this.modalService.showModal(criteria);
if (modalResult.accepted) {
this.searchService.search(this.currentListingType);
}
}
navigateWithState(dest: string, state: any) {
this.router.navigate([dest], { state: state });
}
isActive(route: string): boolean {
return this.router.url === route;
}
isFilterUrl(): boolean {
return ['/businessListings', '/commercialPropertyListings', '/brokerListings'].includes(this.router.url);
}
isBusinessListing(): boolean {
return this.router.url === '/businessListings';
}
isCommercialPropertyListing(): boolean {
return this.router.url === '/commercialPropertyListings';
}
isProfessionalListing(): boolean {
return this.router.url === '/brokerListings';
}
closeDropdown() {
if (!this.isBrowser) return;
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() {
if (!this.isBrowser) return;
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();
// Bestimme Listing-Typ aus dem Pfad
let listingType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings' | null = null;
if (path === 'businessListings') {
listingType = 'businessListings';
} else if (path === 'commercialPropertyListings') {
listingType = 'commercialPropertyListings';
} else if (path === 'brokerListings') {
listingType = 'brokerListings';
}
if (listingType) {
// Reset Pagination beim Wechsel zwischen Views
this.filterStateService.updateCriteria(listingType, {
page: 1,
start: 0,
});
}
}
toggleSortDropdown() {
this.sortDropdownVisible = !this.sortDropdownVisible;
}
get isProfessional() {
return this.user?.customerType === 'professional';
}
// Helper method für leere UserListingCriteria
private createEmptyUserListingCriteria(): UserListingCriteria {
return {
criteriaType: 'brokerListings',
types: [],
state: null,
city: null,
radius: null,
searchType: 'exact' as const,
brokerName: null,
companyName: null,
counties: [],
prompt: null,
page: 1,
start: 0,
length: 12,
};
}
ngAfterViewInit(): void {
// Flowbite initialization is now handled manually or via AppComponent
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { Component, HostListener, OnDestroy, OnInit, AfterViewInit, PLATFORM_ID, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NavigationEnd, Router, RouterModule } from '@angular/router';
import { faUserGear } from '@fortawesome/free-solid-svg-icons';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { Collapse, Dropdown, initFlowbite } from 'flowbite';
import { filter, Observable, Subject, takeUntil } from 'rxjs';
import { SortByOptions, User } from '../../../../../bizmatch-server/src/models/db.model';
import { emailToDirName, KeycloakUser, KeyValueAsSortBy, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../environments/environment';
import { AuthService } from '../../services/auth.service';
import { FilterStateService } from '../../services/filter-state.service';
import { ListingsService } from '../../services/listings.service';
import { SearchService } from '../../services/search.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { SharedService } from '../../services/shared.service';
import { UserService } from '../../services/user.service';
import { map2User } from '../../utils/utils';
import { ModalService } from '../search-modal/modal.service';
@UntilDestroy()
@Component({
selector: 'header',
standalone: true,
imports: [CommonModule, RouterModule, FormsModule],
templateUrl: './header.component.html',
styleUrl: './header.component.scss',
})
export class HeaderComponent implements OnInit, OnDestroy, AfterViewInit {
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 platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
// Aktueller Listing-Typ basierend auf Route
currentListingType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings' | null = null;
// Sortierung
sortDropdownVisible: boolean = false;
sortByOptions: KeyValueAsSortBy[] = [];
sortBy: SortByOptions = null;
// Observable für Anzahl der Listings
numberOfBroker$: Observable<number>;
numberOfCommercial$: Observable<number>;
constructor(
private router: Router,
private userService: UserService,
private sharedService: SharedService,
private modalService: ModalService,
private searchService: SearchService,
private filterStateService: FilterStateService,
public selectOptions: SelectOptionsService,
public authService: AuthService,
private listingService: ListingsService,
) { }
@HostListener('document:click', ['$event'])
handleGlobalClick(event: Event) {
const target = event.target as HTMLElement;
// Don't close sort dropdown when clicking on sort buttons or user menu button
const excludedIds = ['sortDropdownButton', 'sortDropdownMobileButton', 'user-menu-button'];
if (!excludedIds.includes(target.id) && !target.closest('#user-menu-button')) {
this.sortDropdownVisible = false;
// Close User Menu if clicked outside
// We check if the click was inside the menu containers
const userLogin = document.getElementById('user-login');
const userUnknown = document.getElementById('user-unknown');
const clickedInsideMenu = (userLogin && userLogin.contains(target)) || (userUnknown && userUnknown.contains(target));
if (!clickedInsideMenu) {
this.closeDropdown();
}
}
}
async ngOnInit() {
// User Setup
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`;
}
// Lade Anzahl der Listings
this.numberOfBroker$ = this.userService.getNumberOfBroker(this.createEmptyUserListingCriteria());
this.numberOfCommercial$ = this.listingService.getNumberOfListings('commercialProperty');
// Flowbite is now initialized once in AppComponent
// Profile Photo Updates
this.sharedService.currentProfilePhoto.pipe(untilDestroyed(this)).subscribe(photoUrl => {
this.profileUrl = photoUrl;
});
// User Updates - re-initialize Flowbite when user state changes
// This ensures the dropdown bindings are updated when the dropdown target changes
this.userService.currentUser.pipe(untilDestroyed(this)).subscribe(u => {
const previousUser = this.user;
this.user = u;
// Re-initialize Flowbite if user logged in/out state changed
if ((previousUser === null) !== (u === null) && this.isBrowser) {
setTimeout(() => initFlowbite(), 50);
}
});
// Router Events
this.router.events
.pipe(
filter(event => event instanceof NavigationEnd),
untilDestroyed(this),
)
.subscribe((event: NavigationEnd) => {
this.checkCurrentRoute(event.urlAfterRedirects);
});
// Initial Route Check
this.checkCurrentRoute(this.router.url);
}
private checkCurrentRoute(url: string): void {
const baseRoute = url.split('/')[1];
// Bestimme den aktuellen Listing-Typ
if (baseRoute === 'businessListings') {
this.currentListingType = 'businessListings';
} else if (baseRoute === 'commercialPropertyListings') {
this.currentListingType = 'commercialPropertyListings';
} else if (baseRoute === 'brokerListings') {
this.currentListingType = 'brokerListings';
} else {
this.currentListingType = null;
return; // Keine relevante Route für Filter/Sort
}
// Setup für diese Route
this.setupSortByOptions();
this.subscribeToStateChanges();
}
private subscribeToStateChanges(): void {
if (!this.currentListingType) return;
// Abonniere State-Änderungen für den aktuellen Listing-Typ
this.filterStateService
.getState$(this.currentListingType)
.pipe(takeUntil(this.destroy$))
.subscribe(state => {
this.sortBy = state.sortBy;
});
}
private setupSortByOptions(): void {
this.sortByOptions = [];
if (!this.currentListingType) return;
switch (this.currentListingType) {
case 'brokerListings':
this.sortByOptions = [...this.selectOptions.sortByOptions.filter(s => s.type === 'professional')];
break;
case 'businessListings':
this.sortByOptions = [...this.selectOptions.sortByOptions.filter(s => s.type === 'business' || s.type === 'listing')];
break;
case 'commercialPropertyListings':
this.sortByOptions = [...this.selectOptions.sortByOptions.filter(s => s.type === 'commercial' || s.type === 'listing')];
break;
}
// Füge generische Optionen hinzu (ohne type)
this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => !s.type)];
}
sortByFct(selectedSortBy: SortByOptions): void {
if (!this.currentListingType) return;
this.sortDropdownVisible = false;
// Update sortBy im State
this.filterStateService.updateSortBy(this.currentListingType, selectedSortBy);
// Trigger search
this.searchService.search(this.currentListingType);
}
async openModal() {
if (!this.currentListingType) return;
const criteria = this.filterStateService.getCriteria(this.currentListingType);
const modalResult = await this.modalService.showModal(criteria);
if (modalResult.accepted) {
this.searchService.search(this.currentListingType);
}
}
navigateWithState(dest: string, state: any) {
this.router.navigate([dest], { state: state });
}
isActive(route: string): boolean {
return this.router.url === route;
}
isFilterUrl(): boolean {
return ['/businessListings', '/commercialPropertyListings', '/brokerListings'].includes(this.router.url);
}
isBusinessListing(): boolean {
return this.router.url === '/businessListings';
}
isCommercialPropertyListing(): boolean {
return this.router.url === '/commercialPropertyListings';
}
isProfessionalListing(): boolean {
return this.router.url === '/brokerListings';
}
closeDropdown() {
if (!this.isBrowser) return;
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() {
if (!this.isBrowser) return;
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();
// Bestimme Listing-Typ aus dem Pfad
let listingType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings' | null = null;
if (path === 'businessListings') {
listingType = 'businessListings';
} else if (path === 'commercialPropertyListings') {
listingType = 'commercialPropertyListings';
} else if (path === 'brokerListings') {
listingType = 'brokerListings';
}
if (listingType) {
// Reset Pagination beim Wechsel zwischen Views
this.filterStateService.updateCriteria(listingType, {
page: 1,
start: 0,
});
}
}
toggleSortDropdown() {
this.sortDropdownVisible = !this.sortDropdownVisible;
}
get isProfessional() {
return this.user?.customerType === 'professional';
}
// Helper method für leere UserListingCriteria
private createEmptyUserListingCriteria(): UserListingCriteria {
return {
criteriaType: 'brokerListings',
types: [],
state: null,
city: null,
radius: null,
searchType: 'exact' as const,
brokerName: null,
companyName: null,
counties: [],
prompt: null,
page: 1,
start: 0,
length: 12,
};
}
ngAfterViewInit(): void {
// Flowbite initialization is now handled manually or via AppComponent
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -1,106 +1,106 @@
<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">
<!-- Home Button -->
<div class="flex justify-end mb-4">
<a [routerLink]="['/home']" class="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors cursor-pointer">
<i class="fas fa-home mr-2"></i>
Home
</a>
</div>
<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 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<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 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<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 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<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' : 'Sign Up' }}
</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>
<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">
<!-- Home Button -->
<div class="flex justify-end mb-4">
<a [routerLink]="['/home']" class="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors cursor-pointer">
<i class="fas fa-home mr-2"></i>
Home
</a>
</div>
<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 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<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 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<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 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<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' : 'Sign Up' }}
</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

@@ -1,95 +1,95 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } 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, RouterModule],
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
.loginWithEmail(this.email, this.password)
.then(userCredential => {
console.log('Successfully logged in:', userCredential);
this.router.navigate([`myListing`]);
})
.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
.loginWithGoogle()
.then(userCredential => {
console.log('Successfully logged in with Google:', userCredential);
this.router.navigate([`myListing`]);
})
.catch(error => {
console.error('Error during Google login:', error);
this.errorMessage = error.message;
});
}
}
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } 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, RouterModule],
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
.loginWithEmail(this.email, this.password)
.then(userCredential => {
console.log('Successfully logged in:', userCredential);
this.router.navigate([`myListing`]);
})
.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
.loginWithGoogle()
.then(userCredential => {
console.log('Successfully logged in with Google:', userCredential);
this.router.navigate([`myListing`]);
})
.catch(error => {
console.error('Error during Google login:', error);
this.errorMessage = error.message;
});
}
}

View File

@@ -1,40 +1,40 @@
<!-- <section class="bg-white dark:bg-gray-900">
<div class="py-8 px-4 mx-auto max-w-screen-xl lg:py-16 lg:px-6">
<div class="mx-auto max-w-screen-sm text-center">
<h1 class="mb-4 text-7xl tracking-tight font-extrabold lg:text-9xl text-primary-600 dark:text-primary-500">404</h1>
<p class="mb-4 text-3xl tracking-tight font-bold text-gray-900 md:text-4xl dark:text-white">Something's missing.</p>
<p class="mb-4 text-lg font-light text-gray-500 dark:text-gray-400">Sorry, we can't find that page.</p>
<a
routerLink="/home"
class="inline-flex text-white bg-primary-600 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:focus:ring-primary-900 my-4"
>Back to Homepage</a
>
</div>
</div>
</section> -->
<section class="bg-white dark:bg-gray-900">
<div class="py-8 px-4 mx-auto max-w-screen-xl lg:py-16 lg:px-6">
<!-- Breadcrumbs -->
<div class="mb-4">
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
</div>
<div class="mx-auto max-w-screen-sm text-center">
<h1 class="mb-4 text-7xl tracking-tight font-extrabold lg:text-9xl text-blue-700 dark:text-blue-500">404</h1>
<p class="mb-4 text-3xl tracking-tight font-bold text-gray-900 md:text-4xl dark:text-white">Something's missing.</p>
<p class="mb-4 text-lg font-light text-gray-500 dark:text-gray-400">Sorry, we can't find that page</p>
<!-- <a
href="#"
class="inline-flex text-white bg-primary-600 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:focus:ring-primary-900 my-4"
>Back to Homepage</a
> -->
<button
type="button"
[routerLink]="['/home']"
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>
Back to Homepage
</button>
</div>
</div>
</section>
<!-- <section class="bg-white dark:bg-gray-900">
<div class="py-8 px-4 mx-auto max-w-screen-xl lg:py-16 lg:px-6">
<div class="mx-auto max-w-screen-sm text-center">
<h1 class="mb-4 text-7xl tracking-tight font-extrabold lg:text-9xl text-primary-600 dark:text-primary-500">404</h1>
<p class="mb-4 text-3xl tracking-tight font-bold text-gray-900 md:text-4xl dark:text-white">Something's missing.</p>
<p class="mb-4 text-lg font-light text-gray-500 dark:text-gray-400">Sorry, we can't find that page.</p>
<a
routerLink="/home"
class="inline-flex text-white bg-primary-600 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:focus:ring-primary-900 my-4"
>Back to Homepage</a
>
</div>
</div>
</section> -->
<section class="bg-white dark:bg-gray-900">
<div class="py-8 px-4 mx-auto max-w-screen-xl lg:py-16 lg:px-6">
<!-- Breadcrumbs -->
<div class="mb-4">
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
</div>
<div class="mx-auto max-w-screen-sm text-center">
<h1 class="mb-4 text-7xl tracking-tight font-extrabold lg:text-9xl text-blue-700 dark:text-blue-500">404</h1>
<p class="mb-4 text-3xl tracking-tight font-bold text-gray-900 md:text-4xl dark:text-white">Something's missing.</p>
<p class="mb-4 text-lg font-light text-gray-500 dark:text-gray-400">Sorry, we can't find that page</p>
<!-- <a
href="#"
class="inline-flex text-white bg-primary-600 hover:bg-primary-800 focus:ring-4 focus:outline-none focus:ring-primary-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:focus:ring-primary-900 my-4"
>Back to Homepage</a
> -->
<button
type="button"
[routerLink]="['/home']"
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>
Back to Homepage
</button>
</div>
</div>
</section>

View File

@@ -1,32 +1,32 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { RouterModule } from '@angular/router';
import { SeoService } from '../../services/seo.service';
import { BreadcrumbItem, BreadcrumbsComponent } from '../breadcrumbs/breadcrumbs.component';
@Component({
selector: 'app-not-found',
standalone: true,
imports: [CommonModule, RouterModule, BreadcrumbsComponent],
templateUrl: './not-found.component.html',
})
export class NotFoundComponent implements OnInit {
breadcrumbs: BreadcrumbItem[] = [
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
{ label: '404 - Page Not Found' }
];
constructor(private seoService: SeoService) {}
ngOnInit(): void {
// Set noindex to prevent 404 pages from being indexed
this.seoService.setNoIndex();
// Set appropriate meta tags for 404 page
this.seoService.updateMetaTags({
title: '404 - Page Not Found | BizMatch',
description: 'The page you are looking for could not be found. Return to BizMatch to browse businesses for sale or commercial properties.',
type: 'website'
});
}
}
import { CommonModule } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { RouterModule } from '@angular/router';
import { SeoService } from '../../services/seo.service';
import { BreadcrumbItem, BreadcrumbsComponent } from '../breadcrumbs/breadcrumbs.component';
@Component({
selector: 'app-not-found',
standalone: true,
imports: [CommonModule, RouterModule, BreadcrumbsComponent],
templateUrl: './not-found.component.html',
})
export class NotFoundComponent implements OnInit {
breadcrumbs: BreadcrumbItem[] = [
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
{ label: '404 - Page Not Found' }
];
constructor(private seoService: SeoService) {}
ngOnInit(): void {
// Set noindex to prevent 404 pages from being indexed
this.seoService.setNoIndex();
// Set appropriate meta tags for 404 page
this.seoService.updateMetaTags({
title: '404 - Page Not Found | BizMatch',
description: 'The page you are looking for could not be found. Return to BizMatch to browse businesses for sale or commercial properties.',
type: 'website'
});
}
}

View File

@@ -1,260 +1,260 @@
<div
*ngIf="isModal && (modalService.modalVisible$ | async)?.visible && (modalService.modalVisible$ | async)?.type === 'brokerListings'"
class="fixed inset-0 bg-neutral-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50"
>
<div class="relative w-full h-screen max-h-screen">
<div class="relative bg-white rounded-lg shadow h-full">
<div class="flex items-start justify-between p-4 border-b rounded-t bg-primary-600">
<h3 class="text-xl font-semibold text-white p-2 rounded">Professional Search</h3>
<button (click)="closeAndSearch()" type="button" class="text-white bg-transparent hover:bg-gray-200 hover:text-neutral-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-primary-600 font-medium border-b-2 border-primary-600 pb-2">Filter ({{ numberOfResults$ | async }})</button>
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-primary-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-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip">
Clear all Filter
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
<!-- Display active filters as tags -->
<div class="flex flex-wrap gap-2 mb-4" *ngIf="hasActiveFilters()">
<span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.types?.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Types: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Professional Name: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.companyName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Company: {{ criteria.companyName }} <button (click)="removeFilter('companyName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.counties?.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Areas Served: {{ criteria.counties.join(', ') }} <button (click)="removeFilter('counties')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
</div>
@if(criteria.criteriaType==='brokerListings') {
<div class="grid grid-cols-1 gap-6">
<div class="space-y-4">
<div>
<label for="state" class="block mb-2 text-sm font-medium text-neutral-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-neutral-900 font-medium" [state]="criteria.state"></app-validated-city>
</div>
<div *ngIf="criteria.city">
<label class="block mb-2 text-sm font-medium text-neutral-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" (ngModelChange)="updateCriteria({ searchType: $event })" 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" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
<span class="ml-2">Radius Search</span>
</label>
</div>
</div>
<div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2">
<label class="block mb-2 text-sm font-medium text-neutral-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-neutral-200 hover:bg-gray-500 hover:text-white"
[ngClass]="criteria.radius === radius ? 'text-white bg-gray-500' : 'text-neutral-900 bg-white'"
(click)="setRadius(radius)"
>
{{ radius }}
</button>
}
</div>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-neutral-900">Professional Type</label>
<ng-select
class="custom"
[items]="selectOptions.customerSubTypes"
bindLabel="name"
bindValue="value"
[ngModel]="criteria.types"
(ngModelChange)="onCategoryChange($event)"
[multiple]="true"
[closeOnSelect]="true"
placeholder="Select professional types"
></ng-select>
</div>
<div>
<label for="brokerName" class="block mb-2 text-sm font-medium text-neutral-900">Professional Name</label>
<input
type="text"
id="brokerName"
[ngModel]="criteria.brokerName"
(ngModelChange)="updateCriteria({ brokerName: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. John Smith"
/>
</div>
<div>
<label for="companyName" class="block mb-2 text-sm font-medium text-neutral-900">Company Name</label>
<input
type="text"
id="companyName"
[ngModel]="criteria.companyName"
(ngModelChange)="updateCriteria({ companyName: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. ABC Brokers"
/>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-neutral-900">Counties / Areas Served</label>
<ng-select
class="custom"
[items]="counties$ | async"
[multiple]="true"
[loading]="countyLoading"
[typeahead]="countyInput$"
[ngModel]="criteria.counties"
(ngModelChange)="onCountiesChange($event)"
[closeOnSelect]="true"
placeholder="Type to search counties"
></ng-select>
</div>
</div>
</div>
}
</div>
</div>
</div>
</div>
<div *ngIf="!isModal" class="space-y-6 pb-10">
<div class="flex space-x-4 mb-4">
<h3 class="text-xl font-semibold text-neutral-900">Filter ({{ numberOfResults$ | async }})</h3>
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-primary-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-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip">
Clear all Filter
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
<!-- Display active filters as tags -->
<div class="flex flex-wrap gap-2" *ngIf="hasActiveFilters()">
<span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.types?.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Types: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Professional Name: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.companyName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Company: {{ criteria.companyName }} <button (click)="removeFilter('companyName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.counties?.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Areas Served: {{ criteria.counties.join(', ') }} <button (click)="removeFilter('counties')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
</div>
@if(criteria.criteriaType==='brokerListings') {
<div class="space-y-4">
<div>
<label for="state" class="block mb-2 text-sm font-medium text-neutral-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-neutral-900 font-medium" [state]="criteria.state"></app-validated-city>
</div>
<div *ngIf="criteria.city">
<label class="block mb-2 text-sm font-medium text-neutral-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" (ngModelChange)="updateCriteria({ searchType: $event })" 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" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
<span class="ml-2">Radius Search</span>
</label>
</div>
</div>
<div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2">
<label class="block mb-2 text-sm font-medium text-neutral-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-neutral-200 hover:bg-gray-500 hover:text-white"
[ngClass]="criteria.radius === radius ? 'text-white bg-gray-500' : 'text-neutral-900 bg-white'"
(click)="setRadius(radius)"
>
{{ radius }}
</button>
}
</div>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-neutral-900">Professional Type</label>
<ng-select
class="custom"
[items]="selectOptions.customerSubTypes"
bindLabel="name"
bindValue="value"
[ngModel]="criteria.types"
(ngModelChange)="onCategoryChange($event)"
[multiple]="true"
[closeOnSelect]="true"
placeholder="Select professional types"
></ng-select>
</div>
<div>
<label for="brokerName" class="block mb-2 text-sm font-medium text-neutral-900">Professional Name</label>
<input
type="text"
id="brokerName"
[ngModel]="criteria.brokerName"
(ngModelChange)="updateCriteria({ brokerName: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. John Smith"
/>
</div>
<div>
<label for="companyName" class="block mb-2 text-sm font-medium text-neutral-900">Company Name</label>
<input
type="text"
id="companyName"
[ngModel]="criteria.companyName"
(ngModelChange)="updateCriteria({ companyName: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. ABC Brokers"
/>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-neutral-900">Counties / Areas Served</label>
<ng-select
class="custom"
[items]="counties$ | async"
[multiple]="true"
[loading]="countyLoading"
[typeahead]="countyInput$"
[ngModel]="criteria.counties"
(ngModelChange)="onCountiesChange($event)"
[closeOnSelect]="true"
placeholder="Type to search counties"
></ng-select>
</div>
</div>
}
</div>
<div
*ngIf="isModal && (modalService.modalVisible$ | async)?.visible && (modalService.modalVisible$ | async)?.type === 'brokerListings'"
class="fixed inset-0 bg-neutral-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50"
>
<div class="relative w-full h-screen max-h-screen">
<div class="relative bg-white rounded-lg shadow h-full">
<div class="flex items-start justify-between p-4 border-b rounded-t bg-primary-600">
<h3 class="text-xl font-semibold text-white p-2 rounded">Professional Search</h3>
<button (click)="closeAndSearch()" type="button" class="text-white bg-transparent hover:bg-gray-200 hover:text-neutral-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-primary-600 font-medium border-b-2 border-primary-600 pb-2">Filter ({{ numberOfResults$ | async }})</button>
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-primary-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-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip">
Clear all Filter
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
<!-- Display active filters as tags -->
<div class="flex flex-wrap gap-2 mb-4" *ngIf="hasActiveFilters()">
<span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.types?.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Types: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Professional Name: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.companyName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Company: {{ criteria.companyName }} <button (click)="removeFilter('companyName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.counties?.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Areas Served: {{ criteria.counties.join(', ') }} <button (click)="removeFilter('counties')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
</div>
@if(criteria.criteriaType==='brokerListings') {
<div class="grid grid-cols-1 gap-6">
<div class="space-y-4">
<div>
<label for="state" class="block mb-2 text-sm font-medium text-neutral-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-neutral-900 font-medium" [state]="criteria.state"></app-validated-city>
</div>
<div *ngIf="criteria.city">
<label class="block mb-2 text-sm font-medium text-neutral-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" (ngModelChange)="updateCriteria({ searchType: $event })" 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" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
<span class="ml-2">Radius Search</span>
</label>
</div>
</div>
<div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2">
<label class="block mb-2 text-sm font-medium text-neutral-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-neutral-200 hover:bg-gray-500 hover:text-white"
[ngClass]="criteria.radius === radius ? 'text-white bg-gray-500' : 'text-neutral-900 bg-white'"
(click)="setRadius(radius)"
>
{{ radius }}
</button>
}
</div>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-neutral-900">Professional Type</label>
<ng-select
class="custom"
[items]="selectOptions.customerSubTypes"
bindLabel="name"
bindValue="value"
[ngModel]="criteria.types"
(ngModelChange)="onCategoryChange($event)"
[multiple]="true"
[closeOnSelect]="true"
placeholder="Select professional types"
></ng-select>
</div>
<div>
<label for="brokerName" class="block mb-2 text-sm font-medium text-neutral-900">Professional Name</label>
<input
type="text"
id="brokerName"
[ngModel]="criteria.brokerName"
(ngModelChange)="updateCriteria({ brokerName: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. John Smith"
/>
</div>
<div>
<label for="companyName" class="block mb-2 text-sm font-medium text-neutral-900">Company Name</label>
<input
type="text"
id="companyName"
[ngModel]="criteria.companyName"
(ngModelChange)="updateCriteria({ companyName: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. ABC Brokers"
/>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-neutral-900">Counties / Areas Served</label>
<ng-select
class="custom"
[items]="counties$ | async"
[multiple]="true"
[loading]="countyLoading"
[typeahead]="countyInput$"
[ngModel]="criteria.counties"
(ngModelChange)="onCountiesChange($event)"
[closeOnSelect]="true"
placeholder="Type to search counties"
></ng-select>
</div>
</div>
</div>
}
</div>
</div>
</div>
</div>
<div *ngIf="!isModal" class="space-y-6 pb-10">
<div class="flex space-x-4 mb-4">
<h3 class="text-xl font-semibold text-neutral-900">Filter ({{ numberOfResults$ | async }})</h3>
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-primary-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-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip">
Clear all Filter
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
<!-- Display active filters as tags -->
<div class="flex flex-wrap gap-2" *ngIf="hasActiveFilters()">
<span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.types?.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Types: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Professional Name: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.companyName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Company: {{ criteria.companyName }} <button (click)="removeFilter('companyName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.counties?.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Areas Served: {{ criteria.counties.join(', ') }} <button (click)="removeFilter('counties')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
</div>
@if(criteria.criteriaType==='brokerListings') {
<div class="space-y-4">
<div>
<label for="state" class="block mb-2 text-sm font-medium text-neutral-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-neutral-900 font-medium" [state]="criteria.state"></app-validated-city>
</div>
<div *ngIf="criteria.city">
<label class="block mb-2 text-sm font-medium text-neutral-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" (ngModelChange)="updateCriteria({ searchType: $event })" 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" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
<span class="ml-2">Radius Search</span>
</label>
</div>
</div>
<div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2">
<label class="block mb-2 text-sm font-medium text-neutral-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-neutral-200 hover:bg-gray-500 hover:text-white"
[ngClass]="criteria.radius === radius ? 'text-white bg-gray-500' : 'text-neutral-900 bg-white'"
(click)="setRadius(radius)"
>
{{ radius }}
</button>
}
</div>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-neutral-900">Professional Type</label>
<ng-select
class="custom"
[items]="selectOptions.customerSubTypes"
bindLabel="name"
bindValue="value"
[ngModel]="criteria.types"
(ngModelChange)="onCategoryChange($event)"
[multiple]="true"
[closeOnSelect]="true"
placeholder="Select professional types"
></ng-select>
</div>
<div>
<label for="brokerName" class="block mb-2 text-sm font-medium text-neutral-900">Professional Name</label>
<input
type="text"
id="brokerName"
[ngModel]="criteria.brokerName"
(ngModelChange)="updateCriteria({ brokerName: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. John Smith"
/>
</div>
<div>
<label for="companyName" class="block mb-2 text-sm font-medium text-neutral-900">Company Name</label>
<input
type="text"
id="companyName"
[ngModel]="criteria.companyName"
(ngModelChange)="updateCriteria({ companyName: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. ABC Brokers"
/>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-neutral-900">Counties / Areas Served</label>
<ng-select
class="custom"
[items]="counties$ | async"
[multiple]="true"
[loading]="countyLoading"
[typeahead]="countyInput$"
[ngModel]="criteria.counties"
(ngModelChange)="onCountiesChange($event)"
[closeOnSelect]="true"
placeholder="Type to search counties"
></ng-select>
</div>
</div>
}
</div>

View File

@@ -1,316 +1,316 @@
import { CommonModule } from '@angular/common';
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NgSelectModule } from '@ng-select/ng-select';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { catchError, concat, debounceTime, distinctUntilChanged, map, Observable, of, Subject, switchMap, takeUntil, tap } from 'rxjs';
import { CountyResult, GeoResult, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
import { FilterStateService } from '../../services/filter-state.service';
import { GeoService } from '../../services/geo.service';
import { SearchService } from '../../services/search.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { UserService } from '../../services/user.service';
import { ValidatedCityComponent } from '../validated-city/validated-city.component';
import { ModalService } from './modal.service';
@UntilDestroy()
@Component({
selector: 'app-search-modal-broker',
standalone: true,
imports: [CommonModule, FormsModule, NgSelectModule, ValidatedCityComponent],
templateUrl: './search-modal-broker.component.html',
styleUrls: ['./search-modal.component.scss'],
})
export class SearchModalBrokerComponent implements OnInit, OnDestroy {
@Input() isModal: boolean = true;
private destroy$ = new Subject<void>();
private searchDebounce$ = new Subject<void>();
// State
criteria: UserListingCriteria;
backupCriteria: any;
// Geo search
counties$: Observable<CountyResult[]>;
countyLoading = false;
countyInput$ = new Subject<string>();
// Results count
numberOfResults$: Observable<number>;
cancelDisable = false;
constructor(
public selectOptions: SelectOptionsService,
public modalService: ModalService,
private geoService: GeoService,
private filterStateService: FilterStateService,
private userService: UserService,
private searchService: SearchService,
) {}
ngOnInit(): void {
// Load counties
this.loadCounties();
if (this.isModal) {
// Modal mode: Wait for messages from ModalService
this.modalService.message$.pipe(untilDestroyed(this)).subscribe(criteria => {
if (criteria?.criteriaType === 'brokerListings') {
this.initializeWithCriteria(criteria);
}
});
this.modalService.modalVisible$.pipe(untilDestroyed(this)).subscribe(val => {
if (val.visible && val.type === 'brokerListings') {
// Reset pagination when modal opens
if (this.criteria) {
this.criteria.page = 1;
this.criteria.start = 0;
}
}
});
} else {
// Embedded mode: Subscribe to state changes
this.subscribeToStateChanges();
}
// Setup debounced search
this.searchDebounce$.pipe(debounceTime(400), takeUntil(this.destroy$)).subscribe(() => {
this.triggerSearch();
});
}
private initializeWithCriteria(criteria: UserListingCriteria): void {
this.criteria = criteria;
this.backupCriteria = JSON.parse(JSON.stringify(criteria));
this.setTotalNumberOfResults();
}
private subscribeToStateChanges(): void {
if (!this.isModal) {
this.filterStateService
.getState$('brokerListings')
.pipe(takeUntil(this.destroy$))
.subscribe(state => {
this.criteria = { ...state.criteria };
this.setTotalNumberOfResults();
});
}
}
private loadCounties(): void {
this.counties$ = concat(
of([]), // default items
this.countyInput$.pipe(
distinctUntilChanged(),
tap(() => (this.countyLoading = true)),
switchMap(term =>
this.geoService.findCountiesStartingWith(term).pipe(
catchError(() => of([])),
map(counties => counties.map(county => county.name)),
tap(() => (this.countyLoading = false)),
),
),
),
);
}
// Filter removal methods
removeFilter(filterType: string): void {
const updates: any = {};
switch (filterType) {
case 'state':
updates.state = null;
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
break;
case 'city':
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
break;
case 'types':
updates.types = [];
break;
case 'brokerName':
updates.brokerName = null;
break;
case 'companyName':
updates.companyName = null;
break;
case 'counties':
updates.counties = [];
break;
}
this.updateCriteria(updates);
}
// Professional type handling
onCategoryChange(selectedCategories: string[]): void {
this.updateCriteria({ types: selectedCategories });
}
categoryClicked(checked: boolean, value: string): void {
const types = [...(this.criteria.types || [])];
if (checked) {
if (!types.includes(value)) {
types.push(value);
}
} else {
const index = types.indexOf(value);
if (index > -1) {
types.splice(index, 1);
}
}
this.updateCriteria({ types });
}
// Counties handling
onCountiesChange(selectedCounties: string[]): void {
this.updateCriteria({ counties: selectedCounties });
}
// Location handling
setState(state: string): void {
const updates: any = { state };
if (!state) {
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
}
this.updateCriteria(updates);
}
setCity(city: any): void {
const updates: any = {};
if (city) {
updates.city = city;
updates.state = city.state;
// Automatically set radius to 50 miles and enable radius search
updates.searchType = 'radius';
updates.radius = 50;
} else {
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
}
this.updateCriteria(updates);
}
setRadius(radius: number): void {
this.updateCriteria({ radius });
}
onCriteriaChange(): void {
this.triggerSearch();
}
// Debounced search for text inputs
debouncedSearch(): void {
this.searchDebounce$.next();
}
// Clear all filters
clearFilter(): void {
if (this.isModal) {
// In modal: Reset locally
const defaultCriteria = this.getDefaultCriteria();
this.criteria = defaultCriteria;
this.setTotalNumberOfResults();
} else {
// Embedded: Use state service
this.filterStateService.clearFilters('brokerListings');
}
}
// Modal-specific methods
closeAndSearch(): void {
if (this.isModal) {
// Save changes to state
this.filterStateService.setCriteria('brokerListings', this.criteria);
this.modalService.accept();
this.searchService.search('brokerListings');
}
}
close(): void {
if (this.isModal) {
// Discard changes
this.modalService.reject(this.backupCriteria);
}
}
// Helper methods
public updateCriteria(updates: any): void {
if (this.isModal) {
// In modal: Update locally only
this.criteria = { ...this.criteria, ...updates };
this.setTotalNumberOfResults();
} else {
// Embedded: Update through state service
this.filterStateService.updateCriteria('brokerListings', updates);
}
// Trigger search after update
this.debouncedSearch();
}
private triggerSearch(): void {
if (this.isModal) {
// In modal: Only update count
this.setTotalNumberOfResults();
this.cancelDisable = true;
} else {
// Embedded: Full search
this.searchService.search('brokerListings');
}
}
private setTotalNumberOfResults(): void {
this.numberOfResults$ = this.userService.getNumberOfBroker(this.criteria);
}
private getDefaultCriteria(): UserListingCriteria {
return {
criteriaType: 'brokerListings',
types: [],
state: null,
city: null,
radius: null,
searchType: 'exact' as const,
brokerName: null,
companyName: null,
counties: [],
prompt: null,
page: 1,
start: 0,
length: 12,
};
}
hasActiveFilters(): boolean {
if (!this.criteria) return false;
return !!(
this.criteria.state ||
this.criteria.city ||
this.criteria.types?.length ||
this.criteria.brokerName ||
this.criteria.companyName ||
this.criteria.counties?.length
);
}
trackByFn(item: GeoResult): any {
return item.id;
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
import { CommonModule } from '@angular/common';
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NgSelectModule } from '@ng-select/ng-select';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { catchError, concat, debounceTime, distinctUntilChanged, map, Observable, of, Subject, switchMap, takeUntil, tap } from 'rxjs';
import { CountyResult, GeoResult, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
import { FilterStateService } from '../../services/filter-state.service';
import { GeoService } from '../../services/geo.service';
import { SearchService } from '../../services/search.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { UserService } from '../../services/user.service';
import { ValidatedCityComponent } from '../validated-city/validated-city.component';
import { ModalService } from './modal.service';
@UntilDestroy()
@Component({
selector: 'app-search-modal-broker',
standalone: true,
imports: [CommonModule, FormsModule, NgSelectModule, ValidatedCityComponent],
templateUrl: './search-modal-broker.component.html',
styleUrls: ['./search-modal.component.scss'],
})
export class SearchModalBrokerComponent implements OnInit, OnDestroy {
@Input() isModal: boolean = true;
private destroy$ = new Subject<void>();
private searchDebounce$ = new Subject<void>();
// State
criteria: UserListingCriteria;
backupCriteria: any;
// Geo search
counties$: Observable<CountyResult[]>;
countyLoading = false;
countyInput$ = new Subject<string>();
// Results count
numberOfResults$: Observable<number>;
cancelDisable = false;
constructor(
public selectOptions: SelectOptionsService,
public modalService: ModalService,
private geoService: GeoService,
private filterStateService: FilterStateService,
private userService: UserService,
private searchService: SearchService,
) {}
ngOnInit(): void {
// Load counties
this.loadCounties();
if (this.isModal) {
// Modal mode: Wait for messages from ModalService
this.modalService.message$.pipe(untilDestroyed(this)).subscribe(criteria => {
if (criteria?.criteriaType === 'brokerListings') {
this.initializeWithCriteria(criteria);
}
});
this.modalService.modalVisible$.pipe(untilDestroyed(this)).subscribe(val => {
if (val.visible && val.type === 'brokerListings') {
// Reset pagination when modal opens
if (this.criteria) {
this.criteria.page = 1;
this.criteria.start = 0;
}
}
});
} else {
// Embedded mode: Subscribe to state changes
this.subscribeToStateChanges();
}
// Setup debounced search
this.searchDebounce$.pipe(debounceTime(400), takeUntil(this.destroy$)).subscribe(() => {
this.triggerSearch();
});
}
private initializeWithCriteria(criteria: UserListingCriteria): void {
this.criteria = criteria;
this.backupCriteria = JSON.parse(JSON.stringify(criteria));
this.setTotalNumberOfResults();
}
private subscribeToStateChanges(): void {
if (!this.isModal) {
this.filterStateService
.getState$('brokerListings')
.pipe(takeUntil(this.destroy$))
.subscribe(state => {
this.criteria = { ...state.criteria };
this.setTotalNumberOfResults();
});
}
}
private loadCounties(): void {
this.counties$ = concat(
of([]), // default items
this.countyInput$.pipe(
distinctUntilChanged(),
tap(() => (this.countyLoading = true)),
switchMap(term =>
this.geoService.findCountiesStartingWith(term).pipe(
catchError(() => of([])),
map(counties => counties.map(county => county.name)),
tap(() => (this.countyLoading = false)),
),
),
),
);
}
// Filter removal methods
removeFilter(filterType: string): void {
const updates: any = {};
switch (filterType) {
case 'state':
updates.state = null;
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
break;
case 'city':
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
break;
case 'types':
updates.types = [];
break;
case 'brokerName':
updates.brokerName = null;
break;
case 'companyName':
updates.companyName = null;
break;
case 'counties':
updates.counties = [];
break;
}
this.updateCriteria(updates);
}
// Professional type handling
onCategoryChange(selectedCategories: string[]): void {
this.updateCriteria({ types: selectedCategories });
}
categoryClicked(checked: boolean, value: string): void {
const types = [...(this.criteria.types || [])];
if (checked) {
if (!types.includes(value)) {
types.push(value);
}
} else {
const index = types.indexOf(value);
if (index > -1) {
types.splice(index, 1);
}
}
this.updateCriteria({ types });
}
// Counties handling
onCountiesChange(selectedCounties: string[]): void {
this.updateCriteria({ counties: selectedCounties });
}
// Location handling
setState(state: string): void {
const updates: any = { state };
if (!state) {
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
}
this.updateCriteria(updates);
}
setCity(city: any): void {
const updates: any = {};
if (city) {
updates.city = city;
updates.state = city.state;
// Automatically set radius to 50 miles and enable radius search
updates.searchType = 'radius';
updates.radius = 50;
} else {
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
}
this.updateCriteria(updates);
}
setRadius(radius: number): void {
this.updateCriteria({ radius });
}
onCriteriaChange(): void {
this.triggerSearch();
}
// Debounced search for text inputs
debouncedSearch(): void {
this.searchDebounce$.next();
}
// Clear all filters
clearFilter(): void {
if (this.isModal) {
// In modal: Reset locally
const defaultCriteria = this.getDefaultCriteria();
this.criteria = defaultCriteria;
this.setTotalNumberOfResults();
} else {
// Embedded: Use state service
this.filterStateService.clearFilters('brokerListings');
}
}
// Modal-specific methods
closeAndSearch(): void {
if (this.isModal) {
// Save changes to state
this.filterStateService.setCriteria('brokerListings', this.criteria);
this.modalService.accept();
this.searchService.search('brokerListings');
}
}
close(): void {
if (this.isModal) {
// Discard changes
this.modalService.reject(this.backupCriteria);
}
}
// Helper methods
public updateCriteria(updates: any): void {
if (this.isModal) {
// In modal: Update locally only
this.criteria = { ...this.criteria, ...updates };
this.setTotalNumberOfResults();
} else {
// Embedded: Update through state service
this.filterStateService.updateCriteria('brokerListings', updates);
}
// Trigger search after update
this.debouncedSearch();
}
private triggerSearch(): void {
if (this.isModal) {
// In modal: Only update count
this.setTotalNumberOfResults();
this.cancelDisable = true;
} else {
// Embedded: Full search
this.searchService.search('brokerListings');
}
}
private setTotalNumberOfResults(): void {
this.numberOfResults$ = this.userService.getNumberOfBroker(this.criteria);
}
private getDefaultCriteria(): UserListingCriteria {
return {
criteriaType: 'brokerListings',
types: [],
state: null,
city: null,
radius: null,
searchType: 'exact' as const,
brokerName: null,
companyName: null,
counties: [],
prompt: null,
page: 1,
start: 0,
length: 12,
};
}
hasActiveFilters(): boolean {
if (!this.criteria) return false;
return !!(
this.criteria.state ||
this.criteria.city ||
this.criteria.types?.length ||
this.criteria.brokerName ||
this.criteria.companyName ||
this.criteria.counties?.length
);
}
trackByFn(item: GeoResult): any {
return item.id;
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -1,250 +1,250 @@
<div
*ngIf="isModal && (modalService.modalVisible$ | async)?.visible && (modalService.modalVisible$ | async)?.type === 'commercialPropertyListings'"
class="fixed inset-0 bg-neutral-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50"
>
<div class="relative w-full h-screen max-h-screen">
<div class="relative bg-white rounded-lg shadow h-full">
<div class="flex items-start justify-between p-4 border-b rounded-t bg-primary-600">
<h3 class="text-xl font-semibold text-white p-2 rounded">Commercial Property Listing Search</h3>
<button (click)="closeAndSearch()" type="button" class="text-white bg-transparent hover:bg-gray-200 hover:text-neutral-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-primary-600 font-medium border-b-2 border-primary-600 pb-2">Filter ({{ numberOfResults$ | async }})</button>
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-primary-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-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip">
Clear all Filter
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
<!-- Display active filters as tags -->
<div class="flex flex-wrap gap-2 mb-4" *ngIf="hasActiveFilters()">
<span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minPrice || criteria.maxPrice" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.types.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
</div>
@if(criteria.criteriaType==='commercialPropertyListings') {
<div class="grid grid-cols-1 gap-6">
<div class="space-y-4">
<div>
<label for="state" class="block mb-2 text-sm font-medium text-neutral-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-neutral-900 font-medium" [state]="criteria.state"></app-validated-city>
</div>
<div *ngIf="criteria.city">
<label class="block mb-2 text-sm font-medium text-neutral-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" (ngModelChange)="updateCriteria({ searchType: $event })" 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" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
<span class="ml-2">Radius Search</span>
</label>
</div>
</div>
<div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2">
<label class="block mb-2 text-sm font-medium text-neutral-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-neutral-200 hover:bg-gray-500 hover:text-white"
[ngClass]="criteria.radius === radius ? 'text-white bg-gray-500' : 'text-neutral-900 bg-white'"
(click)="setRadius(radius)"
>
{{ radius }}
</button>
}
</div>
</div>
<div>
<label for="price" class="block mb-2 text-sm font-medium text-neutral-900">Price</label>
<div class="flex items-center space-x-2">
<app-validated-price name="price-from" [ngModel]="criteria.minPrice" (ngModelChange)="updateCriteria({ minPrice: $event })" 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" (ngModelChange)="updateCriteria({ maxPrice: $event })" 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-neutral-900">Title / Description (Free Search)</label>
<input
type="text"
id="title"
[ngModel]="criteria.title"
(ngModelChange)="updateCriteria({ title: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. Office Space"
/>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-neutral-900">Category</label>
<ng-select
class="custom"
[items]="selectOptions.typesOfCommercialProperty"
bindLabel="name"
bindValue="value"
[ngModel]="criteria.types"
(ngModelChange)="onCategoryChange($event)"
[multiple]="true"
[closeOnSelect]="true"
placeholder="Select categories"
></ng-select>
</div>
<div>
<label for="brokername" class="block mb-2 text-sm font-medium text-neutral-900">Broker Name / Company Name</label>
<input
type="text"
id="brokername"
[ngModel]="criteria.brokerName"
(ngModelChange)="updateCriteria({ brokerName: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. Brokers Invest"
/>
</div>
</div>
</div>
}
</div>
</div>
</div>
</div>
<div *ngIf="!isModal" class="space-y-6 pb-10">
<div class="flex space-x-4 mb-4">
<h3 class="text-xl font-semibold text-neutral-900">Filter ({{ numberOfResults$ | async }})</h3>
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-primary-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-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip">
Clear all Filter
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
<!-- Display active filters as tags -->
<div class="flex flex-wrap gap-2" *ngIf="hasActiveFilters()">
<span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minPrice || criteria.maxPrice" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.types.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
</div>
@if(criteria.criteriaType==='commercialPropertyListings') {
<div class="space-y-4">
<div>
<label for="state" class="block mb-2 text-sm font-medium text-neutral-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-neutral-900 font-medium" [state]="criteria.state"></app-validated-city>
</div>
<div *ngIf="criteria.city">
<label class="block mb-2 text-sm font-medium text-neutral-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" (ngModelChange)="updateCriteria({ searchType: $event })" 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" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
<span class="ml-2">Radius Search</span>
</label>
</div>
</div>
<div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2">
<label class="block mb-2 text-sm font-medium text-neutral-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-neutral-200 hover:bg-gray-500 hover:text-white"
[ngClass]="criteria.radius === radius ? 'text-white bg-gray-500' : 'text-neutral-900 bg-white'"
(click)="setRadius(radius)"
>
{{ radius }}
</button>
}
</div>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-neutral-900">Category</label>
<ng-select
class="custom"
[items]="selectOptions.typesOfCommercialProperty"
bindLabel="name"
bindValue="value"
[ngModel]="criteria.types"
(ngModelChange)="onCategoryChange($event)"
[multiple]="true"
[closeOnSelect]="true"
placeholder="Select categories"
></ng-select>
</div>
<div>
<label for="price" class="block mb-2 text-sm font-medium text-neutral-900">Price</label>
<div class="flex items-center space-x-2">
<app-validated-price name="price-from" [ngModel]="criteria.minPrice" (ngModelChange)="updateCriteria({ minPrice: $event })" 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" (ngModelChange)="updateCriteria({ maxPrice: $event })" 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-neutral-900">Title / Description (Free Search)</label>
<input
type="text"
id="title"
[ngModel]="criteria.title"
(ngModelChange)="updateCriteria({ title: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. Office Space"
/>
</div>
<div>
<label for="brokername-embedded" class="block mb-2 text-sm font-medium text-neutral-900">Broker Name / Company Name</label>
<input
type="text"
id="brokername-embedded"
[ngModel]="criteria.brokerName"
(ngModelChange)="updateCriteria({ brokerName: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. Brokers Invest"
/>
</div>
</div>
}
</div>
<div
*ngIf="isModal && (modalService.modalVisible$ | async)?.visible && (modalService.modalVisible$ | async)?.type === 'commercialPropertyListings'"
class="fixed inset-0 bg-neutral-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50"
>
<div class="relative w-full h-screen max-h-screen">
<div class="relative bg-white rounded-lg shadow h-full">
<div class="flex items-start justify-between p-4 border-b rounded-t bg-primary-600">
<h3 class="text-xl font-semibold text-white p-2 rounded">Commercial Property Listing Search</h3>
<button (click)="closeAndSearch()" type="button" class="text-white bg-transparent hover:bg-gray-200 hover:text-neutral-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-primary-600 font-medium border-b-2 border-primary-600 pb-2">Filter ({{ numberOfResults$ | async }})</button>
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-primary-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-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip">
Clear all Filter
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
<!-- Display active filters as tags -->
<div class="flex flex-wrap gap-2 mb-4" *ngIf="hasActiveFilters()">
<span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minPrice || criteria.maxPrice" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.types.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
</div>
@if(criteria.criteriaType==='commercialPropertyListings') {
<div class="grid grid-cols-1 gap-6">
<div class="space-y-4">
<div>
<label for="state" class="block mb-2 text-sm font-medium text-neutral-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-neutral-900 font-medium" [state]="criteria.state"></app-validated-city>
</div>
<div *ngIf="criteria.city">
<label class="block mb-2 text-sm font-medium text-neutral-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" (ngModelChange)="updateCriteria({ searchType: $event })" 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" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
<span class="ml-2">Radius Search</span>
</label>
</div>
</div>
<div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2">
<label class="block mb-2 text-sm font-medium text-neutral-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-neutral-200 hover:bg-gray-500 hover:text-white"
[ngClass]="criteria.radius === radius ? 'text-white bg-gray-500' : 'text-neutral-900 bg-white'"
(click)="setRadius(radius)"
>
{{ radius }}
</button>
}
</div>
</div>
<div>
<label for="price" class="block mb-2 text-sm font-medium text-neutral-900">Price</label>
<div class="flex items-center space-x-2">
<app-validated-price name="price-from" [ngModel]="criteria.minPrice" (ngModelChange)="updateCriteria({ minPrice: $event })" 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" (ngModelChange)="updateCriteria({ maxPrice: $event })" 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-neutral-900">Title / Description (Free Search)</label>
<input
type="text"
id="title"
[ngModel]="criteria.title"
(ngModelChange)="updateCriteria({ title: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. Office Space"
/>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-neutral-900">Category</label>
<ng-select
class="custom"
[items]="selectOptions.typesOfCommercialProperty"
bindLabel="name"
bindValue="value"
[ngModel]="criteria.types"
(ngModelChange)="onCategoryChange($event)"
[multiple]="true"
[closeOnSelect]="true"
placeholder="Select categories"
></ng-select>
</div>
<div>
<label for="brokername" class="block mb-2 text-sm font-medium text-neutral-900">Broker Name / Company Name</label>
<input
type="text"
id="brokername"
[ngModel]="criteria.brokerName"
(ngModelChange)="updateCriteria({ brokerName: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. Brokers Invest"
/>
</div>
</div>
</div>
}
</div>
</div>
</div>
</div>
<div *ngIf="!isModal" class="space-y-6 pb-10">
<div class="flex space-x-4 mb-4">
<h3 class="text-xl font-semibold text-neutral-900">Filter ({{ numberOfResults$ | async }})</h3>
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-primary-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-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip">
Clear all Filter
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
<!-- Display active filters as tags -->
<div class="flex flex-wrap gap-2" *ngIf="hasActiveFilters()">
<span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minPrice || criteria.maxPrice" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.types.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
</div>
@if(criteria.criteriaType==='commercialPropertyListings') {
<div class="space-y-4">
<div>
<label for="state" class="block mb-2 text-sm font-medium text-neutral-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-neutral-900 font-medium" [state]="criteria.state"></app-validated-city>
</div>
<div *ngIf="criteria.city">
<label class="block mb-2 text-sm font-medium text-neutral-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" (ngModelChange)="updateCriteria({ searchType: $event })" 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" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
<span class="ml-2">Radius Search</span>
</label>
</div>
</div>
<div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2">
<label class="block mb-2 text-sm font-medium text-neutral-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-neutral-200 hover:bg-gray-500 hover:text-white"
[ngClass]="criteria.radius === radius ? 'text-white bg-gray-500' : 'text-neutral-900 bg-white'"
(click)="setRadius(radius)"
>
{{ radius }}
</button>
}
</div>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-neutral-900">Category</label>
<ng-select
class="custom"
[items]="selectOptions.typesOfCommercialProperty"
bindLabel="name"
bindValue="value"
[ngModel]="criteria.types"
(ngModelChange)="onCategoryChange($event)"
[multiple]="true"
[closeOnSelect]="true"
placeholder="Select categories"
></ng-select>
</div>
<div>
<label for="price" class="block mb-2 text-sm font-medium text-neutral-900">Price</label>
<div class="flex items-center space-x-2">
<app-validated-price name="price-from" [ngModel]="criteria.minPrice" (ngModelChange)="updateCriteria({ minPrice: $event })" 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" (ngModelChange)="updateCriteria({ maxPrice: $event })" 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-neutral-900">Title / Description (Free Search)</label>
<input
type="text"
id="title"
[ngModel]="criteria.title"
(ngModelChange)="updateCriteria({ title: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. Office Space"
/>
</div>
<div>
<label for="brokername-embedded" class="block mb-2 text-sm font-medium text-neutral-900">Broker Name / Company Name</label>
<input
type="text"
id="brokername-embedded"
[ngModel]="criteria.brokerName"
(ngModelChange)="updateCriteria({ brokerName: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. Brokers Invest"
/>
</div>
</div>
}
</div>

View File

@@ -1,316 +1,316 @@
import { CommonModule } from '@angular/common';
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NgSelectModule } from '@ng-select/ng-select';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { catchError, concat, debounceTime, distinctUntilChanged, map, Observable, of, Subject, switchMap, takeUntil, tap } from 'rxjs';
import { CommercialPropertyListingCriteria, CountyResult, GeoResult } from '../../../../../bizmatch-server/src/models/main.model';
import { FilterStateService } from '../../services/filter-state.service';
import { GeoService } from '../../services/geo.service';
import { ListingsService } from '../../services/listings.service';
import { SearchService } from '../../services/search.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { ValidatedCityComponent } from '../validated-city/validated-city.component';
import { ValidatedPriceComponent } from '../validated-price/validated-price.component';
import { ModalService } from './modal.service';
@UntilDestroy()
@Component({
selector: 'app-search-modal-commercial',
standalone: true,
imports: [CommonModule, FormsModule, NgSelectModule, ValidatedCityComponent, ValidatedPriceComponent],
templateUrl: './search-modal-commercial.component.html',
styleUrls: ['./search-modal.component.scss'],
})
export class SearchModalCommercialComponent implements OnInit, OnDestroy {
@Input() isModal: boolean = true;
private destroy$ = new Subject<void>();
private searchDebounce$ = new Subject<void>();
// State
criteria: CommercialPropertyListingCriteria;
backupCriteria: any;
// Geo search
counties$: Observable<CountyResult[]>;
countyLoading = false;
countyInput$ = new Subject<string>();
// Results count
numberOfResults$: Observable<number>;
cancelDisable = false;
constructor(
public selectOptions: SelectOptionsService,
public modalService: ModalService,
private geoService: GeoService,
private filterStateService: FilterStateService,
private listingService: ListingsService,
private searchService: SearchService,
) { }
ngOnInit(): void {
// Load counties
this.loadCounties();
if (this.isModal) {
// Modal mode: Wait for messages from ModalService
this.modalService.message$.pipe(untilDestroyed(this)).subscribe(criteria => {
if (criteria?.criteriaType === 'commercialPropertyListings') {
this.initializeWithCriteria(criteria);
}
});
this.modalService.modalVisible$.pipe(untilDestroyed(this)).subscribe(val => {
if (val.visible && val.type === 'commercialPropertyListings') {
// Reset pagination when modal opens
if (this.criteria) {
this.criteria.page = 1;
this.criteria.start = 0;
}
}
});
} else {
// Embedded mode: Subscribe to state changes
this.subscribeToStateChanges();
}
// Setup debounced search
this.searchDebounce$.pipe(debounceTime(400), takeUntil(this.destroy$)).subscribe(() => {
this.triggerSearch();
});
}
private initializeWithCriteria(criteria: CommercialPropertyListingCriteria): void {
this.criteria = criteria;
this.backupCriteria = JSON.parse(JSON.stringify(criteria));
this.setTotalNumberOfResults();
}
private subscribeToStateChanges(): void {
if (!this.isModal) {
this.filterStateService
.getState$('commercialPropertyListings')
.pipe(takeUntil(this.destroy$))
.subscribe(state => {
this.criteria = { ...state.criteria };
this.setTotalNumberOfResults();
});
}
}
private loadCounties(): void {
this.counties$ = concat(
of([]), // default items
this.countyInput$.pipe(
distinctUntilChanged(),
tap(() => (this.countyLoading = true)),
switchMap(term =>
this.geoService.findCountiesStartingWith(term).pipe(
catchError(() => of([])),
map(counties => counties.map(county => county.name)),
tap(() => (this.countyLoading = false)),
),
),
),
);
}
// Filter removal methods
removeFilter(filterType: string): void {
const updates: any = {};
switch (filterType) {
case 'state':
updates.state = null;
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
break;
case 'city':
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
break;
case 'price':
updates.minPrice = null;
updates.maxPrice = null;
break;
case 'types':
updates.types = [];
break;
case 'title':
updates.title = null;
break;
case 'brokerName':
updates.brokerName = null;
break;
}
this.updateCriteria(updates);
}
// Category handling
onCategoryChange(selectedCategories: string[]): void {
this.updateCriteria({ types: selectedCategories });
}
categoryClicked(checked: boolean, value: string): void {
const types = [...(this.criteria.types || [])];
if (checked) {
if (!types.includes(value)) {
types.push(value);
}
} else {
const index = types.indexOf(value);
if (index > -1) {
types.splice(index, 1);
}
}
this.updateCriteria({ types });
}
// Location handling
setState(state: string): void {
const updates: any = { state };
if (!state) {
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
}
this.updateCriteria(updates);
}
setCity(city: any): void {
const updates: any = {};
if (city) {
updates.city = city;
updates.state = city.state;
// Automatically set radius to 50 miles and enable radius search
updates.searchType = 'radius';
updates.radius = 50;
} else {
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
}
this.updateCriteria(updates);
}
setRadius(radius: number): void {
this.updateCriteria({ radius });
}
onCriteriaChange(): void {
this.triggerSearch();
}
// Debounced search for text inputs
debouncedSearch(): void {
this.searchDebounce$.next();
}
// Clear all filters
clearFilter(): void {
if (this.isModal) {
// In modal: Reset locally
const defaultCriteria = this.getDefaultCriteria();
this.criteria = defaultCriteria;
this.setTotalNumberOfResults();
} else {
// Embedded: Use state service
this.filterStateService.clearFilters('commercialPropertyListings');
}
}
// Modal-specific methods
closeAndSearch(): void {
if (this.isModal) {
// Save changes to state
this.filterStateService.setCriteria('commercialPropertyListings', this.criteria);
this.modalService.accept();
this.searchService.search('commercialPropertyListings');
}
}
close(): void {
if (this.isModal) {
// Discard changes
this.modalService.reject(this.backupCriteria);
}
}
// Helper methods
public updateCriteria(updates: any): void {
if (this.isModal) {
// In modal: Update locally only
this.criteria = { ...this.criteria, ...updates };
this.setTotalNumberOfResults();
} else {
// Embedded: Update through state service
this.filterStateService.updateCriteria('commercialPropertyListings', updates);
}
// Trigger search after update
this.debouncedSearch();
}
private triggerSearch(): void {
if (this.isModal) {
// In modal: Only update count
this.setTotalNumberOfResults();
this.cancelDisable = true;
} else {
// Embedded: Full search
this.searchService.search('commercialPropertyListings');
}
}
private setTotalNumberOfResults(): void {
this.numberOfResults$ = this.listingService.getNumberOfListings('commercialProperty', this.criteria);
}
private getDefaultCriteria(): CommercialPropertyListingCriteria {
// Access the private method through a workaround or create it here
return {
criteriaType: 'commercialPropertyListings',
types: [],
state: null,
city: null,
radius: null,
searchType: 'exact' as const,
minPrice: null,
maxPrice: null,
title: null,
brokerName: null,
prompt: null,
page: 1,
start: 0,
length: 12,
};
}
hasActiveFilters(): boolean {
if (!this.criteria) return false;
return !!(
this.criteria.state ||
this.criteria.city ||
this.criteria.minPrice ||
this.criteria.maxPrice ||
this.criteria.types?.length ||
this.criteria.title ||
this.criteria.brokerName
);
}
trackByFn(item: GeoResult): any {
return item.id;
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
import { CommonModule } from '@angular/common';
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NgSelectModule } from '@ng-select/ng-select';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { catchError, concat, debounceTime, distinctUntilChanged, map, Observable, of, Subject, switchMap, takeUntil, tap } from 'rxjs';
import { CommercialPropertyListingCriteria, CountyResult, GeoResult } from '../../../../../bizmatch-server/src/models/main.model';
import { FilterStateService } from '../../services/filter-state.service';
import { GeoService } from '../../services/geo.service';
import { ListingsService } from '../../services/listings.service';
import { SearchService } from '../../services/search.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { ValidatedCityComponent } from '../validated-city/validated-city.component';
import { ValidatedPriceComponent } from '../validated-price/validated-price.component';
import { ModalService } from './modal.service';
@UntilDestroy()
@Component({
selector: 'app-search-modal-commercial',
standalone: true,
imports: [CommonModule, FormsModule, NgSelectModule, ValidatedCityComponent, ValidatedPriceComponent],
templateUrl: './search-modal-commercial.component.html',
styleUrls: ['./search-modal.component.scss'],
})
export class SearchModalCommercialComponent implements OnInit, OnDestroy {
@Input() isModal: boolean = true;
private destroy$ = new Subject<void>();
private searchDebounce$ = new Subject<void>();
// State
criteria: CommercialPropertyListingCriteria;
backupCriteria: any;
// Geo search
counties$: Observable<CountyResult[]>;
countyLoading = false;
countyInput$ = new Subject<string>();
// Results count
numberOfResults$: Observable<number>;
cancelDisable = false;
constructor(
public selectOptions: SelectOptionsService,
public modalService: ModalService,
private geoService: GeoService,
private filterStateService: FilterStateService,
private listingService: ListingsService,
private searchService: SearchService,
) { }
ngOnInit(): void {
// Load counties
this.loadCounties();
if (this.isModal) {
// Modal mode: Wait for messages from ModalService
this.modalService.message$.pipe(untilDestroyed(this)).subscribe(criteria => {
if (criteria?.criteriaType === 'commercialPropertyListings') {
this.initializeWithCriteria(criteria);
}
});
this.modalService.modalVisible$.pipe(untilDestroyed(this)).subscribe(val => {
if (val.visible && val.type === 'commercialPropertyListings') {
// Reset pagination when modal opens
if (this.criteria) {
this.criteria.page = 1;
this.criteria.start = 0;
}
}
});
} else {
// Embedded mode: Subscribe to state changes
this.subscribeToStateChanges();
}
// Setup debounced search
this.searchDebounce$.pipe(debounceTime(400), takeUntil(this.destroy$)).subscribe(() => {
this.triggerSearch();
});
}
private initializeWithCriteria(criteria: CommercialPropertyListingCriteria): void {
this.criteria = criteria;
this.backupCriteria = JSON.parse(JSON.stringify(criteria));
this.setTotalNumberOfResults();
}
private subscribeToStateChanges(): void {
if (!this.isModal) {
this.filterStateService
.getState$('commercialPropertyListings')
.pipe(takeUntil(this.destroy$))
.subscribe(state => {
this.criteria = { ...state.criteria };
this.setTotalNumberOfResults();
});
}
}
private loadCounties(): void {
this.counties$ = concat(
of([]), // default items
this.countyInput$.pipe(
distinctUntilChanged(),
tap(() => (this.countyLoading = true)),
switchMap(term =>
this.geoService.findCountiesStartingWith(term).pipe(
catchError(() => of([])),
map(counties => counties.map(county => county.name)),
tap(() => (this.countyLoading = false)),
),
),
),
);
}
// Filter removal methods
removeFilter(filterType: string): void {
const updates: any = {};
switch (filterType) {
case 'state':
updates.state = null;
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
break;
case 'city':
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
break;
case 'price':
updates.minPrice = null;
updates.maxPrice = null;
break;
case 'types':
updates.types = [];
break;
case 'title':
updates.title = null;
break;
case 'brokerName':
updates.brokerName = null;
break;
}
this.updateCriteria(updates);
}
// Category handling
onCategoryChange(selectedCategories: string[]): void {
this.updateCriteria({ types: selectedCategories });
}
categoryClicked(checked: boolean, value: string): void {
const types = [...(this.criteria.types || [])];
if (checked) {
if (!types.includes(value)) {
types.push(value);
}
} else {
const index = types.indexOf(value);
if (index > -1) {
types.splice(index, 1);
}
}
this.updateCriteria({ types });
}
// Location handling
setState(state: string): void {
const updates: any = { state };
if (!state) {
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
}
this.updateCriteria(updates);
}
setCity(city: any): void {
const updates: any = {};
if (city) {
updates.city = city;
updates.state = city.state;
// Automatically set radius to 50 miles and enable radius search
updates.searchType = 'radius';
updates.radius = 50;
} else {
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
}
this.updateCriteria(updates);
}
setRadius(radius: number): void {
this.updateCriteria({ radius });
}
onCriteriaChange(): void {
this.triggerSearch();
}
// Debounced search for text inputs
debouncedSearch(): void {
this.searchDebounce$.next();
}
// Clear all filters
clearFilter(): void {
if (this.isModal) {
// In modal: Reset locally
const defaultCriteria = this.getDefaultCriteria();
this.criteria = defaultCriteria;
this.setTotalNumberOfResults();
} else {
// Embedded: Use state service
this.filterStateService.clearFilters('commercialPropertyListings');
}
}
// Modal-specific methods
closeAndSearch(): void {
if (this.isModal) {
// Save changes to state
this.filterStateService.setCriteria('commercialPropertyListings', this.criteria);
this.modalService.accept();
this.searchService.search('commercialPropertyListings');
}
}
close(): void {
if (this.isModal) {
// Discard changes
this.modalService.reject(this.backupCriteria);
}
}
// Helper methods
public updateCriteria(updates: any): void {
if (this.isModal) {
// In modal: Update locally only
this.criteria = { ...this.criteria, ...updates };
this.setTotalNumberOfResults();
} else {
// Embedded: Update through state service
this.filterStateService.updateCriteria('commercialPropertyListings', updates);
}
// Trigger search after update
this.debouncedSearch();
}
private triggerSearch(): void {
if (this.isModal) {
// In modal: Only update count
this.setTotalNumberOfResults();
this.cancelDisable = true;
} else {
// Embedded: Full search
this.searchService.search('commercialPropertyListings');
}
}
private setTotalNumberOfResults(): void {
this.numberOfResults$ = this.listingService.getNumberOfListings('commercialProperty', this.criteria);
}
private getDefaultCriteria(): CommercialPropertyListingCriteria {
// Access the private method through a workaround or create it here
return {
criteriaType: 'commercialPropertyListings',
types: [],
state: null,
city: null,
radius: null,
searchType: 'exact' as const,
minPrice: null,
maxPrice: null,
title: null,
brokerName: null,
prompt: null,
page: 1,
start: 0,
length: 12,
};
}
hasActiveFilters(): boolean {
if (!this.criteria) return false;
return !!(
this.criteria.state ||
this.criteria.city ||
this.criteria.minPrice ||
this.criteria.maxPrice ||
this.criteria.types?.length ||
this.criteria.title ||
this.criteria.brokerName
);
}
trackByFn(item: GeoResult): any {
return item.id;
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -1,415 +1,415 @@
<div
*ngIf="isModal && (modalService.modalVisible$ | async)?.visible && (modalService.modalVisible$ | async)?.type === 'businessListings'"
class="fixed inset-0 bg-neutral-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50"
>
<div class="relative w-full max-h-full">
<div class="relative bg-white rounded-lg shadow">
<div class="flex items-start justify-between p-4 border-b rounded-t bg-primary-600">
<h3 class="text-xl font-semibold text-white p-2 rounded">Business Listing Search</h3>
<button (click)="closeAndSearch()" type="button" class="text-white bg-transparent hover:bg-neutral-200 hover:text-neutral-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-primary-600 font-medium border-b-2 border-primary-600 pb-2">Filter ({{ numberOfResults$ | async }})</button>
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-primary-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-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip">
Clear all Filter
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
<!-- Display active filters as tags -->
<div class="flex flex-wrap gap-2 mb-4" *ngIf="hasActiveFilters()">
<span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minPrice || criteria.maxPrice" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minRevenue || criteria.maxRevenue" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Revenue: {{ criteria.minRevenue || 'Any' }} - {{ criteria.maxRevenue || 'Any' }} <button (click)="removeFilter('revenue')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minCashFlow || criteria.maxCashFlow" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Cashflow: {{ criteria.minCashFlow || 'Any' }} - {{ criteria.maxCashFlow || 'Any' }} <button (click)="removeFilter('cashflow')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.types.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="selectedPropertyType" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Property Type: {{ getSelectedPropertyTypeName() }} <button (click)="removeFilter('propertyType')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minNumberEmployees || criteria.maxNumberEmployees" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Employees: {{ criteria.minNumberEmployees || 'Any' }} - {{ criteria.maxNumberEmployees || 'Any' }} <button (click)="removeFilter('employees')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.establishedMin" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Established: {{ criteria.establishedMin || 'Any' }} <button (click)="removeFilter('established')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
</div>
<div class="grid grid-cols-1 gap-6">
<div class="space-y-4">
<div>
<label for="state" class="block mb-2 text-sm font-medium text-neutral-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-neutral-900 font-medium" [state]="criteria.state"></app-validated-city>
</div>
<div *ngIf="criteria.city">
<label class="block mb-2 text-sm font-medium text-neutral-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" (ngModelChange)="updateCriteria({ searchType: $event })" 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" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
<span class="ml-2">Radius Search</span>
</label>
</div>
</div>
<div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2">
<label class="block mb-2 text-sm font-medium text-neutral-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-neutral-200 hover:bg-neutral-500 hover:text-white"
[ngClass]="criteria.radius === radius ? 'text-white bg-neutral-500' : 'text-neutral-900 bg-white'"
(click)="setRadius(radius)"
>
{{ radius }}
</button>
}
</div>
</div>
<div>
<label for="price" class="block mb-2 text-sm font-medium text-neutral-900">Price</label>
<div class="flex items-center space-x-2">
<app-validated-price name="price-from" [ngModel]="criteria.minPrice" (ngModelChange)="updateCriteria({ minPrice: $event })" placeholder="From" inputClasses="bg-neutral-50 text-sm !mt-0 p-2.5">
</app-validated-price>
<span>-</span>
<app-validated-price name="price-to" [ngModel]="criteria.maxPrice" (ngModelChange)="updateCriteria({ maxPrice: $event })" placeholder="To" inputClasses="bg-neutral-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-neutral-900">Sales Revenue</label>
<div class="flex items-center space-x-2">
<app-validated-price name="salesRevenue-from" [ngModel]="criteria.minRevenue" (ngModelChange)="updateCriteria({ minRevenue: $event })" placeholder="From" inputClasses="bg-neutral-50 text-sm !mt-0 p-2.5">
</app-validated-price>
<span>-</span>
<app-validated-price name="salesRevenue-to" [ngModel]="criteria.maxRevenue" (ngModelChange)="updateCriteria({ maxRevenue: $event })" placeholder="To" inputClasses="bg-neutral-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-neutral-900">Cashflow</label>
<div class="flex items-center space-x-2">
<app-validated-price name="cashflow-from" [ngModel]="criteria.minCashFlow" (ngModelChange)="updateCriteria({ minCashFlow: $event })" placeholder="From" inputClasses="bg-neutral-50 text-sm !mt-0 p-2.5">
</app-validated-price>
<span>-</span>
<app-validated-price name="cashflow-to" [ngModel]="criteria.maxCashFlow" (ngModelChange)="updateCriteria({ maxCashFlow: $event })" placeholder="To" inputClasses="bg-neutral-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-neutral-900">Title / Description (Free Search)</label>
<input
type="text"
id="title"
[ngModel]="criteria.title"
(ngModelChange)="updateCriteria({ title: $event })"
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. Restaurant"
/>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-neutral-900">Category</label>
<ng-select
class="custom"
[items]="selectOptions.typesOfBusiness"
bindLabel="name"
bindValue="value"
[ngModel]="criteria.types"
(ngModelChange)="onCategoryChange($event)"
[multiple]="true"
[closeOnSelect]="true"
placeholder="Select categories"
></ng-select>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-neutral-900">Type of Property</label>
<ng-select
class="custom"
[items]="propertyTypeOptions"
bindLabel="name"
bindValue="value"
[ngModel]="selectedPropertyType"
(ngModelChange)="onPropertyTypeChange($event)"
placeholder="Select property type"
></ng-select>
</div>
<div>
<label for="numberEmployees" class="block mb-2 text-sm font-medium text-neutral-900">Number of Employees</label>
<div class="flex items-center space-x-2">
<input
type="number"
id="numberEmployees-from"
[ngModel]="criteria.minNumberEmployees"
(ngModelChange)="updateCriteria({ minNumberEmployees: $event })"
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-1/2 p-2.5"
placeholder="From"
/>
<span>-</span>
<input
type="number"
id="numberEmployees-to"
[ngModel]="criteria.maxNumberEmployees"
(ngModelChange)="updateCriteria({ maxNumberEmployees: $event })"
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-1/2 p-2.5"
placeholder="To"
/>
</div>
</div>
<div>
<label for="establishedMin" class="block mb-2 text-sm font-medium text-neutral-900">Minimum years established</label>
<div class="flex items-center space-x-2">
<input
type="number"
id="establishedMin"
[ngModel]="criteria.establishedMin"
(ngModelChange)="updateCriteria({ establishedMin: $event })"
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-1/2 p-2.5"
placeholder="YY"
/>
</div>
</div>
<div>
<label for="brokername" class="block mb-2 text-sm font-medium text-neutral-900">Broker Name / Company Name</label>
<input
type="text"
id="brokername"
[ngModel]="criteria.brokerName"
(ngModelChange)="updateCriteria({ brokerName: $event })"
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. Brokers Invest"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ################################################################################## -->
<!-- ################################################################################## -->
<!-- ################################################################################## -->
<div *ngIf="!isModal" class="space-y-6">
<div class="flex space-x-4 mb-4">
<h3 class="text-xl font-semibold text-neutral-900">Filter ({{ numberOfResults$ | async }})</h3>
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-primary-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-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip">
Clear all Filter
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
<!-- Display active filters as tags -->
<div class="flex flex-wrap gap-2" *ngIf="hasActiveFilters()">
<span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minPrice || criteria.maxPrice" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minRevenue || criteria.maxRevenue" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Revenue: {{ criteria.minRevenue || 'Any' }} - {{ criteria.maxRevenue || 'Any' }} <button (click)="removeFilter('revenue')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minCashFlow || criteria.maxCashFlow" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Cashflow: {{ criteria.minCashFlow || 'Any' }} - {{ criteria.maxCashFlow || 'Any' }} <button (click)="removeFilter('cashflow')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.types.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="selectedPropertyType" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Property Type: {{ getSelectedPropertyTypeName() }} <button (click)="removeFilter('propertyType')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minNumberEmployees || criteria.maxNumberEmployees" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Employees: {{ criteria.minNumberEmployees || 'Any' }} - {{ criteria.maxNumberEmployees || 'Any' }} <button (click)="removeFilter('employees')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.establishedMin" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Years established: {{ criteria.establishedMin || 'Any' }} <button (click)="removeFilter('established')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
</div>
@if(criteria.criteriaType==='businessListings') {
<div class="space-y-4">
<div>
<label for="state" class="block mb-2 text-sm font-medium text-neutral-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-neutral-900 font-medium" [state]="criteria.state"></app-validated-city>
</div>
<div *ngIf="criteria.city">
<label class="block mb-2 text-sm font-medium text-neutral-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" (ngModelChange)="updateCriteria({ searchType: $event })" 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" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
<span class="ml-2">Radius Search</span>
</label>
</div>
</div>
<div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2">
<label class="block mb-2 text-sm font-medium text-neutral-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-neutral-200 hover:bg-neutral-500 hover:text-white"
[ngClass]="criteria.radius === radius ? 'text-white bg-neutral-500' : 'text-neutral-900 bg-white'"
(click)="setRadius(radius)"
>
{{ radius }}
</button>
}
</div>
</div>
<div>
<label for="price" class="block mb-2 text-sm font-medium text-neutral-900">Price</label>
<div class="flex items-center space-x-2">
<app-validated-price name="price-from" [ngModel]="criteria.minPrice" (ngModelChange)="updateCriteria({ minPrice: $event })" placeholder="From" inputClasses="bg-neutral-50 text-sm !mt-0 p-2.5"> </app-validated-price>
<span>-</span>
<app-validated-price name="price-to" [ngModel]="criteria.maxPrice" (ngModelChange)="updateCriteria({ maxPrice: $event })" placeholder="To" inputClasses="bg-neutral-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-neutral-900">Sales Revenue</label>
<div class="flex items-center space-x-2">
<app-validated-price name="salesRevenue-from" [ngModel]="criteria.minRevenue" (ngModelChange)="updateCriteria({ minRevenue: $event })" placeholder="From" inputClasses="bg-neutral-50 text-sm !mt-0 p-2.5">
</app-validated-price>
<span>-</span>
<app-validated-price name="salesRevenue-to" [ngModel]="criteria.maxRevenue" (ngModelChange)="updateCriteria({ maxRevenue: $event })" placeholder="To" inputClasses="bg-neutral-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-neutral-900">Cashflow</label>
<div class="flex items-center space-x-2">
<app-validated-price name="cashflow-from" [ngModel]="criteria.minCashFlow" (ngModelChange)="updateCriteria({ minCashFlow: $event })" placeholder="From" inputClasses="bg-neutral-50 text-sm !mt-0 p-2.5">
</app-validated-price>
<span>-</span>
<app-validated-price name="cashflow-to" [ngModel]="criteria.maxCashFlow" (ngModelChange)="updateCriteria({ maxCashFlow: $event })" placeholder="To" inputClasses="bg-neutral-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-neutral-900">Title / Description (Free Search)</label>
<input
type="text"
id="title"
[ngModel]="criteria.title"
(ngModelChange)="updateCriteria({ title: $event })"
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. Restaurant"
/>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-neutral-900">Category</label>
<ng-select
class="custom"
[items]="selectOptions.typesOfBusiness"
bindLabel="name"
bindValue="value"
[ngModel]="criteria.types"
(ngModelChange)="onCategoryChange($event)"
[multiple]="true"
[closeOnSelect]="true"
placeholder="Select categories"
></ng-select>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-neutral-900">Type of Property</label>
<ng-select
class="custom"
[items]="propertyTypeOptions"
bindLabel="name"
bindValue="value"
[ngModel]="selectedPropertyType"
(ngModelChange)="onPropertyTypeChange($event)"
placeholder="Select property type"
></ng-select>
</div>
<div>
<label for="numberEmployees" class="block mb-2 text-sm font-medium text-neutral-900">Number of Employees</label>
<div class="flex items-center space-x-2">
<input
type="number"
id="numberEmployees-from"
[ngModel]="criteria.minNumberEmployees"
(ngModelChange)="updateCriteria({ minNumberEmployees: $event })"
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-1/2 p-2.5"
placeholder="From"
/>
<span>-</span>
<input
type="number"
id="numberEmployees-to"
[ngModel]="criteria.maxNumberEmployees"
(ngModelChange)="updateCriteria({ maxNumberEmployees: $event })"
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-1/2 p-2.5"
placeholder="To"
/>
</div>
</div>
<div>
<label for="establishedMin" class="block mb-2 text-sm font-medium text-neutral-900">Minimum years established</label>
<div class="flex items-center space-x-2">
<input
type="number"
id="establishedMin"
[ngModel]="criteria.establishedMin"
(ngModelChange)="updateCriteria({ establishedMin: $event })"
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-1/2 p-2.5"
placeholder="YY"
/>
</div>
</div>
<div>
<label for="brokername" class="block mb-2 text-sm font-medium text-neutral-900">Broker Name / Company Name</label>
<input
type="text"
id="brokername"
[ngModel]="criteria.brokerName"
(ngModelChange)="updateCriteria({ brokerName: $event })"
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. Brokers Invest"
/>
</div>
</div>
}
</div>
<div
*ngIf="isModal && (modalService.modalVisible$ | async)?.visible && (modalService.modalVisible$ | async)?.type === 'businessListings'"
class="fixed inset-0 bg-neutral-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50"
>
<div class="relative w-full max-h-full">
<div class="relative bg-white rounded-lg shadow">
<div class="flex items-start justify-between p-4 border-b rounded-t bg-primary-600">
<h3 class="text-xl font-semibold text-white p-2 rounded">Business Listing Search</h3>
<button (click)="closeAndSearch()" type="button" class="text-white bg-transparent hover:bg-neutral-200 hover:text-neutral-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-primary-600 font-medium border-b-2 border-primary-600 pb-2">Filter ({{ numberOfResults$ | async }})</button>
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-primary-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-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip">
Clear all Filter
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
<!-- Display active filters as tags -->
<div class="flex flex-wrap gap-2 mb-4" *ngIf="hasActiveFilters()">
<span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minPrice || criteria.maxPrice" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minRevenue || criteria.maxRevenue" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Revenue: {{ criteria.minRevenue || 'Any' }} - {{ criteria.maxRevenue || 'Any' }} <button (click)="removeFilter('revenue')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minCashFlow || criteria.maxCashFlow" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Cashflow: {{ criteria.minCashFlow || 'Any' }} - {{ criteria.maxCashFlow || 'Any' }} <button (click)="removeFilter('cashflow')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.types.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="selectedPropertyType" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Property Type: {{ getSelectedPropertyTypeName() }} <button (click)="removeFilter('propertyType')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minNumberEmployees || criteria.maxNumberEmployees" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Employees: {{ criteria.minNumberEmployees || 'Any' }} - {{ criteria.maxNumberEmployees || 'Any' }} <button (click)="removeFilter('employees')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.establishedMin" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Established: {{ criteria.establishedMin || 'Any' }} <button (click)="removeFilter('established')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
</div>
<div class="grid grid-cols-1 gap-6">
<div class="space-y-4">
<div>
<label for="state" class="block mb-2 text-sm font-medium text-neutral-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-neutral-900 font-medium" [state]="criteria.state"></app-validated-city>
</div>
<div *ngIf="criteria.city">
<label class="block mb-2 text-sm font-medium text-neutral-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" (ngModelChange)="updateCriteria({ searchType: $event })" 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" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
<span class="ml-2">Radius Search</span>
</label>
</div>
</div>
<div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2">
<label class="block mb-2 text-sm font-medium text-neutral-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-neutral-200 hover:bg-neutral-500 hover:text-white"
[ngClass]="criteria.radius === radius ? 'text-white bg-neutral-500' : 'text-neutral-900 bg-white'"
(click)="setRadius(radius)"
>
{{ radius }}
</button>
}
</div>
</div>
<div>
<label for="price" class="block mb-2 text-sm font-medium text-neutral-900">Price</label>
<div class="flex items-center space-x-2">
<app-validated-price name="price-from" [ngModel]="criteria.minPrice" (ngModelChange)="updateCriteria({ minPrice: $event })" placeholder="From" inputClasses="bg-neutral-50 text-sm !mt-0 p-2.5">
</app-validated-price>
<span>-</span>
<app-validated-price name="price-to" [ngModel]="criteria.maxPrice" (ngModelChange)="updateCriteria({ maxPrice: $event })" placeholder="To" inputClasses="bg-neutral-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-neutral-900">Sales Revenue</label>
<div class="flex items-center space-x-2">
<app-validated-price name="salesRevenue-from" [ngModel]="criteria.minRevenue" (ngModelChange)="updateCriteria({ minRevenue: $event })" placeholder="From" inputClasses="bg-neutral-50 text-sm !mt-0 p-2.5">
</app-validated-price>
<span>-</span>
<app-validated-price name="salesRevenue-to" [ngModel]="criteria.maxRevenue" (ngModelChange)="updateCriteria({ maxRevenue: $event })" placeholder="To" inputClasses="bg-neutral-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-neutral-900">Cashflow</label>
<div class="flex items-center space-x-2">
<app-validated-price name="cashflow-from" [ngModel]="criteria.minCashFlow" (ngModelChange)="updateCriteria({ minCashFlow: $event })" placeholder="From" inputClasses="bg-neutral-50 text-sm !mt-0 p-2.5">
</app-validated-price>
<span>-</span>
<app-validated-price name="cashflow-to" [ngModel]="criteria.maxCashFlow" (ngModelChange)="updateCriteria({ maxCashFlow: $event })" placeholder="To" inputClasses="bg-neutral-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-neutral-900">Title / Description (Free Search)</label>
<input
type="text"
id="title"
[ngModel]="criteria.title"
(ngModelChange)="updateCriteria({ title: $event })"
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. Restaurant"
/>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-neutral-900">Category</label>
<ng-select
class="custom"
[items]="selectOptions.typesOfBusiness"
bindLabel="name"
bindValue="value"
[ngModel]="criteria.types"
(ngModelChange)="onCategoryChange($event)"
[multiple]="true"
[closeOnSelect]="true"
placeholder="Select categories"
></ng-select>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-neutral-900">Type of Property</label>
<ng-select
class="custom"
[items]="propertyTypeOptions"
bindLabel="name"
bindValue="value"
[ngModel]="selectedPropertyType"
(ngModelChange)="onPropertyTypeChange($event)"
placeholder="Select property type"
></ng-select>
</div>
<div>
<label for="numberEmployees" class="block mb-2 text-sm font-medium text-neutral-900">Number of Employees</label>
<div class="flex items-center space-x-2">
<input
type="number"
id="numberEmployees-from"
[ngModel]="criteria.minNumberEmployees"
(ngModelChange)="updateCriteria({ minNumberEmployees: $event })"
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-1/2 p-2.5"
placeholder="From"
/>
<span>-</span>
<input
type="number"
id="numberEmployees-to"
[ngModel]="criteria.maxNumberEmployees"
(ngModelChange)="updateCriteria({ maxNumberEmployees: $event })"
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-1/2 p-2.5"
placeholder="To"
/>
</div>
</div>
<div>
<label for="establishedMin" class="block mb-2 text-sm font-medium text-neutral-900">Minimum years established</label>
<div class="flex items-center space-x-2">
<input
type="number"
id="establishedMin"
[ngModel]="criteria.establishedMin"
(ngModelChange)="updateCriteria({ establishedMin: $event })"
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-1/2 p-2.5"
placeholder="YY"
/>
</div>
</div>
<div>
<label for="brokername" class="block mb-2 text-sm font-medium text-neutral-900">Broker Name / Company Name</label>
<input
type="text"
id="brokername"
[ngModel]="criteria.brokerName"
(ngModelChange)="updateCriteria({ brokerName: $event })"
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. Brokers Invest"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ################################################################################## -->
<!-- ################################################################################## -->
<!-- ################################################################################## -->
<div *ngIf="!isModal" class="space-y-6">
<div class="flex space-x-4 mb-4">
<h3 class="text-xl font-semibold text-neutral-900">Filter ({{ numberOfResults$ | async }})</h3>
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-primary-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-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip">
Clear all Filter
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
<!-- Display active filters as tags -->
<div class="flex flex-wrap gap-2" *ngIf="hasActiveFilters()">
<span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minPrice || criteria.maxPrice" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minRevenue || criteria.maxRevenue" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Revenue: {{ criteria.minRevenue || 'Any' }} - {{ criteria.maxRevenue || 'Any' }} <button (click)="removeFilter('revenue')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minCashFlow || criteria.maxCashFlow" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Cashflow: {{ criteria.minCashFlow || 'Any' }} - {{ criteria.maxCashFlow || 'Any' }} <button (click)="removeFilter('cashflow')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.types.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="selectedPropertyType" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Property Type: {{ getSelectedPropertyTypeName() }} <button (click)="removeFilter('propertyType')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minNumberEmployees || criteria.maxNumberEmployees" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Employees: {{ criteria.minNumberEmployees || 'Any' }} - {{ criteria.maxNumberEmployees || 'Any' }} <button (click)="removeFilter('employees')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.establishedMin" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Years established: {{ criteria.establishedMin || 'Any' }} <button (click)="removeFilter('established')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
</div>
@if(criteria.criteriaType==='businessListings') {
<div class="space-y-4">
<div>
<label for="state" class="block mb-2 text-sm font-medium text-neutral-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-neutral-900 font-medium" [state]="criteria.state"></app-validated-city>
</div>
<div *ngIf="criteria.city">
<label class="block mb-2 text-sm font-medium text-neutral-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" (ngModelChange)="updateCriteria({ searchType: $event })" 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" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
<span class="ml-2">Radius Search</span>
</label>
</div>
</div>
<div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2">
<label class="block mb-2 text-sm font-medium text-neutral-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-neutral-200 hover:bg-neutral-500 hover:text-white"
[ngClass]="criteria.radius === radius ? 'text-white bg-neutral-500' : 'text-neutral-900 bg-white'"
(click)="setRadius(radius)"
>
{{ radius }}
</button>
}
</div>
</div>
<div>
<label for="price" class="block mb-2 text-sm font-medium text-neutral-900">Price</label>
<div class="flex items-center space-x-2">
<app-validated-price name="price-from" [ngModel]="criteria.minPrice" (ngModelChange)="updateCriteria({ minPrice: $event })" placeholder="From" inputClasses="bg-neutral-50 text-sm !mt-0 p-2.5"> </app-validated-price>
<span>-</span>
<app-validated-price name="price-to" [ngModel]="criteria.maxPrice" (ngModelChange)="updateCriteria({ maxPrice: $event })" placeholder="To" inputClasses="bg-neutral-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-neutral-900">Sales Revenue</label>
<div class="flex items-center space-x-2">
<app-validated-price name="salesRevenue-from" [ngModel]="criteria.minRevenue" (ngModelChange)="updateCriteria({ minRevenue: $event })" placeholder="From" inputClasses="bg-neutral-50 text-sm !mt-0 p-2.5">
</app-validated-price>
<span>-</span>
<app-validated-price name="salesRevenue-to" [ngModel]="criteria.maxRevenue" (ngModelChange)="updateCriteria({ maxRevenue: $event })" placeholder="To" inputClasses="bg-neutral-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-neutral-900">Cashflow</label>
<div class="flex items-center space-x-2">
<app-validated-price name="cashflow-from" [ngModel]="criteria.minCashFlow" (ngModelChange)="updateCriteria({ minCashFlow: $event })" placeholder="From" inputClasses="bg-neutral-50 text-sm !mt-0 p-2.5">
</app-validated-price>
<span>-</span>
<app-validated-price name="cashflow-to" [ngModel]="criteria.maxCashFlow" (ngModelChange)="updateCriteria({ maxCashFlow: $event })" placeholder="To" inputClasses="bg-neutral-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-neutral-900">Title / Description (Free Search)</label>
<input
type="text"
id="title"
[ngModel]="criteria.title"
(ngModelChange)="updateCriteria({ title: $event })"
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. Restaurant"
/>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-neutral-900">Category</label>
<ng-select
class="custom"
[items]="selectOptions.typesOfBusiness"
bindLabel="name"
bindValue="value"
[ngModel]="criteria.types"
(ngModelChange)="onCategoryChange($event)"
[multiple]="true"
[closeOnSelect]="true"
placeholder="Select categories"
></ng-select>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-neutral-900">Type of Property</label>
<ng-select
class="custom"
[items]="propertyTypeOptions"
bindLabel="name"
bindValue="value"
[ngModel]="selectedPropertyType"
(ngModelChange)="onPropertyTypeChange($event)"
placeholder="Select property type"
></ng-select>
</div>
<div>
<label for="numberEmployees" class="block mb-2 text-sm font-medium text-neutral-900">Number of Employees</label>
<div class="flex items-center space-x-2">
<input
type="number"
id="numberEmployees-from"
[ngModel]="criteria.minNumberEmployees"
(ngModelChange)="updateCriteria({ minNumberEmployees: $event })"
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-1/2 p-2.5"
placeholder="From"
/>
<span>-</span>
<input
type="number"
id="numberEmployees-to"
[ngModel]="criteria.maxNumberEmployees"
(ngModelChange)="updateCriteria({ maxNumberEmployees: $event })"
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-1/2 p-2.5"
placeholder="To"
/>
</div>
</div>
<div>
<label for="establishedMin" class="block mb-2 text-sm font-medium text-neutral-900">Minimum years established</label>
<div class="flex items-center space-x-2">
<input
type="number"
id="establishedMin"
[ngModel]="criteria.establishedMin"
(ngModelChange)="updateCriteria({ establishedMin: $event })"
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-1/2 p-2.5"
placeholder="YY"
/>
</div>
</div>
<div>
<label for="brokername" class="block mb-2 text-sm font-medium text-neutral-900">Broker Name / Company Name</label>
<input
type="text"
id="brokername"
[ngModel]="criteria.brokerName"
(ngModelChange)="updateCriteria({ brokerName: $event })"
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. Brokers Invest"
/>
</div>
</div>
}
</div>

View File

@@ -1,445 +1,445 @@
import { AsyncPipe, NgIf } from '@angular/common';
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { NgSelectModule } from '@ng-select/ng-select';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { catchError, concat, debounceTime, distinctUntilChanged, map, Observable, of, Subject, switchMap, takeUntil, tap } from 'rxjs';
import { BusinessListingCriteria, CountyResult, GeoResult, KeyValue, KeyValueStyle } from '../../../../../bizmatch-server/src/models/main.model';
import { FilterStateService } from '../../services/filter-state.service';
import { GeoService } from '../../services/geo.service';
import { ListingsService } from '../../services/listings.service';
import { SearchService } from '../../services/search.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { UserService } from '../../services/user.service';
import { SharedModule } from '../../shared/shared/shared.module';
import { ValidatedCityComponent } from '../validated-city/validated-city.component';
import { ValidatedPriceComponent } from '../validated-price/validated-price.component';
import { ModalService } from './modal.service';
@UntilDestroy()
@Component({
selector: 'app-search-modal',
standalone: true,
imports: [SharedModule, AsyncPipe, NgIf, NgSelectModule, ValidatedCityComponent, ValidatedPriceComponent],
templateUrl: './search-modal.component.html',
styleUrl: './search-modal.component.scss',
})
export class SearchModalComponent implements OnInit, OnDestroy {
@Input() isModal: boolean = true;
private destroy$ = new Subject<void>();
private searchDebounce$ = new Subject<void>();
// State
criteria: BusinessListingCriteria;
backupCriteria: any;
currentListingType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
// Geo search
counties$: Observable<CountyResult[]>;
countyLoading = false;
countyInput$ = new Subject<string>();
// Property type for business listings
selectedPropertyType: string | null = null;
propertyTypeOptions = [
{ name: 'Real Estate', value: 'realEstateChecked' },
{ name: 'Leased Location', value: 'leasedLocation' },
{ name: 'Franchise', value: 'franchiseResale' },
];
// Results count
numberOfResults$: Observable<number>;
constructor(
public selectOptions: SelectOptionsService,
public modalService: ModalService,
private geoService: GeoService,
private filterStateService: FilterStateService,
private listingService: ListingsService,
private userService: UserService,
private searchService: SearchService,
) {}
ngOnInit(): void {
// Load counties
this.loadCounties();
if (this.isModal) {
// Modal mode: Wait for messages from ModalService
this.modalService.message$.pipe(untilDestroyed(this)).subscribe(criteria => {
this.initializeWithCriteria(criteria);
});
this.modalService.modalVisible$.pipe(untilDestroyed(this)).subscribe(val => {
if (val.visible) {
// Reset pagination when modal opens
if (this.criteria) {
this.criteria.page = 1;
this.criteria.start = 0;
}
}
});
} else {
// Embedded mode: Determine type from route and subscribe to state
this.determineListingType();
this.subscribeToStateChanges();
}
// Setup debounced search
this.searchDebounce$.pipe(debounceTime(400), takeUntil(this.destroy$)).subscribe(() => this.triggerSearch());
}
private initializeWithCriteria(criteria: any): void {
this.criteria = criteria;
this.currentListingType = criteria?.criteriaType;
this.backupCriteria = JSON.parse(JSON.stringify(criteria));
this.updateSelectedPropertyType();
this.setTotalNumberOfResults();
}
private determineListingType(): void {
const url = window.location.pathname;
if (url.includes('businessListings')) {
this.currentListingType = 'businessListings';
} else if (url.includes('commercialPropertyListings')) {
this.currentListingType = 'commercialPropertyListings';
} else if (url.includes('brokerListings')) {
this.currentListingType = 'brokerListings';
}
}
private subscribeToStateChanges(): void {
if (!this.isModal && this.currentListingType) {
this.filterStateService
.getState$(this.currentListingType)
.pipe(takeUntil(this.destroy$))
.subscribe(state => {
this.criteria = { ...state.criteria };
this.updateSelectedPropertyType();
this.setTotalNumberOfResults();
});
}
}
private loadCounties(): void {
this.counties$ = concat(
of([]), // default items
this.countyInput$.pipe(
distinctUntilChanged(),
tap(() => (this.countyLoading = true)),
switchMap(term =>
this.geoService.findCountiesStartingWith(term).pipe(
catchError(() => of([])),
map(counties => counties.map(county => county.name)),
tap(() => (this.countyLoading = false)),
),
),
),
);
}
// Filter removal methods
removeFilter(filterType: string): void {
const updates: any = {};
switch (filterType) {
case 'state':
updates.state = null;
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
break;
case 'city':
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
break;
case 'price':
updates.minPrice = null;
updates.maxPrice = null;
break;
case 'revenue':
updates.minRevenue = null;
updates.maxRevenue = null;
break;
case 'cashflow':
updates.minCashFlow = null;
updates.maxCashFlow = null;
break;
case 'types':
updates.types = [];
break;
case 'propertyType':
updates.realEstateChecked = false;
updates.leasedLocation = false;
updates.franchiseResale = false;
this.selectedPropertyType = null;
break;
case 'employees':
updates.minNumberEmployees = null;
updates.maxNumberEmployees = null;
break;
case 'established':
updates.establishedMin = null;
break;
case 'brokerName':
updates.brokerName = null;
break;
case 'title':
updates.title = null;
break;
}
this.updateCriteria(updates);
}
// Category handling
onCategoryChange(selectedCategories: string[]): void {
this.updateCriteria({ types: selectedCategories });
}
categoryClicked(checked: boolean, value: string): void {
const types = [...(this.criteria.types || [])];
if (checked) {
if (!types.includes(value)) {
types.push(value);
}
} else {
const index = types.indexOf(value);
if (index > -1) {
types.splice(index, 1);
}
}
this.updateCriteria({ types });
}
// Property type handling (Business listings only)
onPropertyTypeChange(value: string): void {
const updates: any = {
realEstateChecked: false,
leasedLocation: false,
franchiseResale: false,
};
if (value) {
updates[value] = true;
}
this.selectedPropertyType = value;
this.updateCriteria(updates);
}
onCheckboxChange(checkbox: string, value: boolean): void {
const updates: any = {
realEstateChecked: false,
leasedLocation: false,
franchiseResale: false,
};
updates[checkbox] = value;
this.selectedPropertyType = value ? checkbox : null;
this.updateCriteria(updates);
}
// Location handling
setState(state: string): void {
const updates: any = { state };
if (!state) {
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
}
this.updateCriteria(updates);
}
setCity(city: any): void {
const updates: any = {};
if (city) {
updates.city = city;
updates.state = city.state;
// Automatically set radius to 50 miles and enable radius search
updates.searchType = 'radius';
updates.radius = 50;
} else {
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
}
this.updateCriteria(updates);
}
setRadius(radius: number): void {
this.updateCriteria({ radius });
}
onCriteriaChange(): void {
this.triggerSearch();
}
// Debounced search for text inputs
debouncedSearch(): void {
this.searchDebounce$.next();
}
// Clear all filters
clearFilter(): void {
if (this.isModal) {
// In modal: Reset locally
const defaultCriteria = this.getDefaultCriteria();
this.criteria = defaultCriteria;
this.updateSelectedPropertyType();
this.setTotalNumberOfResults();
} else {
// Embedded: Use state service
this.filterStateService.clearFilters(this.currentListingType);
}
}
// Modal-specific methods
closeAndSearch(): void {
if (this.isModal) {
// Save changes to state
this.filterStateService.setCriteria(this.currentListingType, this.criteria);
this.modalService.accept();
this.searchService.search(this.currentListingType);
}
}
close(): void {
if (this.isModal) {
// Discard changes
this.modalService.reject(this.backupCriteria);
}
}
// Helper methods
public updateCriteria(updates: any): void {
if (this.isModal) {
// In modal: Update locally only
this.criteria = { ...this.criteria, ...updates };
this.setTotalNumberOfResults();
} else {
// Embedded: Update through state service
this.filterStateService.updateCriteria(this.currentListingType, updates);
}
// Trigger search after update
this.debouncedSearch();
}
private triggerSearch(): void {
if (this.isModal) {
// In modal: Only update count
this.setTotalNumberOfResults();
} else {
// Embedded: Full search
this.searchService.search(this.currentListingType);
}
}
private updateSelectedPropertyType(): void {
if (this.currentListingType === 'businessListings') {
const businessCriteria = this.criteria as BusinessListingCriteria;
if (businessCriteria.realEstateChecked) {
this.selectedPropertyType = 'realEstateChecked';
} else if (businessCriteria.leasedLocation) {
this.selectedPropertyType = 'leasedLocation';
} else if (businessCriteria.franchiseResale) {
this.selectedPropertyType = 'franchiseResale';
} else {
this.selectedPropertyType = null;
}
}
}
private setTotalNumberOfResults(): void {
if (!this.criteria) return;
switch (this.currentListingType) {
case 'businessListings':
this.numberOfResults$ = this.listingService.getNumberOfListings('business', this.criteria);
break;
case 'commercialPropertyListings':
this.numberOfResults$ = this.listingService.getNumberOfListings('commercialProperty', this.criteria);
break;
case 'brokerListings':
this.numberOfResults$ = this.userService.getNumberOfBroker();
break;
}
}
private getDefaultCriteria(): any {
switch (this.currentListingType) {
case 'businessListings':
return this.filterStateService['createEmptyBusinessListingCriteria']();
case 'commercialPropertyListings':
return this.filterStateService['createEmptyCommercialPropertyListingCriteria']();
case 'brokerListings':
return this.filterStateService['createEmptyUserListingCriteria']();
}
}
hasActiveFilters(): boolean {
if (!this.criteria) return false;
// Check all possible filter properties
const hasBasicFilters = !!(this.criteria.state || this.criteria.city || this.criteria.types?.length);
// Check business-specific filters
if (this.currentListingType === 'businessListings') {
const bc = this.criteria as BusinessListingCriteria;
return (
hasBasicFilters ||
!!(
bc.minPrice ||
bc.maxPrice ||
bc.minRevenue ||
bc.maxRevenue ||
bc.minCashFlow ||
bc.maxCashFlow ||
bc.minNumberEmployees ||
bc.maxNumberEmployees ||
bc.establishedMin ||
bc.brokerName ||
bc.title ||
this.selectedPropertyType
)
);
}
// Check commercial property filters
// if (this.currentListingType === 'commercialPropertyListings') {
// const cc = this.criteria as CommercialPropertyListingCriteria;
// return hasBasicFilters || !!(cc.minPrice || cc.maxPrice || cc.title);
// }
// Check user/broker filters
// if (this.currentListingType === 'brokerListings') {
// const uc = this.criteria as UserListingCriteria;
// return hasBasicFilters || !!(uc.brokerName || uc.companyName || uc.counties?.length);
// }
return hasBasicFilters;
}
getSelectedPropertyTypeName(): string | null {
return this.selectedPropertyType ? this.propertyTypeOptions.find(opt => opt.value === this.selectedPropertyType)?.name || null : null;
}
isTypeOfBusinessClicked(v: KeyValueStyle): boolean {
return !!this.criteria.types?.find(t => t === v.value);
}
isTypeOfProfessionalClicked(v: KeyValue): boolean {
return !!this.criteria.types?.find(t => t === v.value);
}
trackByFn(item: GeoResult): any {
return item.id;
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}
import { AsyncPipe, NgIf } from '@angular/common';
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { NgSelectModule } from '@ng-select/ng-select';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { catchError, concat, debounceTime, distinctUntilChanged, map, Observable, of, Subject, switchMap, takeUntil, tap } from 'rxjs';
import { BusinessListingCriteria, CountyResult, GeoResult, KeyValue, KeyValueStyle } from '../../../../../bizmatch-server/src/models/main.model';
import { FilterStateService } from '../../services/filter-state.service';
import { GeoService } from '../../services/geo.service';
import { ListingsService } from '../../services/listings.service';
import { SearchService } from '../../services/search.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { UserService } from '../../services/user.service';
import { SharedModule } from '../../shared/shared/shared.module';
import { ValidatedCityComponent } from '../validated-city/validated-city.component';
import { ValidatedPriceComponent } from '../validated-price/validated-price.component';
import { ModalService } from './modal.service';
@UntilDestroy()
@Component({
selector: 'app-search-modal',
standalone: true,
imports: [SharedModule, AsyncPipe, NgIf, NgSelectModule, ValidatedCityComponent, ValidatedPriceComponent],
templateUrl: './search-modal.component.html',
styleUrl: './search-modal.component.scss',
})
export class SearchModalComponent implements OnInit, OnDestroy {
@Input() isModal: boolean = true;
private destroy$ = new Subject<void>();
private searchDebounce$ = new Subject<void>();
// State
criteria: BusinessListingCriteria;
backupCriteria: any;
currentListingType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
// Geo search
counties$: Observable<CountyResult[]>;
countyLoading = false;
countyInput$ = new Subject<string>();
// Property type for business listings
selectedPropertyType: string | null = null;
propertyTypeOptions = [
{ name: 'Real Estate', value: 'realEstateChecked' },
{ name: 'Leased Location', value: 'leasedLocation' },
{ name: 'Franchise', value: 'franchiseResale' },
];
// Results count
numberOfResults$: Observable<number>;
constructor(
public selectOptions: SelectOptionsService,
public modalService: ModalService,
private geoService: GeoService,
private filterStateService: FilterStateService,
private listingService: ListingsService,
private userService: UserService,
private searchService: SearchService,
) {}
ngOnInit(): void {
// Load counties
this.loadCounties();
if (this.isModal) {
// Modal mode: Wait for messages from ModalService
this.modalService.message$.pipe(untilDestroyed(this)).subscribe(criteria => {
this.initializeWithCriteria(criteria);
});
this.modalService.modalVisible$.pipe(untilDestroyed(this)).subscribe(val => {
if (val.visible) {
// Reset pagination when modal opens
if (this.criteria) {
this.criteria.page = 1;
this.criteria.start = 0;
}
}
});
} else {
// Embedded mode: Determine type from route and subscribe to state
this.determineListingType();
this.subscribeToStateChanges();
}
// Setup debounced search
this.searchDebounce$.pipe(debounceTime(400), takeUntil(this.destroy$)).subscribe(() => this.triggerSearch());
}
private initializeWithCriteria(criteria: any): void {
this.criteria = criteria;
this.currentListingType = criteria?.criteriaType;
this.backupCriteria = JSON.parse(JSON.stringify(criteria));
this.updateSelectedPropertyType();
this.setTotalNumberOfResults();
}
private determineListingType(): void {
const url = window.location.pathname;
if (url.includes('businessListings')) {
this.currentListingType = 'businessListings';
} else if (url.includes('commercialPropertyListings')) {
this.currentListingType = 'commercialPropertyListings';
} else if (url.includes('brokerListings')) {
this.currentListingType = 'brokerListings';
}
}
private subscribeToStateChanges(): void {
if (!this.isModal && this.currentListingType) {
this.filterStateService
.getState$(this.currentListingType)
.pipe(takeUntil(this.destroy$))
.subscribe(state => {
this.criteria = { ...state.criteria };
this.updateSelectedPropertyType();
this.setTotalNumberOfResults();
});
}
}
private loadCounties(): void {
this.counties$ = concat(
of([]), // default items
this.countyInput$.pipe(
distinctUntilChanged(),
tap(() => (this.countyLoading = true)),
switchMap(term =>
this.geoService.findCountiesStartingWith(term).pipe(
catchError(() => of([])),
map(counties => counties.map(county => county.name)),
tap(() => (this.countyLoading = false)),
),
),
),
);
}
// Filter removal methods
removeFilter(filterType: string): void {
const updates: any = {};
switch (filterType) {
case 'state':
updates.state = null;
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
break;
case 'city':
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
break;
case 'price':
updates.minPrice = null;
updates.maxPrice = null;
break;
case 'revenue':
updates.minRevenue = null;
updates.maxRevenue = null;
break;
case 'cashflow':
updates.minCashFlow = null;
updates.maxCashFlow = null;
break;
case 'types':
updates.types = [];
break;
case 'propertyType':
updates.realEstateChecked = false;
updates.leasedLocation = false;
updates.franchiseResale = false;
this.selectedPropertyType = null;
break;
case 'employees':
updates.minNumberEmployees = null;
updates.maxNumberEmployees = null;
break;
case 'established':
updates.establishedMin = null;
break;
case 'brokerName':
updates.brokerName = null;
break;
case 'title':
updates.title = null;
break;
}
this.updateCriteria(updates);
}
// Category handling
onCategoryChange(selectedCategories: string[]): void {
this.updateCriteria({ types: selectedCategories });
}
categoryClicked(checked: boolean, value: string): void {
const types = [...(this.criteria.types || [])];
if (checked) {
if (!types.includes(value)) {
types.push(value);
}
} else {
const index = types.indexOf(value);
if (index > -1) {
types.splice(index, 1);
}
}
this.updateCriteria({ types });
}
// Property type handling (Business listings only)
onPropertyTypeChange(value: string): void {
const updates: any = {
realEstateChecked: false,
leasedLocation: false,
franchiseResale: false,
};
if (value) {
updates[value] = true;
}
this.selectedPropertyType = value;
this.updateCriteria(updates);
}
onCheckboxChange(checkbox: string, value: boolean): void {
const updates: any = {
realEstateChecked: false,
leasedLocation: false,
franchiseResale: false,
};
updates[checkbox] = value;
this.selectedPropertyType = value ? checkbox : null;
this.updateCriteria(updates);
}
// Location handling
setState(state: string): void {
const updates: any = { state };
if (!state) {
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
}
this.updateCriteria(updates);
}
setCity(city: any): void {
const updates: any = {};
if (city) {
updates.city = city;
updates.state = city.state;
// Automatically set radius to 50 miles and enable radius search
updates.searchType = 'radius';
updates.radius = 50;
} else {
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
}
this.updateCriteria(updates);
}
setRadius(radius: number): void {
this.updateCriteria({ radius });
}
onCriteriaChange(): void {
this.triggerSearch();
}
// Debounced search for text inputs
debouncedSearch(): void {
this.searchDebounce$.next();
}
// Clear all filters
clearFilter(): void {
if (this.isModal) {
// In modal: Reset locally
const defaultCriteria = this.getDefaultCriteria();
this.criteria = defaultCriteria;
this.updateSelectedPropertyType();
this.setTotalNumberOfResults();
} else {
// Embedded: Use state service
this.filterStateService.clearFilters(this.currentListingType);
}
}
// Modal-specific methods
closeAndSearch(): void {
if (this.isModal) {
// Save changes to state
this.filterStateService.setCriteria(this.currentListingType, this.criteria);
this.modalService.accept();
this.searchService.search(this.currentListingType);
}
}
close(): void {
if (this.isModal) {
// Discard changes
this.modalService.reject(this.backupCriteria);
}
}
// Helper methods
public updateCriteria(updates: any): void {
if (this.isModal) {
// In modal: Update locally only
this.criteria = { ...this.criteria, ...updates };
this.setTotalNumberOfResults();
} else {
// Embedded: Update through state service
this.filterStateService.updateCriteria(this.currentListingType, updates);
}
// Trigger search after update
this.debouncedSearch();
}
private triggerSearch(): void {
if (this.isModal) {
// In modal: Only update count
this.setTotalNumberOfResults();
} else {
// Embedded: Full search
this.searchService.search(this.currentListingType);
}
}
private updateSelectedPropertyType(): void {
if (this.currentListingType === 'businessListings') {
const businessCriteria = this.criteria as BusinessListingCriteria;
if (businessCriteria.realEstateChecked) {
this.selectedPropertyType = 'realEstateChecked';
} else if (businessCriteria.leasedLocation) {
this.selectedPropertyType = 'leasedLocation';
} else if (businessCriteria.franchiseResale) {
this.selectedPropertyType = 'franchiseResale';
} else {
this.selectedPropertyType = null;
}
}
}
private setTotalNumberOfResults(): void {
if (!this.criteria) return;
switch (this.currentListingType) {
case 'businessListings':
this.numberOfResults$ = this.listingService.getNumberOfListings('business', this.criteria);
break;
case 'commercialPropertyListings':
this.numberOfResults$ = this.listingService.getNumberOfListings('commercialProperty', this.criteria);
break;
case 'brokerListings':
this.numberOfResults$ = this.userService.getNumberOfBroker();
break;
}
}
private getDefaultCriteria(): any {
switch (this.currentListingType) {
case 'businessListings':
return this.filterStateService['createEmptyBusinessListingCriteria']();
case 'commercialPropertyListings':
return this.filterStateService['createEmptyCommercialPropertyListingCriteria']();
case 'brokerListings':
return this.filterStateService['createEmptyUserListingCriteria']();
}
}
hasActiveFilters(): boolean {
if (!this.criteria) return false;
// Check all possible filter properties
const hasBasicFilters = !!(this.criteria.state || this.criteria.city || this.criteria.types?.length);
// Check business-specific filters
if (this.currentListingType === 'businessListings') {
const bc = this.criteria as BusinessListingCriteria;
return (
hasBasicFilters ||
!!(
bc.minPrice ||
bc.maxPrice ||
bc.minRevenue ||
bc.maxRevenue ||
bc.minCashFlow ||
bc.maxCashFlow ||
bc.minNumberEmployees ||
bc.maxNumberEmployees ||
bc.establishedMin ||
bc.brokerName ||
bc.title ||
this.selectedPropertyType
)
);
}
// Check commercial property filters
// if (this.currentListingType === 'commercialPropertyListings') {
// const cc = this.criteria as CommercialPropertyListingCriteria;
// return hasBasicFilters || !!(cc.minPrice || cc.maxPrice || cc.title);
// }
// Check user/broker filters
// if (this.currentListingType === 'brokerListings') {
// const uc = this.criteria as UserListingCriteria;
// return hasBasicFilters || !!(uc.brokerName || uc.companyName || uc.counties?.length);
// }
return hasBasicFilters;
}
getSelectedPropertyTypeName(): string | null {
return this.selectedPropertyType ? this.propertyTypeOptions.find(opt => opt.value === this.selectedPropertyType)?.name || null : null;
}
isTypeOfBusinessClicked(v: KeyValueStyle): boolean {
return !!this.criteria.types?.find(t => t === v.value);
}
isTypeOfProfessionalClicked(v: KeyValue): boolean {
return !!this.criteria.types?.find(t => t === v.value);
}
trackByFn(item: GeoResult): any {
return item.id;
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -1,24 +1,24 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-test-ssr',
standalone: true,
template: `
<div>
<h1>SSR Test Component</h1>
<p>If you see this, SSR is working!</p>
</div>
`,
styles: [`
div {
padding: 20px;
background: #f0f0f0;
}
h1 { color: green; }
`]
})
export class TestSsrComponent {
constructor() {
console.log('[SSR] TestSsrComponent constructor called');
}
}
import { Component } from '@angular/core';
@Component({
selector: 'app-test-ssr',
standalone: true,
template: `
<div>
<h1>SSR Test Component</h1>
<p>If you see this, SSR is working!</p>
</div>
`,
styles: [`
div {
padding: 20px;
background: #f0f0f0;
}
h1 { color: green; }
`]
})
export class TestSsrComponent {
constructor() {
console.log('[SSR] TestSsrComponent constructor called');
}
}

View File

@@ -1,46 +1,46 @@
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { Component, Input, SimpleChanges, PLATFORM_ID, inject } from '@angular/core';
@Component({
selector: 'app-tooltip',
standalone: true,
imports: [CommonModule],
templateUrl: './tooltip.component.html',
})
export class TooltipComponent {
@Input() id: string;
@Input() text: string;
@Input() isVisible: boolean = false;
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
ngOnInit() {
this.initializeTooltip();
}
ngOnChanges(changes: SimpleChanges) {
if (changes['isVisible']) {
this.updateTooltipVisibility();
}
}
private initializeTooltip() {
// Flowbite is now initialized once in AppComponent
}
private updateTooltipVisibility() {
if (!this.isBrowser) return;
const tooltipElement = document.getElementById(this.id);
if (tooltipElement) {
if (this.isVisible) {
tooltipElement.classList.remove('invisible', 'opacity-0');
tooltipElement.classList.add('visible', 'opacity-100');
} else {
tooltipElement.classList.remove('visible', 'opacity-100');
tooltipElement.classList.add('invisible', 'opacity-0');
}
}
}
}
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { Component, Input, SimpleChanges, PLATFORM_ID, inject } from '@angular/core';
@Component({
selector: 'app-tooltip',
standalone: true,
imports: [CommonModule],
templateUrl: './tooltip.component.html',
})
export class TooltipComponent {
@Input() id: string;
@Input() text: string;
@Input() isVisible: boolean = false;
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
ngOnInit() {
this.initializeTooltip();
}
ngOnChanges(changes: SimpleChanges) {
if (changes['isVisible']) {
this.updateTooltipVisibility();
}
}
private initializeTooltip() {
// Flowbite is now initialized once in AppComponent
}
private updateTooltipVisibility() {
if (!this.isBrowser) return;
const tooltipElement = document.getElementById(this.id);
if (tooltipElement) {
if (this.isVisible) {
tooltipElement.classList.remove('invisible', 'opacity-0');
tooltipElement.classList.add('visible', 'opacity-100');
} else {
tooltipElement.classList.remove('visible', 'opacity-100');
tooltipElement.classList.add('invisible', 'opacity-0');
}
}
}
}

View File

@@ -1,44 +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],
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);
}
}
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],
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

@@ -1,60 +1,60 @@
import { CommonModule } from '@angular/common';
import { Component, forwardRef, Input, OnInit, OnDestroy } from '@angular/core';
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
import { NgxCurrencyDirective } from 'ngx-currency';
import { Subject } from 'rxjs';
import { debounceTime, takeUntil } from 'rxjs/operators';
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 implements OnInit, OnDestroy {
@Input() inputClasses: string;
@Input() placeholder: string = '';
@Input() debounceTimeMs: number = 400; // Configurable debounce time in milliseconds
private inputChange$ = new Subject<any>();
private destroy$ = new Subject<void>();
constructor(validationMessagesService: ValidationMessagesService) {
super(validationMessagesService);
}
override ngOnInit(): void {
// Setup debounced onChange
this.inputChange$
.pipe(
debounceTime(this.debounceTimeMs),
takeUntil(this.destroy$)
)
.subscribe(value => {
this.value = value;
this.onChange(this.value);
});
}
override ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
onInputChange(event: Event): void {
const newValue = !event ? null : event;
// Send signal to Subject instead of calling onChange directly
this.inputChange$.next(newValue);
}
}
import { CommonModule } from '@angular/common';
import { Component, forwardRef, Input, OnInit, OnDestroy } from '@angular/core';
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
import { NgxCurrencyDirective } from 'ngx-currency';
import { Subject } from 'rxjs';
import { debounceTime, takeUntil } from 'rxjs/operators';
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 implements OnInit, OnDestroy {
@Input() inputClasses: string;
@Input() placeholder: string = '';
@Input() debounceTimeMs: number = 400; // Configurable debounce time in milliseconds
private inputChange$ = new Subject<any>();
private destroy$ = new Subject<void>();
constructor(validationMessagesService: ValidationMessagesService) {
super(validationMessagesService);
}
override ngOnInit(): void {
// Setup debounced onChange
this.inputChange$
.pipe(
debounceTime(this.debounceTimeMs),
takeUntil(this.destroy$)
)
.subscribe(value => {
this.value = value;
this.onChange(this.value);
});
}
override ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
onInputChange(event: Event): void {
const newValue = !event ? null : event;
// Send signal to Subject instead of calling onChange directly
this.inputChange$.next(newValue);
}
}

View File

@@ -1,90 +1,90 @@
import { Directive, ElementRef, Input, OnInit, Renderer2 } from '@angular/core';
@Directive({
selector: 'img[appLazyLoad]',
standalone: true
})
export class LazyLoadImageDirective implements OnInit {
@Input() appLazyLoad: string = '';
@Input() placeholder: string = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300"%3E%3Crect fill="%23f3f4f6" width="400" height="300"/%3E%3C/svg%3E';
private observer: IntersectionObserver | null = null;
constructor(
private el: ElementRef<HTMLImageElement>,
private renderer: Renderer2
) {}
ngOnInit() {
// Add loading="lazy" attribute for native lazy loading
this.renderer.setAttribute(this.el.nativeElement, 'loading', 'lazy');
// Set placeholder while image loads
if (this.placeholder) {
this.renderer.setAttribute(this.el.nativeElement, 'src', this.placeholder);
}
// Add a CSS class for styling during loading
this.renderer.addClass(this.el.nativeElement, 'lazy-loading');
// Use Intersection Observer for enhanced lazy loading
if ('IntersectionObserver' in window) {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.loadImage();
}
});
},
{
rootMargin: '50px' // Start loading 50px before image enters viewport
}
);
this.observer.observe(this.el.nativeElement);
} else {
// Fallback for browsers without Intersection Observer
this.loadImage();
}
}
private loadImage() {
const img = this.el.nativeElement;
const src = this.appLazyLoad || img.getAttribute('data-src');
if (src) {
// Create a new image to preload
const tempImg = new Image();
tempImg.onload = () => {
this.renderer.setAttribute(img, 'src', src);
this.renderer.removeClass(img, 'lazy-loading');
this.renderer.addClass(img, 'lazy-loaded');
// Disconnect observer after loading
if (this.observer) {
this.observer.disconnect();
}
};
tempImg.onerror = () => {
console.error('Failed to load image:', src);
this.renderer.removeClass(img, 'lazy-loading');
this.renderer.addClass(img, 'lazy-error');
if (this.observer) {
this.observer.disconnect();
}
};
tempImg.src = src;
}
}
ngOnDestroy() {
if (this.observer) {
this.observer.disconnect();
}
}
}
import { Directive, ElementRef, Input, OnInit, Renderer2 } from '@angular/core';
@Directive({
selector: 'img[appLazyLoad]',
standalone: true
})
export class LazyLoadImageDirective implements OnInit {
@Input() appLazyLoad: string = '';
@Input() placeholder: string = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300"%3E%3Crect fill="%23f3f4f6" width="400" height="300"/%3E%3C/svg%3E';
private observer: IntersectionObserver | null = null;
constructor(
private el: ElementRef<HTMLImageElement>,
private renderer: Renderer2
) {}
ngOnInit() {
// Add loading="lazy" attribute for native lazy loading
this.renderer.setAttribute(this.el.nativeElement, 'loading', 'lazy');
// Set placeholder while image loads
if (this.placeholder) {
this.renderer.setAttribute(this.el.nativeElement, 'src', this.placeholder);
}
// Add a CSS class for styling during loading
this.renderer.addClass(this.el.nativeElement, 'lazy-loading');
// Use Intersection Observer for enhanced lazy loading
if ('IntersectionObserver' in window) {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.loadImage();
}
});
},
{
rootMargin: '50px' // Start loading 50px before image enters viewport
}
);
this.observer.observe(this.el.nativeElement);
} else {
// Fallback for browsers without Intersection Observer
this.loadImage();
}
}
private loadImage() {
const img = this.el.nativeElement;
const src = this.appLazyLoad || img.getAttribute('data-src');
if (src) {
// Create a new image to preload
const tempImg = new Image();
tempImg.onload = () => {
this.renderer.setAttribute(img, 'src', src);
this.renderer.removeClass(img, 'lazy-loading');
this.renderer.addClass(img, 'lazy-loaded');
// Disconnect observer after loading
if (this.observer) {
this.observer.disconnect();
}
};
tempImg.onerror = () => {
console.error('Failed to load image:', src);
this.renderer.removeClass(img, 'lazy-loading');
this.renderer.addClass(img, 'lazy-error');
if (this.observer) {
this.observer.disconnect();
}
};
tempImg.src = src;
}
}
ngOnDestroy() {
if (this.observer) {
this.observer.disconnect();
}
}
}

View File

@@ -1,36 +1,36 @@
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;
const slug = response.slug || id;
if (category === 'business') {
this.router.navigate(['business', slug]);
} else if (category === 'commercialProperty') {
this.router.navigate(['commercial-property', slug]);
} else {
this.router.navigate(['not-found']);
}
}),
catchError(() => {
return of(this.router.createUrlTree(['/not-found']));
}),
);
}
}
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;
const slug = response.slug || id;
if (category === 'business') {
this.router.navigate(['business', slug]);
} else if (category === 'commercialProperty') {
this.router.navigate(['commercial-property', slug]);
} else {
this.router.navigate(['not-found']);
}
}),
catchError(() => {
return of(this.router.createUrlTree(['/not-found']));
}),
);
}
}

View File

@@ -1,150 +1,150 @@
import { Component, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { Control, DomEvent, DomUtil, icon, Icon, latLng, Layer, Map, MapOptions, Marker, tileLayer } from 'leaflet';
import { BusinessListing, CommercialPropertyListing } from '../../../../../bizmatch-server/src/models/db.model';
@Component({
selector: 'app-base-details',
template: ``,
standalone: true,
imports: [],
})
export abstract class BaseDetailsComponent {
// Leaflet-Map-Einstellungen
mapOptions: MapOptions;
mapLayers: Layer[] = [];
mapCenter: any;
mapZoom: number = 13;
protected listing: BusinessListing | CommercialPropertyListing;
protected isBrowser: boolean;
private platformId = inject(PLATFORM_ID);
constructor() {
this.isBrowser = isPlatformBrowser(this.platformId);
// Only initialize mapOptions in browser context
if (this.isBrowser) {
this.mapOptions = {
layers: [
tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
}),
],
zoom: this.mapZoom,
center: latLng(0, 0),
};
}
}
protected configureMap() {
if (!this.isBrowser) {
return; // Skip on server
}
const latitude = this.listing.location.latitude;
const longitude = this.listing.location.longitude;
if (latitude !== null && latitude !== undefined &&
longitude !== null && longitude !== undefined) {
this.mapCenter = latLng(latitude, longitude);
const addressParts = [];
if (this.listing.location.housenumber) addressParts.push(this.listing.location.housenumber);
if (this.listing.location.street) addressParts.push(this.listing.location.street);
if (this.listing.location.name) addressParts.push(this.listing.location.name);
else if (this.listing.location.county) addressParts.push(this.listing.location.county);
if (this.listing.location.state) addressParts.push(this.listing.location.state);
if (this.listing.location.zipCode) addressParts.push(this.listing.location.zipCode);
const fullAddress = addressParts.join(', ');
const marker = new Marker([latitude, longitude], {
icon: icon({
...Icon.Default.prototype.options,
iconUrl: 'assets/leaflet/marker-icon.png',
iconRetinaUrl: 'assets/leaflet/marker-icon-2x.png',
shadowUrl: 'assets/leaflet/marker-shadow.png',
}),
});
if (fullAddress) {
marker.bindPopup(`
<div style="padding: 8px;">
<strong>Location:</strong><br/>
${fullAddress}
</div>
`);
}
this.mapLayers = [
tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
}),
marker
];
this.mapOptions = {
...this.mapOptions,
center: this.mapCenter,
zoom: this.mapZoom,
};
}
}
onMapReady(map: Map) {
if (!this.isBrowser) {
return;
}
const addressParts = [];
if (this.listing.location.housenumber) addressParts.push(this.listing.location.housenumber);
if (this.listing.location.street) addressParts.push(this.listing.location.street);
if (this.listing.location.name) addressParts.push(this.listing.location.name);
else if (this.listing.location.county) addressParts.push(this.listing.location.county);
if (this.listing.location.state) addressParts.push(this.listing.location.state);
if (this.listing.location.zipCode) addressParts.push(this.listing.location.zipCode);
if (addressParts.length > 0) {
const addressControl = new Control({ position: 'topright' });
addressControl.onAdd = () => {
const container = DomUtil.create('div', 'address-control bg-white p-2 rounded shadow');
const address = addressParts.join(', ');
container.innerHTML = `
<div style="max-width: 250px;">
${address}<br/>
<a href="#" id="view-full-map" style="color: #2563eb; text-decoration: underline;">View larger map</a>
</div>
`;
DomEvent.disableClickPropagation(container);
const link = container.querySelector('#view-full-map') as HTMLElement;
if (link) {
DomEvent.on(link, 'click', (e: Event) => {
e.preventDefault();
this.openFullMap();
});
}
return container;
};
addressControl.addTo(map);
}
}
openFullMap() {
if (!this.isBrowser) {
return;
}
const latitude = this.listing.location.latitude;
const longitude = this.listing.location.longitude;
const url = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=15/${latitude}/${longitude}`;
window.open(url, '_blank');
}
}
import { Component, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { Control, DomEvent, DomUtil, icon, Icon, latLng, Layer, Map, MapOptions, Marker, tileLayer } from 'leaflet';
import { BusinessListing, CommercialPropertyListing } from '../../../../../bizmatch-server/src/models/db.model';
@Component({
selector: 'app-base-details',
template: ``,
standalone: true,
imports: [],
})
export abstract class BaseDetailsComponent {
// Leaflet-Map-Einstellungen
mapOptions: MapOptions;
mapLayers: Layer[] = [];
mapCenter: any;
mapZoom: number = 13;
protected listing: BusinessListing | CommercialPropertyListing;
protected isBrowser: boolean;
private platformId = inject(PLATFORM_ID);
constructor() {
this.isBrowser = isPlatformBrowser(this.platformId);
// Only initialize mapOptions in browser context
if (this.isBrowser) {
this.mapOptions = {
layers: [
tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
}),
],
zoom: this.mapZoom,
center: latLng(0, 0),
};
}
}
protected configureMap() {
if (!this.isBrowser) {
return; // Skip on server
}
const latitude = this.listing.location.latitude;
const longitude = this.listing.location.longitude;
if (latitude !== null && latitude !== undefined &&
longitude !== null && longitude !== undefined) {
this.mapCenter = latLng(latitude, longitude);
const addressParts = [];
if (this.listing.location.housenumber) addressParts.push(this.listing.location.housenumber);
if (this.listing.location.street) addressParts.push(this.listing.location.street);
if (this.listing.location.name) addressParts.push(this.listing.location.name);
else if (this.listing.location.county) addressParts.push(this.listing.location.county);
if (this.listing.location.state) addressParts.push(this.listing.location.state);
if (this.listing.location.zipCode) addressParts.push(this.listing.location.zipCode);
const fullAddress = addressParts.join(', ');
const marker = new Marker([latitude, longitude], {
icon: icon({
...Icon.Default.prototype.options,
iconUrl: 'assets/leaflet/marker-icon.png',
iconRetinaUrl: 'assets/leaflet/marker-icon-2x.png',
shadowUrl: 'assets/leaflet/marker-shadow.png',
}),
});
if (fullAddress) {
marker.bindPopup(`
<div style="padding: 8px;">
<strong>Location:</strong><br/>
${fullAddress}
</div>
`);
}
this.mapLayers = [
tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
}),
marker
];
this.mapOptions = {
...this.mapOptions,
center: this.mapCenter,
zoom: this.mapZoom,
};
}
}
onMapReady(map: Map) {
if (!this.isBrowser) {
return;
}
const addressParts = [];
if (this.listing.location.housenumber) addressParts.push(this.listing.location.housenumber);
if (this.listing.location.street) addressParts.push(this.listing.location.street);
if (this.listing.location.name) addressParts.push(this.listing.location.name);
else if (this.listing.location.county) addressParts.push(this.listing.location.county);
if (this.listing.location.state) addressParts.push(this.listing.location.state);
if (this.listing.location.zipCode) addressParts.push(this.listing.location.zipCode);
if (addressParts.length > 0) {
const addressControl = new Control({ position: 'topright' });
addressControl.onAdd = () => {
const container = DomUtil.create('div', 'address-control bg-white p-2 rounded shadow');
const address = addressParts.join(', ');
container.innerHTML = `
<div style="max-width: 250px;">
${address}<br/>
<a href="#" id="view-full-map" style="color: #2563eb; text-decoration: underline;">View larger map</a>
</div>
`;
DomEvent.disableClickPropagation(container);
const link = container.querySelector('#view-full-map') as HTMLElement;
if (link) {
DomEvent.on(link, 'click', (e: Event) => {
e.preventDefault();
this.openFullMap();
});
}
return container;
};
addressControl.addTo(map);
}
}
openFullMap() {
if (!this.isBrowser) {
return;
}
const latitude = this.listing.location.latitude;
const longitude = this.listing.location.longitude;
const url = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=15/${latitude}/${longitude}`;
window.open(url, '_blank');
}
}

View File

@@ -1,223 +1,223 @@
<div class="container mx-auto p-4">
<!-- Breadcrumbs for SEO and Navigation -->
@if(breadcrumbs.length > 0) {
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
}
<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 break-words">{{ listing.title }}</h1>
<p class="mb-4 break-words" [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-neutral-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 break-words" *ngIf="!detail.isHtml && !detail.isListingBy">{{ detail.value
}}</div>
<div class="w-full sm:w-2/3 p-2 flex space-x-2 break-words" [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 && listingUser">
<a routerLink="/details-user/{{ listingUser.id }}"
class="text-primary-600 dark:text-primary-500 hover:underline">{{ listingUser.firstname }} {{
listingUser.lastname }}</a>
<div class="relative w-[100px] h-[30px] mr-5 lg:mb-0" *ngIf="listing.imageName">
<img ngSrc="{{ env.imageBaseUrl }}/pictures/logo/{{ listing.imageName }}.avif?_ts={{ ts }}" fill
class="object-contain"
alt="Business logo for {{ listingUser.firstname }} {{ listingUser.lastname }}" />
</div>
</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)="toggleFavorite()">
<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>
<div class="inline">
<button type="button"
class="share share-facebook text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToFacebook()">
<i class="fab fa-facebook"></i>
<span class="ml-2">Facebook</span>
</button>
</div>
<div class="inline">
<button type="button"
class="share share-twitter text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToTwitter()">
<i class="fab fa-x-twitter"></i>
<span class="ml-2">X</span>
</button>
</div>
<div class="inline">
<button type="button"
class="share share-linkedin text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToLinkedIn()">
<i class="fab fa-linkedin"></i>
<span class="ml-2">LinkedIn</span>
</button>
</div>
</div>
<!-- Karte hinzufügen, wenn Straße vorhanden ist -->
<div *ngIf="listing.location.latitude && listing.location.longitude" class="mt-6">
<h2 class="text-xl font-semibold mb-2">Location Map</h2>
<!-- <div style="height: 300px" leaflet [leafletOptions]="mapOptions" [leafletLayers]="mapLayers" [leafletCenter]="mapCenter" [leafletZoom]="mapZoom"></div> -->
<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="md:mt-8 mb-4 text-xl font-bold">Contact the Author of this Listing</h2>
<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-primary-600 text-white rounded-md hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-opacity-50">Submit</button>
</form>
</div>
</div>
}
</div>
<!-- FAQ Section for AEO (Answer Engine Optimization) -->
@if(businessFAQs && businessFAQs.length > 0) {
<div class="container mx-auto p-4 mt-8">
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6">
<h2 class="text-2xl font-bold mb-6 text-gray-900">Frequently Asked Questions</h2>
<div class="space-y-4">
@for (faq of businessFAQs; track $index) {
<details
class="group border border-gray-200 rounded-lg overflow-hidden hover:border-primary-300 transition-colors">
<summary
class="flex items-center justify-between cursor-pointer p-4 bg-gray-50 hover:bg-gray-100 transition-colors">
<h3 class="text-lg font-semibold text-gray-900">{{ faq.question }}</h3>
<svg class="w-5 h-5 text-gray-600 group-open:rotate-180 transition-transform" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</summary>
<div class="p-4 bg-white border-t border-gray-200">
<p class="text-gray-700 leading-relaxed">{{ faq.answer }}</p>
</div>
</details>
}
</div>
<div class="mt-6 p-4 bg-primary-50 border-l-4 border-primary-500 rounded">
<p class="text-sm text-gray-700">
<strong class="text-primary-700">Have more questions?</strong> Contact the seller directly using the form
above or reach out to our support team for assistance.
</p>
</div>
</div>
</div>
}
<!-- Related Listings Section for SEO Internal Linking -->
@if(relatedListings && relatedListings.length > 0) {
<div class="container mx-auto p-4 mt-8">
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6">
<h2 class="text-2xl font-bold mb-6 text-gray-900">Similar Businesses You May Like</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
@for (related of relatedListings; track related.id) {
<a [routerLink]="['/business', related.slug || related.id]" class="block group">
<div
class="bg-white rounded-lg border border-gray-200 overflow-hidden hover:shadow-xl transition-all duration-300 hover:scale-[1.02]">
<div class="p-4">
<div class="flex items-center mb-3">
<i [class]="selectOptions.getIconAndTextColorType(related.type)" class="mr-2 text-lg"></i>
<span [class]="selectOptions.getTextColorType(related.type)" class="font-semibold text-sm">{{
selectOptions.getBusiness(related.type) }}</span>
</div>
<h3
class="text-lg font-bold mb-2 text-gray-900 group-hover:text-primary-600 transition-colors line-clamp-2">
{{ related.title }}</h3>
<div class="space-y-1 text-sm text-gray-600">
<div class="flex justify-between">
<span class="font-medium">Price:</span>
<span class="font-bold text-primary-600">${{ related.price?.toLocaleString() || 'Contact' }}</span>
</div>
@if(related.salesRevenue) {
<div class="flex justify-between">
<span class="font-medium">Revenue:</span>
<span>${{ related.salesRevenue?.toLocaleString() }}</span>
</div>
}
<div class="flex justify-between">
<span class="font-medium">Location:</span>
<span>{{ related.location.name || related.location.county }}, {{
selectOptions.getState(related.location.state) }}</span>
</div>
</div>
<div class="mt-4">
<span
class="inline-block bg-primary-100 text-primary-800 text-xs font-medium px-2.5 py-0.5 rounded">View
Details →</span>
</div>
</div>
</div>
</a>
}
</div>
</div>
</div>
}
<div class="container mx-auto p-4">
<!-- Breadcrumbs for SEO and Navigation -->
@if(breadcrumbs.length > 0) {
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
}
<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 break-words">{{ listing.title }}</h1>
<p class="mb-4 break-words" [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-neutral-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 break-words" *ngIf="!detail.isHtml && !detail.isListingBy">{{ detail.value
}}</div>
<div class="w-full sm:w-2/3 p-2 flex space-x-2 break-words" [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 && listingUser">
<a routerLink="/details-user/{{ listingUser.id }}"
class="text-primary-600 dark:text-primary-500 hover:underline">{{ listingUser.firstname }} {{
listingUser.lastname }}</a>
<div class="relative w-[100px] h-[30px] mr-5 lg:mb-0" *ngIf="listing.imageName">
<img ngSrc="{{ env.imageBaseUrl }}/pictures/logo/{{ listing.imageName }}.avif?_ts={{ ts }}" fill
class="object-contain"
alt="Business logo for {{ listingUser.firstname }} {{ listingUser.lastname }}" />
</div>
</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)="toggleFavorite()">
<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>
<div class="inline">
<button type="button"
class="share share-facebook text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToFacebook()">
<i class="fab fa-facebook"></i>
<span class="ml-2">Facebook</span>
</button>
</div>
<div class="inline">
<button type="button"
class="share share-twitter text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToTwitter()">
<i class="fab fa-x-twitter"></i>
<span class="ml-2">X</span>
</button>
</div>
<div class="inline">
<button type="button"
class="share share-linkedin text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToLinkedIn()">
<i class="fab fa-linkedin"></i>
<span class="ml-2">LinkedIn</span>
</button>
</div>
</div>
<!-- Karte hinzufügen, wenn Straße vorhanden ist -->
<div *ngIf="listing.location.latitude && listing.location.longitude" class="mt-6">
<h2 class="text-xl font-semibold mb-2">Location Map</h2>
<!-- <div style="height: 300px" leaflet [leafletOptions]="mapOptions" [leafletLayers]="mapLayers" [leafletCenter]="mapCenter" [leafletZoom]="mapZoom"></div> -->
<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="md:mt-8 mb-4 text-xl font-bold">Contact the Author of this Listing</h2>
<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-primary-600 text-white rounded-md hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-opacity-50">Submit</button>
</form>
</div>
</div>
}
</div>
<!-- FAQ Section for AEO (Answer Engine Optimization) -->
@if(businessFAQs && businessFAQs.length > 0) {
<div class="container mx-auto p-4 mt-8">
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6">
<h2 class="text-2xl font-bold mb-6 text-gray-900">Frequently Asked Questions</h2>
<div class="space-y-4">
@for (faq of businessFAQs; track $index) {
<details
class="group border border-gray-200 rounded-lg overflow-hidden hover:border-primary-300 transition-colors">
<summary
class="flex items-center justify-between cursor-pointer p-4 bg-gray-50 hover:bg-gray-100 transition-colors">
<h3 class="text-lg font-semibold text-gray-900">{{ faq.question }}</h3>
<svg class="w-5 h-5 text-gray-600 group-open:rotate-180 transition-transform" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</summary>
<div class="p-4 bg-white border-t border-gray-200">
<p class="text-gray-700 leading-relaxed">{{ faq.answer }}</p>
</div>
</details>
}
</div>
<div class="mt-6 p-4 bg-primary-50 border-l-4 border-primary-500 rounded">
<p class="text-sm text-gray-700">
<strong class="text-primary-700">Have more questions?</strong> Contact the seller directly using the form
above or reach out to our support team for assistance.
</p>
</div>
</div>
</div>
}
<!-- Related Listings Section for SEO Internal Linking -->
@if(relatedListings && relatedListings.length > 0) {
<div class="container mx-auto p-4 mt-8">
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6">
<h2 class="text-2xl font-bold mb-6 text-gray-900">Similar Businesses You May Like</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
@for (related of relatedListings; track related.id) {
<a [routerLink]="['/business', related.slug || related.id]" class="block group">
<div
class="bg-white rounded-lg border border-gray-200 overflow-hidden hover:shadow-xl transition-all duration-300 hover:scale-[1.02]">
<div class="p-4">
<div class="flex items-center mb-3">
<i [class]="selectOptions.getIconAndTextColorType(related.type)" class="mr-2 text-lg"></i>
<span [class]="selectOptions.getTextColorType(related.type)" class="font-semibold text-sm">{{
selectOptions.getBusiness(related.type) }}</span>
</div>
<h3
class="text-lg font-bold mb-2 text-gray-900 group-hover:text-primary-600 transition-colors line-clamp-2">
{{ related.title }}</h3>
<div class="space-y-1 text-sm text-gray-600">
<div class="flex justify-between">
<span class="font-medium">Price:</span>
<span class="font-bold text-primary-600">${{ related.price?.toLocaleString() || 'Contact' }}</span>
</div>
@if(related.salesRevenue) {
<div class="flex justify-between">
<span class="font-medium">Revenue:</span>
<span>${{ related.salesRevenue?.toLocaleString() }}</span>
</div>
}
<div class="flex justify-between">
<span class="font-medium">Location:</span>
<span>{{ related.location.name || related.location.county }}, {{
selectOptions.getState(related.location.state) }}</span>
</div>
</div>
<div class="mt-4">
<span
class="inline-block bg-primary-100 text-primary-800 text-xs font-medium px-2.5 py-0.5 rounded">View
Details →</span>
</div>
</div>
</div>
</a>
}
</div>
</div>
</div>
}
</div>

View File

@@ -1,231 +1,231 @@
<div class="container mx-auto p-4">
<!-- Breadcrumbs for SEO and Navigation -->
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
<div class="bg-white drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg rounded-lg overflow-hidden">
@if(listing){
<div class="p-6 relative">
<h1 class="text-3xl font-bold mb-4 break-words">{{ listing?.title }}</h1>
<button (click)="historyService.goBack()"
class="print:hidden 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">
<i class="fas fa-times"></i>
</button>
<div class="flex flex-col lg:flex-row">
<div class="w-full lg:w-1/2 pr-0 lg:pr-4">
<p class="mb-4 break-words" [innerHTML]="description"></p>
<div class="space-y-2">
<div *ngFor="let detail of propertyDetails; let i = index" class="flex flex-col sm:flex-row"
[ngClass]="{ 'bg-neutral-100': i % 2 === 0 }">
<div class="w-full sm:w-1/3 font-semibold p-2">{{ detail.label }}</div>
<!-- Standard Text -->
<div class="w-full sm:w-2/3 p-2 break-words" *ngIf="!detail.isHtml && !detail.isListingBy">{{ detail.value
}}</div>
<!-- HTML Content (nicht für RouterLink) -->
<div class="w-full sm:w-2/3 p-2 flex space-x-2 break-words" [innerHTML]="detail.value"
*ngIf="detail.isHtml && !detail.isListingBy"></div>
<!-- Speziell für Listing By mit RouterLink -->
<div class="w-full sm:w-2/3 p-2 flex space-x-2" *ngIf="detail.isListingBy && detail.user">
<a [routerLink]="['/details-user', detail.user.id]"
class="text-primary-600 dark:text-primary-500 hover:underline"> {{ detail.user.firstname }} {{
detail.user.lastname }} </a>
<div class="relative w-[100px] h-[30px] mr-5 lg:mb-0" *ngIf="detail.user.hasCompanyLogo">
<img [ngSrc]="detail.imageBaseUrl + '/pictures/logo/' + detail.imagePath + '.avif?_ts=' + detail.ts"
fill class="object-contain"
alt="Company logo for {{ detail.user.firstname }} {{ detail.user.lastname }}" />
</div>
</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]="['/editCommercialPropertyListing', 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)="toggleFavorite()">
<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>
<div class="inline">
<button type="button"
class="share share-facebook text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToFacebook()">
<i class="fab fa-facebook"></i>
<span class="ml-2">Facebook</span>
</button>
</div>
<div class="inline">
<button type="button"
class="share share-twitter text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToTwitter()">
<i class="fab fa-x-twitter"></i>
<span class="ml-2">X</span>
</button>
</div>
<div class="inline">
<button type="button"
class="share share-linkedin text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToLinkedIn()">
<i class="fab fa-linkedin"></i>
<span class="ml-2">LinkedIn</span>
</button>
</div>
</div>
<!-- Karte hinzufügen, wenn Straße vorhanden ist -->
<div *ngIf="listing.location.latitude && listing.location.longitude" class="mt-6">
<h2 class="text-lg font-semibold mb-2">Location Map</h2>
<!-- <div style="height: 300px" leaflet [leafletOptions]="mapOptions" [leafletLayers]="mapLayers" [leafletCenter]="mapCenter" [leafletZoom]="mapZoom"></div> -->
<div style="height: 400px" leaflet [leafletOptions]="mapOptions" [leafletLayers]="mapLayers"
[leafletCenter]="mapCenter" [leafletZoom]="mapZoom" (leafletMapReady)="onMapReady($event)"></div>
</div>
</div>
<div class="w-full lg:w-1/2 mt-6 lg:mt-0">
@if(this.images.length>0){
<div class="block print:hidden">
<gallery [items]="images"></gallery>
</div>
}
<div class="print:hidden" [ngClass]="{ 'mt-6': this.images.length > 0 }">
@if(this.images.length>0){
<h2 class="text-xl font-semibold">Contact the Author of this Listing</h2>
}@else {
<div class="text-2xl font-bold mb-4">Contact the Author of this Listing</div>
}
<p class="text-sm text-neutral-600 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>
<div class="flex items-center justify-between">
<button (click)="mail()"
class="bg-primary-500 text-white px-4 py-2 rounded hover:bg-primary-600">Submit</button>
</div>
</form>
</div>
</div>
</div>
</div>
}
</div>
<!-- FAQ Section for AEO (Answer Engine Optimization) -->
@if(propertyFAQs && propertyFAQs.length > 0) {
<div class="container mx-auto p-4 mt-8">
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6">
<h2 class="text-2xl font-bold mb-6 text-gray-900">Frequently Asked Questions</h2>
<div class="space-y-4">
@for (faq of propertyFAQs; track $index) {
<details
class="group border border-gray-200 rounded-lg overflow-hidden hover:border-primary-300 transition-colors">
<summary
class="flex items-center justify-between cursor-pointer p-4 bg-gray-50 hover:bg-gray-100 transition-colors">
<h3 class="text-lg font-semibold text-gray-900">{{ faq.question }}</h3>
<svg class="w-5 h-5 text-gray-600 group-open:rotate-180 transition-transform" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</summary>
<div class="p-4 bg-white border-t border-gray-200">
<p class="text-gray-700 leading-relaxed">{{ faq.answer }}</p>
</div>
</details>
}
</div>
<div class="mt-6 p-4 bg-primary-50 border-l-4 border-primary-500 rounded">
<p class="text-sm text-gray-700">
<strong class="text-primary-700">Have more questions?</strong> Contact the seller directly using the form
above or reach out to our support team for assistance.
</p>
</div>
</div>
</div>
}
<!-- Related Listings Section for SEO Internal Linking -->
@if(relatedListings && relatedListings.length > 0) {
<div class="container mx-auto p-4 mt-8">
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6">
<h2 class="text-2xl font-bold mb-6 text-gray-900">Similar Properties You May Like</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
@for (related of relatedListings; track related.id) {
<a [routerLink]="['/commercial-property', related.slug || related.id]" class="block group">
<div
class="bg-white rounded-lg border border-gray-200 overflow-hidden hover:shadow-xl transition-all duration-300 hover:scale-[1.02]">
<div class="p-4">
<div class="flex items-center mb-3">
<i [class]="selectOptions.getIconAndTextColorType(related.type)" class="mr-2 text-lg"></i>
<span [class]="selectOptions.getTextColorType(related.type)" class="font-semibold text-sm">{{
selectOptions.getCommercialProperty(related.type) }}</span>
</div>
<h3
class="text-lg font-bold mb-2 text-gray-900 group-hover:text-primary-600 transition-colors line-clamp-2">
{{ related.title }}</h3>
<div class="space-y-1 text-sm text-gray-600">
<div class="flex justify-between">
<span class="font-medium">Price:</span>
<span class="font-bold text-primary-600">${{ related.price?.toLocaleString() || 'Contact' }}</span>
</div>
<div class="flex justify-between">
<span class="font-medium">Location:</span>
<span>{{ related.location.name || related.location.county }}, {{
selectOptions.getState(related.location.state) }}</span>
</div>
</div>
<div class="mt-4">
<span
class="inline-block bg-primary-100 text-primary-800 text-xs font-medium px-2.5 py-0.5 rounded">View
Details →</span>
</div>
</div>
</div>
</a>
}
</div>
</div>
</div>
}
<div class="container mx-auto p-4">
<!-- Breadcrumbs for SEO and Navigation -->
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
<div class="bg-white drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg rounded-lg overflow-hidden">
@if(listing){
<div class="p-6 relative">
<h1 class="text-3xl font-bold mb-4 break-words">{{ listing?.title }}</h1>
<button (click)="historyService.goBack()"
class="print:hidden 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">
<i class="fas fa-times"></i>
</button>
<div class="flex flex-col lg:flex-row">
<div class="w-full lg:w-1/2 pr-0 lg:pr-4">
<p class="mb-4 break-words" [innerHTML]="description"></p>
<div class="space-y-2">
<div *ngFor="let detail of propertyDetails; let i = index" class="flex flex-col sm:flex-row"
[ngClass]="{ 'bg-neutral-100': i % 2 === 0 }">
<div class="w-full sm:w-1/3 font-semibold p-2">{{ detail.label }}</div>
<!-- Standard Text -->
<div class="w-full sm:w-2/3 p-2 break-words" *ngIf="!detail.isHtml && !detail.isListingBy">{{ detail.value
}}</div>
<!-- HTML Content (nicht für RouterLink) -->
<div class="w-full sm:w-2/3 p-2 flex space-x-2 break-words" [innerHTML]="detail.value"
*ngIf="detail.isHtml && !detail.isListingBy"></div>
<!-- Speziell für Listing By mit RouterLink -->
<div class="w-full sm:w-2/3 p-2 flex space-x-2" *ngIf="detail.isListingBy && detail.user">
<a [routerLink]="['/details-user', detail.user.id]"
class="text-primary-600 dark:text-primary-500 hover:underline"> {{ detail.user.firstname }} {{
detail.user.lastname }} </a>
<div class="relative w-[100px] h-[30px] mr-5 lg:mb-0" *ngIf="detail.user.hasCompanyLogo">
<img [ngSrc]="detail.imageBaseUrl + '/pictures/logo/' + detail.imagePath + '.avif?_ts=' + detail.ts"
fill class="object-contain"
alt="Company logo for {{ detail.user.firstname }} {{ detail.user.lastname }}" />
</div>
</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]="['/editCommercialPropertyListing', 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)="toggleFavorite()">
<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>
<div class="inline">
<button type="button"
class="share share-facebook text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToFacebook()">
<i class="fab fa-facebook"></i>
<span class="ml-2">Facebook</span>
</button>
</div>
<div class="inline">
<button type="button"
class="share share-twitter text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToTwitter()">
<i class="fab fa-x-twitter"></i>
<span class="ml-2">X</span>
</button>
</div>
<div class="inline">
<button type="button"
class="share share-linkedin text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToLinkedIn()">
<i class="fab fa-linkedin"></i>
<span class="ml-2">LinkedIn</span>
</button>
</div>
</div>
<!-- Karte hinzufügen, wenn Straße vorhanden ist -->
<div *ngIf="listing.location.latitude && listing.location.longitude" class="mt-6">
<h2 class="text-lg font-semibold mb-2">Location Map</h2>
<!-- <div style="height: 300px" leaflet [leafletOptions]="mapOptions" [leafletLayers]="mapLayers" [leafletCenter]="mapCenter" [leafletZoom]="mapZoom"></div> -->
<div style="height: 400px" leaflet [leafletOptions]="mapOptions" [leafletLayers]="mapLayers"
[leafletCenter]="mapCenter" [leafletZoom]="mapZoom" (leafletMapReady)="onMapReady($event)"></div>
</div>
</div>
<div class="w-full lg:w-1/2 mt-6 lg:mt-0">
@if(this.images.length>0){
<div class="block print:hidden">
<gallery [items]="images"></gallery>
</div>
}
<div class="print:hidden" [ngClass]="{ 'mt-6': this.images.length > 0 }">
@if(this.images.length>0){
<h2 class="text-xl font-semibold">Contact the Author of this Listing</h2>
}@else {
<div class="text-2xl font-bold mb-4">Contact the Author of this Listing</div>
}
<p class="text-sm text-neutral-600 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>
<div class="flex items-center justify-between">
<button (click)="mail()"
class="bg-primary-500 text-white px-4 py-2 rounded hover:bg-primary-600">Submit</button>
</div>
</form>
</div>
</div>
</div>
</div>
}
</div>
<!-- FAQ Section for AEO (Answer Engine Optimization) -->
@if(propertyFAQs && propertyFAQs.length > 0) {
<div class="container mx-auto p-4 mt-8">
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6">
<h2 class="text-2xl font-bold mb-6 text-gray-900">Frequently Asked Questions</h2>
<div class="space-y-4">
@for (faq of propertyFAQs; track $index) {
<details
class="group border border-gray-200 rounded-lg overflow-hidden hover:border-primary-300 transition-colors">
<summary
class="flex items-center justify-between cursor-pointer p-4 bg-gray-50 hover:bg-gray-100 transition-colors">
<h3 class="text-lg font-semibold text-gray-900">{{ faq.question }}</h3>
<svg class="w-5 h-5 text-gray-600 group-open:rotate-180 transition-transform" fill="none"
stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</summary>
<div class="p-4 bg-white border-t border-gray-200">
<p class="text-gray-700 leading-relaxed">{{ faq.answer }}</p>
</div>
</details>
}
</div>
<div class="mt-6 p-4 bg-primary-50 border-l-4 border-primary-500 rounded">
<p class="text-sm text-gray-700">
<strong class="text-primary-700">Have more questions?</strong> Contact the seller directly using the form
above or reach out to our support team for assistance.
</p>
</div>
</div>
</div>
}
<!-- Related Listings Section for SEO Internal Linking -->
@if(relatedListings && relatedListings.length > 0) {
<div class="container mx-auto p-4 mt-8">
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6">
<h2 class="text-2xl font-bold mb-6 text-gray-900">Similar Properties You May Like</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
@for (related of relatedListings; track related.id) {
<a [routerLink]="['/commercial-property', related.slug || related.id]" class="block group">
<div
class="bg-white rounded-lg border border-gray-200 overflow-hidden hover:shadow-xl transition-all duration-300 hover:scale-[1.02]">
<div class="p-4">
<div class="flex items-center mb-3">
<i [class]="selectOptions.getIconAndTextColorType(related.type)" class="mr-2 text-lg"></i>
<span [class]="selectOptions.getTextColorType(related.type)" class="font-semibold text-sm">{{
selectOptions.getCommercialProperty(related.type) }}</span>
</div>
<h3
class="text-lg font-bold mb-2 text-gray-900 group-hover:text-primary-600 transition-colors line-clamp-2">
{{ related.title }}</h3>
<div class="space-y-1 text-sm text-gray-600">
<div class="flex justify-between">
<span class="font-medium">Price:</span>
<span class="font-bold text-primary-600">${{ related.price?.toLocaleString() || 'Contact' }}</span>
</div>
<div class="flex justify-between">
<span class="font-medium">Location:</span>
<span>{{ related.location.name || related.location.county }}, {{
selectOptions.getState(related.location.state) }}</span>
</div>
</div>
<div class="mt-4">
<span
class="inline-block bg-primary-100 text-primary-800 text-xs font-medium px-2.5 py-0.5 rounded">View
Details →</span>
</div>
</div>
</div>
</a>
}
</div>
</div>
</div>
}
</div>

View File

@@ -1,388 +1,388 @@
import { ChangeDetectorRef, Component, NgZone } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { LeafletModule } from '@bluehalo/ngx-leaflet';
import { faTimes } from '@fortawesome/free-solid-svg-icons';
import dayjs from 'dayjs';
import { GalleryModule, ImageItem } from 'ng-gallery';
import { lastValueFrom } from 'rxjs';
import { CommercialPropertyListing, EventTypeEnum, ShareByEMail, User } from '../../../../../../bizmatch-server/src/models/db.model';
import { CommercialPropertyListingCriteria, ErrorResponse, KeycloakUser, MailInfo } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment';
import { EMailService } from '../../../components/email/email.service';
import { MessageService } from '../../../components/message/message.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 { HistoryService } from '../../../services/history.service';
import { ImageService } from '../../../services/image.service';
import { ListingsService } from '../../../services/listings.service';
import { MailService } from '../../../services/mail.service';
import { SelectOptionsService } from '../../../services/select-options.service';
import { SeoService } from '../../../services/seo.service';
import { UserService } from '../../../services/user.service';
import { SharedModule } from '../../../shared/shared/shared.module';
import { createMailInfo, map2User } from '../../../utils/utils';
import { BaseDetailsComponent } from '../base-details.component';
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
import { ShareButton } from 'ngx-sharebuttons/button';
@Component({
selector: 'app-details-commercial-property-listing',
standalone: true,
imports: [SharedModule, ValidatedInputComponent, ValidatedTextareaComponent, ValidatedNgSelectComponent, GalleryModule, LeafletModule, BreadcrumbsComponent, ShareButton, NgOptimizedImage],
providers: [],
templateUrl: './details-commercial-property-listing.component.html',
styleUrl: '../details.scss',
})
export class DetailsCommercialPropertyListingComponent extends BaseDetailsComponent {
responsiveOptions = [
{
breakpoint: '1199px',
numVisible: 1,
numScroll: 1,
},
{
breakpoint: '991px',
numVisible: 2,
numScroll: 1,
},
{
breakpoint: '767px',
numVisible: 1,
numScroll: 1,
},
];
private id: string | undefined = this.activatedRoute.snapshot.params['slug'] as string | undefined;
override listing: CommercialPropertyListing;
criteria: CommercialPropertyListingCriteria;
mailinfo: MailInfo;
environment = environment;
keycloakUser: KeycloakUser;
user: User;
listingUser: User;
description: SafeHtml;
ts = new Date().getTime();
env = environment;
errorResponse: ErrorResponse;
faTimes = faTimes;
propertyDetails = [];
images: Array<ImageItem> = [];
relatedListings: CommercialPropertyListing[] = [];
breadcrumbs: BreadcrumbItem[] = [];
propertyFAQs: Array<{ question: string; answer: string }> = [];
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 imageService: ImageService,
private ngZone: NgZone,
private validationMessagesService: ValidationMessagesService,
private messageService: MessageService,
private auditService: AuditService,
private emailService: EMailService,
public authService: AuthService,
private seoService: SeoService,
private cdref: ChangeDetectorRef,
) {
super();
this.mailinfo = { sender: { name: '', email: '', phoneNumber: '', state: '', comments: '' }, email: '', url: environment.mailinfoUrl };
}
async ngOnInit() {
// Initialize default breadcrumbs first
this.breadcrumbs = [
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
{ label: 'Commercial Properties', url: '/commercialPropertyListings' }
];
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, 'commercialProperty'))) as CommercialPropertyListing;
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);
import('flowbite').then(flowbite => {
flowbite.initCarousels();
});
this.propertyDetails = [
{ label: 'Property Category', value: this.selectOptions.getCommercialProperty(this.listing.type) },
{ label: 'Located in', value: this.selectOptions.getState(this.listing.location.state) },
{ label: this.listing.location.name ? 'City' : 'County', value: this.listing.location.name ? this.listing.location.name : this.listing.location.county },
{ label: 'Asking Price:', value: `$${this.listing.price?.toLocaleString()}` },
{ 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.imagePath,
imageBaseUrl: this.env.imageBaseUrl,
ts: this.ts,
},
];
if (this.listing.draft) {
this.propertyDetails.push({ label: 'Draft', value: this.listing.draft ? 'Yes' : 'No' });
}
if (this.listing.imageOrder && Array.isArray(this.listing.imageOrder)) {
this.listing.imageOrder.forEach(image => {
const imageURL = `${this.env.imageBaseUrl}/pictures/property/${this.listing.imagePath}/${this.listing.serialId}/${image}`;
this.images.push(new ImageItem({ src: imageURL, thumb: imageURL }));
});
}
if (this.listing.location.latitude && this.listing.location.longitude) {
this.configureMap();
}
// Update SEO meta tags for commercial property
const propertyData = {
id: this.listing.id,
propertyType: this.selectOptions.getCommercialProperty(this.listing.type),
propertyDescription: this.listing.description?.replace(/<[^>]*>/g, '').substring(0, 200) || '',
askingPrice: this.listing.price,
city: this.listing.location.name || this.listing.location.county || '',
state: this.listing.location.state,
address: this.listing.location.street || '',
zip: this.listing.location.zipCode || '',
latitude: this.listing.location.latitude,
longitude: this.listing.location.longitude,
squareFootage: (this.listing as any).squareFeet,
yearBuilt: (this.listing as any).yearBuilt,
images: this.listing.imageOrder?.length > 0
? this.listing.imageOrder.map(img =>
`${this.env.imageBaseUrl}/pictures/property/${this.listing.imagePath}/${this.listing.serialId}/${img}`)
: []
};
this.seoService.updateCommercialPropertyMeta(propertyData);
// Add RealEstateListing structured data
const realEstateSchema = this.seoService.generateRealEstateListingSchema(propertyData);
const breadcrumbSchema = this.seoService.generateBreadcrumbSchema([
{ name: 'Home', url: '/' },
{ name: 'Commercial Properties', url: '/commercialPropertyListings' },
{ name: propertyData.propertyType, url: `/details-commercial-property/${this.listing.id}` }
]);
// Generate FAQ for AEO (Answer Engine Optimization)
this.propertyFAQs = this.generatePropertyFAQ();
const faqSchema = this.seoService.generateFAQPageSchema(this.propertyFAQs);
// Inject all schemas including FAQ
this.seoService.injectMultipleSchemas([realEstateSchema, breadcrumbSchema, faqSchema]);
// Generate breadcrumbs for navigation
this.breadcrumbs = [
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
{ label: 'Commercial Properties', url: '/commercialPropertyListings' },
{ label: propertyData.propertyType, url: '/commercialPropertyListings' },
{ label: this.listing.title }
];
// Load related listings for internal linking (SEO improvement)
this.loadRelatedListings();
} catch (error) {
// Set default breadcrumbs even on error
this.breadcrumbs = [
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
{ label: 'Commercial Properties', url: '/commercialPropertyListings' }
];
const errorMessage = error?.error?.message || error?.message || 'An error occurred while loading the listing';
this.auditService.log({ severity: 'error', text: errorMessage });
this.router.navigate(['notfound']);
}
//this.initFlowbite();
}
/**
* Load related commercial property listings based on same category, location, and price range
* Improves SEO through internal linking
*/
private async loadRelatedListings() {
try {
this.relatedListings = (await this.listingsService.getRelatedListings(this.listing, 'commercialProperty', 3)) as CommercialPropertyListing[];
} catch (error) {
console.error('Error loading related listings:', error);
this.relatedListings = [];
}
}
/**
* Generate dynamic FAQ based on commercial property listing data
* Provides AEO (Answer Engine Optimization) content
*/
private generatePropertyFAQ(): Array<{ question: string; answer: string }> {
const faqs: Array<{ question: string; answer: string }> = [];
// FAQ 1: What type of property is this?
faqs.push({
question: 'What type of commercial property is this?',
answer: `This is a ${this.selectOptions.getCommercialProperty(this.listing.type)} property located in ${this.listing.location.name || this.listing.location.county}, ${this.selectOptions.getState(this.listing.location.state)}.`
});
// FAQ 2: What is the asking price?
if (this.listing.price) {
faqs.push({
question: 'What is the asking price for this property?',
answer: `The asking price for this commercial property is $${this.listing.price.toLocaleString()}.`
});
} else {
faqs.push({
question: 'What is the asking price for this property?',
answer: 'The asking price is available upon request. Please contact the seller for detailed pricing information.'
});
}
// FAQ 3: Where is the property located?
faqs.push({
question: 'Where is this commercial property located?',
answer: `The property is located in ${this.listing.location.name || this.listing.location.county}, ${this.selectOptions.getState(this.listing.location.state)}.${this.listing.location.street ? ' The exact address will be provided after initial contact.' : ''}`
});
// FAQ 4: How long has the property been listed?
const daysListed = this.getDaysListed();
faqs.push({
question: 'How long has this property been on the market?',
answer: `This property was listed on ${this.dateInserted()} and has been on the market for ${daysListed} ${daysListed === 1 ? 'day' : 'days'}.`
});
// FAQ 5: How can I schedule a viewing?
faqs.push({
question: 'How can I schedule a property viewing?',
answer: 'To schedule a viewing of this commercial property, please use the contact form above to get in touch with the listing agent. They will coordinate a convenient time for you to visit the property.'
});
// FAQ 6: What is the zoning for this property?
faqs.push({
question: 'What is this property suitable for?',
answer: `This ${this.selectOptions.getCommercialProperty(this.listing.type)} property is ideal for various commercial uses. Contact the seller for specific zoning information and permitted use details.`
});
return faqs;
}
ngOnDestroy() {
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
this.seoService.clearStructuredData(); // Clean up SEO structured data
}
private initFlowbite() {
this.ngZone.runOutsideAngular(() => {
import('flowbite')
.then(flowbite => {
flowbite.initCarousels();
})
.catch(error => console.error('Error initializing Flowbite:', error));
});
}
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);
}
}
}
containsError(fieldname: string) {
return this.errorResponse?.fields.map(f => f.fieldname).includes(fieldname);
}
getImageIndices(): number[] {
return this.listing && this.listing.imageOrder ? this.listing.imageOrder.slice(1).map((e, i) => i + 1) : [];
}
async toggleFavorite() {
try {
const isFavorited = this.listing.favoritesForUser.includes(this.user.email);
if (isFavorited) {
// Remove from favorites
await this.listingsService.removeFavorite(this.listing.id, 'commercialProperty');
this.listing.favoritesForUser = this.listing.favoritesForUser.filter(
email => email !== this.user.email
);
} else {
// Add to favorites
await this.listingsService.addToFavorites(this.listing.id, 'commercialProperty');
this.listing.favoritesForUser.push(this.user.email);
this.auditService.createEvent(this.listing.id, 'favorite', this.user?.email);
}
this.cdref.detectChanges();
} catch (error) {
console.error('Error toggling favorite:', error);
}
}
async showShareByEMail() {
const result = await this.emailService.showShareByEMail({
yourEmail: this.user ? this.user.email : '',
yourName: this.user ? `${this.user.firstname} ${this.user.lastname}` : '',
recipientEmail: '',
url: environment.mailinfoUrl,
listingTitle: this.listing.title,
id: this.listing.id,
type: 'commercialProperty',
});
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);
}
shareToFacebook() {
const url = encodeURIComponent(window.location.href);
window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`, '_blank', 'width=600,height=400');
this.createEvent('facebook');
}
shareToTwitter() {
const url = encodeURIComponent(window.location.href);
const text = encodeURIComponent(this.listing?.title || 'Check out this commercial property');
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank', 'width=600,height=400');
this.createEvent('x');
}
shareToLinkedIn() {
const url = encodeURIComponent(window.location.href);
window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${url}`, '_blank', 'width=600,height=400');
this.createEvent('linkedin');
}
getDaysListed() {
return dayjs().diff(this.listing.created, 'day');
}
dateInserted() {
return dayjs(this.listing.created).format('DD/MM/YYYY');
}
}
import { ChangeDetectorRef, Component, NgZone } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { LeafletModule } from '@bluehalo/ngx-leaflet';
import { faTimes } from '@fortawesome/free-solid-svg-icons';
import dayjs from 'dayjs';
import { GalleryModule, ImageItem } from 'ng-gallery';
import { lastValueFrom } from 'rxjs';
import { CommercialPropertyListing, EventTypeEnum, ShareByEMail, User } from '../../../../../../bizmatch-server/src/models/db.model';
import { CommercialPropertyListingCriteria, ErrorResponse, KeycloakUser, MailInfo } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment';
import { EMailService } from '../../../components/email/email.service';
import { MessageService } from '../../../components/message/message.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 { HistoryService } from '../../../services/history.service';
import { ImageService } from '../../../services/image.service';
import { ListingsService } from '../../../services/listings.service';
import { MailService } from '../../../services/mail.service';
import { SelectOptionsService } from '../../../services/select-options.service';
import { SeoService } from '../../../services/seo.service';
import { UserService } from '../../../services/user.service';
import { SharedModule } from '../../../shared/shared/shared.module';
import { createMailInfo, map2User } from '../../../utils/utils';
import { BaseDetailsComponent } from '../base-details.component';
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
import { ShareButton } from 'ngx-sharebuttons/button';
@Component({
selector: 'app-details-commercial-property-listing',
standalone: true,
imports: [SharedModule, ValidatedInputComponent, ValidatedTextareaComponent, ValidatedNgSelectComponent, GalleryModule, LeafletModule, BreadcrumbsComponent, ShareButton, NgOptimizedImage],
providers: [],
templateUrl: './details-commercial-property-listing.component.html',
styleUrl: '../details.scss',
})
export class DetailsCommercialPropertyListingComponent extends BaseDetailsComponent {
responsiveOptions = [
{
breakpoint: '1199px',
numVisible: 1,
numScroll: 1,
},
{
breakpoint: '991px',
numVisible: 2,
numScroll: 1,
},
{
breakpoint: '767px',
numVisible: 1,
numScroll: 1,
},
];
private id: string | undefined = this.activatedRoute.snapshot.params['slug'] as string | undefined;
override listing: CommercialPropertyListing;
criteria: CommercialPropertyListingCriteria;
mailinfo: MailInfo;
environment = environment;
keycloakUser: KeycloakUser;
user: User;
listingUser: User;
description: SafeHtml;
ts = new Date().getTime();
env = environment;
errorResponse: ErrorResponse;
faTimes = faTimes;
propertyDetails = [];
images: Array<ImageItem> = [];
relatedListings: CommercialPropertyListing[] = [];
breadcrumbs: BreadcrumbItem[] = [];
propertyFAQs: Array<{ question: string; answer: string }> = [];
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 imageService: ImageService,
private ngZone: NgZone,
private validationMessagesService: ValidationMessagesService,
private messageService: MessageService,
private auditService: AuditService,
private emailService: EMailService,
public authService: AuthService,
private seoService: SeoService,
private cdref: ChangeDetectorRef,
) {
super();
this.mailinfo = { sender: { name: '', email: '', phoneNumber: '', state: '', comments: '' }, email: '', url: environment.mailinfoUrl };
}
async ngOnInit() {
// Initialize default breadcrumbs first
this.breadcrumbs = [
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
{ label: 'Commercial Properties', url: '/commercialPropertyListings' }
];
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, 'commercialProperty'))) as CommercialPropertyListing;
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);
import('flowbite').then(flowbite => {
flowbite.initCarousels();
});
this.propertyDetails = [
{ label: 'Property Category', value: this.selectOptions.getCommercialProperty(this.listing.type) },
{ label: 'Located in', value: this.selectOptions.getState(this.listing.location.state) },
{ label: this.listing.location.name ? 'City' : 'County', value: this.listing.location.name ? this.listing.location.name : this.listing.location.county },
{ label: 'Asking Price:', value: `$${this.listing.price?.toLocaleString()}` },
{ 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.imagePath,
imageBaseUrl: this.env.imageBaseUrl,
ts: this.ts,
},
];
if (this.listing.draft) {
this.propertyDetails.push({ label: 'Draft', value: this.listing.draft ? 'Yes' : 'No' });
}
if (this.listing.imageOrder && Array.isArray(this.listing.imageOrder)) {
this.listing.imageOrder.forEach(image => {
const imageURL = `${this.env.imageBaseUrl}/pictures/property/${this.listing.imagePath}/${this.listing.serialId}/${image}`;
this.images.push(new ImageItem({ src: imageURL, thumb: imageURL }));
});
}
if (this.listing.location.latitude && this.listing.location.longitude) {
this.configureMap();
}
// Update SEO meta tags for commercial property
const propertyData = {
id: this.listing.id,
propertyType: this.selectOptions.getCommercialProperty(this.listing.type),
propertyDescription: this.listing.description?.replace(/<[^>]*>/g, '').substring(0, 200) || '',
askingPrice: this.listing.price,
city: this.listing.location.name || this.listing.location.county || '',
state: this.listing.location.state,
address: this.listing.location.street || '',
zip: this.listing.location.zipCode || '',
latitude: this.listing.location.latitude,
longitude: this.listing.location.longitude,
squareFootage: (this.listing as any).squareFeet,
yearBuilt: (this.listing as any).yearBuilt,
images: this.listing.imageOrder?.length > 0
? this.listing.imageOrder.map(img =>
`${this.env.imageBaseUrl}/pictures/property/${this.listing.imagePath}/${this.listing.serialId}/${img}`)
: []
};
this.seoService.updateCommercialPropertyMeta(propertyData);
// Add RealEstateListing structured data
const realEstateSchema = this.seoService.generateRealEstateListingSchema(propertyData);
const breadcrumbSchema = this.seoService.generateBreadcrumbSchema([
{ name: 'Home', url: '/' },
{ name: 'Commercial Properties', url: '/commercialPropertyListings' },
{ name: propertyData.propertyType, url: `/details-commercial-property/${this.listing.id}` }
]);
// Generate FAQ for AEO (Answer Engine Optimization)
this.propertyFAQs = this.generatePropertyFAQ();
const faqSchema = this.seoService.generateFAQPageSchema(this.propertyFAQs);
// Inject all schemas including FAQ
this.seoService.injectMultipleSchemas([realEstateSchema, breadcrumbSchema, faqSchema]);
// Generate breadcrumbs for navigation
this.breadcrumbs = [
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
{ label: 'Commercial Properties', url: '/commercialPropertyListings' },
{ label: propertyData.propertyType, url: '/commercialPropertyListings' },
{ label: this.listing.title }
];
// Load related listings for internal linking (SEO improvement)
this.loadRelatedListings();
} catch (error) {
// Set default breadcrumbs even on error
this.breadcrumbs = [
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
{ label: 'Commercial Properties', url: '/commercialPropertyListings' }
];
const errorMessage = error?.error?.message || error?.message || 'An error occurred while loading the listing';
this.auditService.log({ severity: 'error', text: errorMessage });
this.router.navigate(['notfound']);
}
//this.initFlowbite();
}
/**
* Load related commercial property listings based on same category, location, and price range
* Improves SEO through internal linking
*/
private async loadRelatedListings() {
try {
this.relatedListings = (await this.listingsService.getRelatedListings(this.listing, 'commercialProperty', 3)) as CommercialPropertyListing[];
} catch (error) {
console.error('Error loading related listings:', error);
this.relatedListings = [];
}
}
/**
* Generate dynamic FAQ based on commercial property listing data
* Provides AEO (Answer Engine Optimization) content
*/
private generatePropertyFAQ(): Array<{ question: string; answer: string }> {
const faqs: Array<{ question: string; answer: string }> = [];
// FAQ 1: What type of property is this?
faqs.push({
question: 'What type of commercial property is this?',
answer: `This is a ${this.selectOptions.getCommercialProperty(this.listing.type)} property located in ${this.listing.location.name || this.listing.location.county}, ${this.selectOptions.getState(this.listing.location.state)}.`
});
// FAQ 2: What is the asking price?
if (this.listing.price) {
faqs.push({
question: 'What is the asking price for this property?',
answer: `The asking price for this commercial property is $${this.listing.price.toLocaleString()}.`
});
} else {
faqs.push({
question: 'What is the asking price for this property?',
answer: 'The asking price is available upon request. Please contact the seller for detailed pricing information.'
});
}
// FAQ 3: Where is the property located?
faqs.push({
question: 'Where is this commercial property located?',
answer: `The property is located in ${this.listing.location.name || this.listing.location.county}, ${this.selectOptions.getState(this.listing.location.state)}.${this.listing.location.street ? ' The exact address will be provided after initial contact.' : ''}`
});
// FAQ 4: How long has the property been listed?
const daysListed = this.getDaysListed();
faqs.push({
question: 'How long has this property been on the market?',
answer: `This property was listed on ${this.dateInserted()} and has been on the market for ${daysListed} ${daysListed === 1 ? 'day' : 'days'}.`
});
// FAQ 5: How can I schedule a viewing?
faqs.push({
question: 'How can I schedule a property viewing?',
answer: 'To schedule a viewing of this commercial property, please use the contact form above to get in touch with the listing agent. They will coordinate a convenient time for you to visit the property.'
});
// FAQ 6: What is the zoning for this property?
faqs.push({
question: 'What is this property suitable for?',
answer: `This ${this.selectOptions.getCommercialProperty(this.listing.type)} property is ideal for various commercial uses. Contact the seller for specific zoning information and permitted use details.`
});
return faqs;
}
ngOnDestroy() {
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
this.seoService.clearStructuredData(); // Clean up SEO structured data
}
private initFlowbite() {
this.ngZone.runOutsideAngular(() => {
import('flowbite')
.then(flowbite => {
flowbite.initCarousels();
})
.catch(error => console.error('Error initializing Flowbite:', error));
});
}
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);
}
}
}
containsError(fieldname: string) {
return this.errorResponse?.fields.map(f => f.fieldname).includes(fieldname);
}
getImageIndices(): number[] {
return this.listing && this.listing.imageOrder ? this.listing.imageOrder.slice(1).map((e, i) => i + 1) : [];
}
async toggleFavorite() {
try {
const isFavorited = this.listing.favoritesForUser.includes(this.user.email);
if (isFavorited) {
// Remove from favorites
await this.listingsService.removeFavorite(this.listing.id, 'commercialProperty');
this.listing.favoritesForUser = this.listing.favoritesForUser.filter(
email => email !== this.user.email
);
} else {
// Add to favorites
await this.listingsService.addToFavorites(this.listing.id, 'commercialProperty');
this.listing.favoritesForUser.push(this.user.email);
this.auditService.createEvent(this.listing.id, 'favorite', this.user?.email);
}
this.cdref.detectChanges();
} catch (error) {
console.error('Error toggling favorite:', error);
}
}
async showShareByEMail() {
const result = await this.emailService.showShareByEMail({
yourEmail: this.user ? this.user.email : '',
yourName: this.user ? `${this.user.firstname} ${this.user.lastname}` : '',
recipientEmail: '',
url: environment.mailinfoUrl,
listingTitle: this.listing.title,
id: this.listing.id,
type: 'commercialProperty',
});
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);
}
shareToFacebook() {
const url = encodeURIComponent(window.location.href);
window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`, '_blank', 'width=600,height=400');
this.createEvent('facebook');
}
shareToTwitter() {
const url = encodeURIComponent(window.location.href);
const text = encodeURIComponent(this.listing?.title || 'Check out this commercial property');
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank', 'width=600,height=400');
this.createEvent('x');
}
shareToLinkedIn() {
const url = encodeURIComponent(window.location.href);
window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${url}`, '_blank', 'width=600,height=400');
this.createEvent('linkedin');
}
getDaysListed() {
return dayjs().diff(this.listing.created, 'day');
}
dateInserted() {
return dayjs(this.listing.created).format('DD/MM/YYYY');
}
}

View File

@@ -1,221 +1,221 @@
<div class="container mx-auto p-4">
<!-- Breadcrumbs -->
<div class="mb-4">
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
</div>
@if(user){
<div class="bg-white drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg rounded-lg overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b relative">
<div class="flex items-center space-x-4">
<!-- <img src="https://placehold.co/80x80" alt="Profile picture of Avery Brown smiling" class="w-20 h-20 rounded-full" /> -->
@if(user.hasProfile){
<img ngSrc="{{ env.imageBaseUrl }}/pictures//profile/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}"
class="w-20 h-20 rounded-full object-cover" width="80" height="80" priority
alt="Profile picture of {{ user.firstname }} {{ user.lastname }}" />
} @else {
<img ngSrc="assets/images/person_placeholder.jpg" class="w-20 h-20 rounded-full" width="80" height="80" priority
alt="Default profile picture" />
}
<div>
<h1 class="text-2xl font-bold flex items-center">
{{ user.firstname }} {{ user.lastname }}
<span class="text-yellow-400 ml-2">&#9733;</span>
</h1>
<p class="text-neutral-600">
Company
<span class="mx-1">-</span>
{{ user.companyName }}
<span class="mx-2">|</span>
For Sale
<span class="mx-1">-</span>
<!-- <i class="fas fa-building text-red-500"></i> -->
<span>{{ businessListings?.length + commercialPropListings?.length }}</span>
</p>
</div>
@if(user.hasCompanyLogo){
<div class="relative w-14 h-14">
<img ngSrc="{{ env.imageBaseUrl }}/pictures/logo/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}" fill
class="object-contain" alt="Company logo of {{ user.companyName }}" />
</div>
}
<!-- <img src="https://placehold.co/45x60" class="w-11 h-14" /> -->
</div>
<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">
<i class="fas fa-times"></i>
</button>
</div>
<!-- Description -->
<p class="p-4 text-neutral-700 break-words">{{ user.description }}</p>
<!-- Like and Share Action Buttons -->
<div class="py-4 px-4 print:hidden">
@if(user && keycloakUser && (user?.email===keycloakUser?.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]="['/account', user.id]">
<i class="fa-regular fa-pen-to-square"></i>
<span class="ml-2">Edit</span>
</button>
</div>
}
<div class="inline">
<button type="button" class="share share-save text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="toggleFavorite()">
<i class="fa-regular fa-heart"></i>
@if(isAlreadyFavorite()){
<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>
<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>
<div class="inline">
<button type="button"
class="share share-facebook text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToFacebook()">
<i class="fab fa-facebook"></i>
<span class="ml-2">Facebook</span>
</button>
</div>
<div class="inline">
<button type="button"
class="share share-twitter text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToTwitter()">
<i class="fab fa-x-twitter"></i>
<span class="ml-2">X</span>
</button>
</div>
<div class="inline">
<button type="button"
class="share share-linkedin text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToLinkedIn()">
<i class="fab fa-linkedin"></i>
<span class="ml-2">LinkedIn</span>
</button>
</div>
</div>
<!-- Company Profile -->
<div class="p-4">
<h2 class="text-xl font-semibold mb-4">Company Profile</h2>
<p class="text-neutral-700 mb-4 break-words" [innerHTML]="companyOverview"></p>
<!-- Profile Details -->
<div class="space-y-2">
<div class="flex flex-col sm:flex-row sm:items-center bg-neutral-100">
<span class="font-semibold w-40 p-2">Name</span>
<span class="p-2 flex-grow">{{ user.firstname }} {{ user.lastname }}</span>
</div>
<div class="flex flex-col sm:flex-row sm:items-center">
<span class="font-semibold w-40 p-2">EMail Address</span>
<span class="p-2 flex-grow">{{ user.email }}</span>
</div>
@if(user.customerType==='professional'){
<div class="flex flex-col sm:flex-row sm:items-center bg-neutral-100">
<span class="font-semibold w-40 p-2">Phone Number</span>
<span class="p-2 flex-grow">{{ formatPhoneNumber(user.phoneNumber) }}</span>
</div>
<div class="flex flex-col sm:flex-row sm:items-center">
<span class="font-semibold w-40 p-2">Company Location</span>
<span class="p-2 flex-grow">{{ user.location?.name }} - {{ user.location?.state }}</span>
</div>
<div class="flex flex-col sm:flex-row sm:items-center bg-neutral-100">
<span class="font-semibold w-40 p-2">Professional Type</span>
<span class="p-2 flex-grow">{{ selectOptions.getCustomerSubType(user.customerSubType) }}</span>
</div>
}
</div>
@if(user.customerType==='professional'){
<!-- Services -->
<div class="mt-6">
<h3 class="font-semibold mb-2">Services we offer</h3>
<p class="text-neutral-700 mb-4 break-words" [innerHTML]="offeredServices"></p>
</div>
<!-- Areas Served -->
<div class="mt-6">
<h3 class="font-semibold mb-2">Areas (Counties) we serve</h3>
<div class="flex flex-wrap gap-2">
@for (area of user.areasServed; track area) {
<span class="bg-primary-100 text-primary-800 px-2 py-1 rounded-full text-sm">{{ area.county }}{{ area.county ?
'-' : '' }}{{ area.state }}</span>
}
</div>
</div>
<!-- Licensed In -->
<div class="mt-6">
<h3 class="font-semibold mb-2">Licensed In</h3>
@for (license of user.licensedIn; track license) {
<span class="bg-success-100 text-success-800 px-2 py-1 rounded-full text-sm">{{ license.registerNo }}-{{
license.state }}</span>
}
</div>
}
</div>
<!-- Business Listings -->
<div class="p-4">
@if(businessListings?.length>0){
<h2 class="text-xl font-semibold mb-4">My Business Listings For Sale</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@for (listing of businessListings; track listing) {
<div class="border rounded-lg p-4 hover:cursor-pointer"
[routerLink]="['/business', listing.slug || listing.id]">
<div class="flex items-center mb-2">
<i [class]="selectOptions.getIconAndTextColorType(listing.type)" class="mr-2"></i>
<span class="font-medium">{{ selectOptions.getBusiness(listing.type) }}</span>
</div>
<p class="text-neutral-700">{{ listing.title }}</p>
</div>
}
</div>
}
<!-- Commercial Property Listings -->
@if(commercialPropListings?.length>0){
<h2 class="text-xl font-semibold mb-4">My Commercial Property Listings For Sale</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@for (listing of commercialPropListings; track listing) {
<div class="border rounded-lg p-4 hover:cursor-pointer"
[routerLink]="['/commercial-property', listing.slug || listing.id]">
<div class="flex items-center space-x-4">
@if (listing.imageOrder?.length>0){
<img
ngSrc="{{ env.imageBaseUrl }}/pictures/property/{{ listing.imagePath }}/{{ listing.serialId }}/{{ listing.imageOrder[0] }}?_ts={{ ts }}"
class="w-12 h-12 object-cover rounded" width="48" height="48"
alt="Property image for {{ listing.title }}" />
} @else {
<img ngSrc="assets/images/placeholder_properties.jpg" class="w-12 h-12 object-cover rounded" width="48"
height="48" alt="Property placeholder image" />
}
<div>
<p class="font-medium">{{ selectOptions.getCommercialProperty(listing.type) }}</p>
<p class="text-neutral-700">{{ listing.title }}</p>
</div>
</div>
</div>
}
</div>
}
</div>
</div>
}
<div class="container mx-auto p-4">
<!-- Breadcrumbs -->
<div class="mb-4">
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
</div>
@if(user){
<div class="bg-white drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg rounded-lg overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b relative">
<div class="flex items-center space-x-4">
<!-- <img src="https://placehold.co/80x80" alt="Profile picture of Avery Brown smiling" class="w-20 h-20 rounded-full" /> -->
@if(user.hasProfile){
<img ngSrc="{{ env.imageBaseUrl }}/pictures//profile/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}"
class="w-20 h-20 rounded-full object-cover" width="80" height="80" priority
alt="Profile picture of {{ user.firstname }} {{ user.lastname }}" />
} @else {
<img ngSrc="assets/images/person_placeholder.jpg" class="w-20 h-20 rounded-full" width="80" height="80" priority
alt="Default profile picture" />
}
<div>
<h1 class="text-2xl font-bold flex items-center">
{{ user.firstname }} {{ user.lastname }}
<span class="text-yellow-400 ml-2">&#9733;</span>
</h1>
<p class="text-neutral-600">
Company
<span class="mx-1">-</span>
{{ user.companyName }}
<span class="mx-2">|</span>
For Sale
<span class="mx-1">-</span>
<!-- <i class="fas fa-building text-red-500"></i> -->
<span>{{ businessListings?.length + commercialPropListings?.length }}</span>
</p>
</div>
@if(user.hasCompanyLogo){
<div class="relative w-14 h-14">
<img ngSrc="{{ env.imageBaseUrl }}/pictures/logo/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}" fill
class="object-contain" alt="Company logo of {{ user.companyName }}" />
</div>
}
<!-- <img src="https://placehold.co/45x60" class="w-11 h-14" /> -->
</div>
<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">
<i class="fas fa-times"></i>
</button>
</div>
<!-- Description -->
<p class="p-4 text-neutral-700 break-words">{{ user.description }}</p>
<!-- Like and Share Action Buttons -->
<div class="py-4 px-4 print:hidden">
@if(user && keycloakUser && (user?.email===keycloakUser?.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]="['/account', user.id]">
<i class="fa-regular fa-pen-to-square"></i>
<span class="ml-2">Edit</span>
</button>
</div>
}
<div class="inline">
<button type="button" class="share share-save text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="toggleFavorite()">
<i class="fa-regular fa-heart"></i>
@if(isAlreadyFavorite()){
<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>
<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>
<div class="inline">
<button type="button"
class="share share-facebook text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToFacebook()">
<i class="fab fa-facebook"></i>
<span class="ml-2">Facebook</span>
</button>
</div>
<div class="inline">
<button type="button"
class="share share-twitter text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToTwitter()">
<i class="fab fa-x-twitter"></i>
<span class="ml-2">X</span>
</button>
</div>
<div class="inline">
<button type="button"
class="share share-linkedin text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToLinkedIn()">
<i class="fab fa-linkedin"></i>
<span class="ml-2">LinkedIn</span>
</button>
</div>
</div>
<!-- Company Profile -->
<div class="p-4">
<h2 class="text-xl font-semibold mb-4">Company Profile</h2>
<p class="text-neutral-700 mb-4 break-words" [innerHTML]="companyOverview"></p>
<!-- Profile Details -->
<div class="space-y-2">
<div class="flex flex-col sm:flex-row sm:items-center bg-neutral-100">
<span class="font-semibold w-40 p-2">Name</span>
<span class="p-2 flex-grow">{{ user.firstname }} {{ user.lastname }}</span>
</div>
<div class="flex flex-col sm:flex-row sm:items-center">
<span class="font-semibold w-40 p-2">EMail Address</span>
<span class="p-2 flex-grow">{{ user.email }}</span>
</div>
@if(user.customerType==='professional'){
<div class="flex flex-col sm:flex-row sm:items-center bg-neutral-100">
<span class="font-semibold w-40 p-2">Phone Number</span>
<span class="p-2 flex-grow">{{ formatPhoneNumber(user.phoneNumber) }}</span>
</div>
<div class="flex flex-col sm:flex-row sm:items-center">
<span class="font-semibold w-40 p-2">Company Location</span>
<span class="p-2 flex-grow">{{ user.location?.name }} - {{ user.location?.state }}</span>
</div>
<div class="flex flex-col sm:flex-row sm:items-center bg-neutral-100">
<span class="font-semibold w-40 p-2">Professional Type</span>
<span class="p-2 flex-grow">{{ selectOptions.getCustomerSubType(user.customerSubType) }}</span>
</div>
}
</div>
@if(user.customerType==='professional'){
<!-- Services -->
<div class="mt-6">
<h3 class="font-semibold mb-2">Services we offer</h3>
<p class="text-neutral-700 mb-4 break-words" [innerHTML]="offeredServices"></p>
</div>
<!-- Areas Served -->
<div class="mt-6">
<h3 class="font-semibold mb-2">Areas (Counties) we serve</h3>
<div class="flex flex-wrap gap-2">
@for (area of user.areasServed; track area) {
<span class="bg-primary-100 text-primary-800 px-2 py-1 rounded-full text-sm">{{ area.county }}{{ area.county ?
'-' : '' }}{{ area.state }}</span>
}
</div>
</div>
<!-- Licensed In -->
<div class="mt-6">
<h3 class="font-semibold mb-2">Licensed In</h3>
@for (license of user.licensedIn; track license) {
<span class="bg-success-100 text-success-800 px-2 py-1 rounded-full text-sm">{{ license.registerNo }}-{{
license.state }}</span>
}
</div>
}
</div>
<!-- Business Listings -->
<div class="p-4">
@if(businessListings?.length>0){
<h2 class="text-xl font-semibold mb-4">My Business Listings For Sale</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@for (listing of businessListings; track listing) {
<div class="border rounded-lg p-4 hover:cursor-pointer"
[routerLink]="['/business', listing.slug || listing.id]">
<div class="flex items-center mb-2">
<i [class]="selectOptions.getIconAndTextColorType(listing.type)" class="mr-2"></i>
<span class="font-medium">{{ selectOptions.getBusiness(listing.type) }}</span>
</div>
<p class="text-neutral-700">{{ listing.title }}</p>
</div>
}
</div>
}
<!-- Commercial Property Listings -->
@if(commercialPropListings?.length>0){
<h2 class="text-xl font-semibold mb-4">My Commercial Property Listings For Sale</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@for (listing of commercialPropListings; track listing) {
<div class="border rounded-lg p-4 hover:cursor-pointer"
[routerLink]="['/commercial-property', listing.slug || listing.id]">
<div class="flex items-center space-x-4">
@if (listing.imageOrder?.length>0){
<img
ngSrc="{{ env.imageBaseUrl }}/pictures/property/{{ listing.imagePath }}/{{ listing.serialId }}/{{ listing.imageOrder[0] }}?_ts={{ ts }}"
class="w-12 h-12 object-cover rounded" width="48" height="48"
alt="Property image for {{ listing.title }}" />
} @else {
<img ngSrc="assets/images/placeholder_properties.jpg" class="w-12 h-12 object-cover rounded" width="48"
height="48" alt="Property placeholder image" />
}
<div>
<p class="font-medium">{{ selectOptions.getCommercialProperty(listing.type) }}</p>
<p class="text-neutral-700">{{ listing.title }}</p>
</div>
</div>
</div>
}
</div>
}
</div>
</div>
}
</div>

View File

@@ -1,169 +1,169 @@
import { ChangeDetectorRef, Component } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { BusinessListing, CommercialPropertyListing, User, ShareByEMail, EventTypeEnum } from '../../../../../../bizmatch-server/src/models/db.model';
import { KeycloakUser, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment';
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
import { AuthService } from '../../../services/auth.service';
import { AuditService } from '../../../services/audit.service';
import { EMailService } from '../../../components/email/email.service';
import { MessageService } from '../../../components/message/message.service';
import { HistoryService } from '../../../services/history.service';
import { ImageService } from '../../../services/image.service';
import { ListingsService } from '../../../services/listings.service';
import { SelectOptionsService } from '../../../services/select-options.service';
import { UserService } from '../../../services/user.service';
import { SharedModule } from '../../../shared/shared/shared.module';
import { formatPhoneNumber, map2User } from '../../../utils/utils';
import { ShareButton } from 'ngx-sharebuttons/button';
@Component({
selector: 'app-details-user',
standalone: true,
imports: [SharedModule, BreadcrumbsComponent, NgOptimizedImage, ShareButton],
templateUrl: './details-user.component.html',
styleUrl: '../details.scss',
})
export class DetailsUserComponent {
private id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined;
user: User;
breadcrumbs: BreadcrumbItem[] = [
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
{ label: 'Professionals', url: '/brokerListings' },
{ label: 'Profile' }
];
user$: Observable<KeycloakUser>;
keycloakUser: KeycloakUser;
environment = environment;
businessListings: BusinessListing[];
commercialPropListings: CommercialPropertyListing[];
companyOverview: SafeHtml;
offeredServices: SafeHtml;
ts = new Date().getTime();
env = environment;
emailToDirName = emailToDirName;
formatPhoneNumber = formatPhoneNumber;
constructor(
private activatedRoute: ActivatedRoute,
private router: Router,
private userService: UserService,
private listingsService: ListingsService,
public selectOptions: SelectOptionsService,
private sanitizer: DomSanitizer,
private imageService: ImageService,
public historyService: HistoryService,
public authService: AuthService,
private auditService: AuditService,
private emailService: EMailService,
private messageService: MessageService,
private cdref: ChangeDetectorRef,
) { }
async ngOnInit() {
this.user = await this.userService.getById(this.id);
const results = await Promise.all([await this.listingsService.getListingsByEmail(this.user.email, 'business'), await this.listingsService.getListingsByEmail(this.user.email, 'commercialProperty')]);
// Zuweisen der Ergebnisse zu den Member-Variablen der Klasse
this.businessListings = results[0];
this.commercialPropListings = results[1] as CommercialPropertyListing[];
const token = await this.authService.getToken();
this.keycloakUser = map2User(token);
this.companyOverview = this.sanitizer.bypassSecurityTrustHtml(this.user.companyOverview ? this.user.companyOverview : '');
this.offeredServices = this.sanitizer.bypassSecurityTrustHtml(this.user.offeredServices ? this.user.offeredServices : '');
}
/**
* Toggle professional favorite status
*/
async toggleFavorite() {
try {
const isFavorited = this.user.favoritesForUser?.includes(this.keycloakUser.email);
if (isFavorited) {
// Remove from favorites
await this.listingsService.removeFavorite(this.user.id, 'user');
this.user.favoritesForUser = this.user.favoritesForUser.filter(
email => email !== this.keycloakUser.email
);
} else {
// Add to favorites
await this.listingsService.addToFavorites(this.user.id, 'user');
if (!this.user.favoritesForUser) {
this.user.favoritesForUser = [];
}
this.user.favoritesForUser.push(this.keycloakUser.email);
this.auditService.createEvent(this.user.id, 'favorite', this.keycloakUser?.email);
}
this.cdref.detectChanges();
} catch (error) {
console.error('Error toggling favorite', error);
}
}
isAlreadyFavorite(): boolean {
if (!this.keycloakUser?.email || !this.user?.favoritesForUser) return false;
return this.user.favoritesForUser.includes(this.keycloakUser.email);
}
/**
* Show email sharing modal
*/
async showShareByEMail() {
const result = await this.emailService.showShareByEMail({
yourEmail: this.keycloakUser ? this.keycloakUser.email : '',
yourName: this.keycloakUser ? `${this.keycloakUser.firstName} ${this.keycloakUser.lastName}` : '',
recipientEmail: '',
url: environment.mailinfoUrl,
listingTitle: `${this.user.firstname} ${this.user.lastname} - ${this.user.companyName}`,
id: this.user.id,
type: 'user',
});
if (result) {
this.auditService.createEvent(this.user.id, 'email', this.keycloakUser?.email, <ShareByEMail>result);
this.messageService.addMessage({
severity: 'success',
text: 'Your Email has been sent',
duration: 5000,
});
}
}
/**
* Create audit event
*/
createEvent(eventType: EventTypeEnum) {
this.auditService.createEvent(this.user.id, eventType, this.keycloakUser?.email);
}
/**
* Share to Facebook
*/
shareToFacebook() {
const url = encodeURIComponent(window.location.href);
window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`, '_blank', 'width=600,height=400');
this.createEvent('facebook');
}
/**
* Share to Twitter/X
*/
shareToTwitter() {
const url = encodeURIComponent(window.location.href);
const text = encodeURIComponent(`Check out ${this.user.firstname} ${this.user.lastname} - ${this.user.companyName}`);
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank', 'width=600,height=400');
this.createEvent('x');
}
/**
* Share to LinkedIn
*/
shareToLinkedIn() {
const url = encodeURIComponent(window.location.href);
window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${url}`, '_blank', 'width=600,height=400');
this.createEvent('linkedin');
}
}
import { ChangeDetectorRef, Component } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { Observable } from 'rxjs';
import { BusinessListing, CommercialPropertyListing, User, ShareByEMail, EventTypeEnum } from '../../../../../../bizmatch-server/src/models/db.model';
import { KeycloakUser, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment';
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
import { AuthService } from '../../../services/auth.service';
import { AuditService } from '../../../services/audit.service';
import { EMailService } from '../../../components/email/email.service';
import { MessageService } from '../../../components/message/message.service';
import { HistoryService } from '../../../services/history.service';
import { ImageService } from '../../../services/image.service';
import { ListingsService } from '../../../services/listings.service';
import { SelectOptionsService } from '../../../services/select-options.service';
import { UserService } from '../../../services/user.service';
import { SharedModule } from '../../../shared/shared/shared.module';
import { formatPhoneNumber, map2User } from '../../../utils/utils';
import { ShareButton } from 'ngx-sharebuttons/button';
@Component({
selector: 'app-details-user',
standalone: true,
imports: [SharedModule, BreadcrumbsComponent, NgOptimizedImage, ShareButton],
templateUrl: './details-user.component.html',
styleUrl: '../details.scss',
})
export class DetailsUserComponent {
private id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined;
user: User;
breadcrumbs: BreadcrumbItem[] = [
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
{ label: 'Professionals', url: '/brokerListings' },
{ label: 'Profile' }
];
user$: Observable<KeycloakUser>;
keycloakUser: KeycloakUser;
environment = environment;
businessListings: BusinessListing[];
commercialPropListings: CommercialPropertyListing[];
companyOverview: SafeHtml;
offeredServices: SafeHtml;
ts = new Date().getTime();
env = environment;
emailToDirName = emailToDirName;
formatPhoneNumber = formatPhoneNumber;
constructor(
private activatedRoute: ActivatedRoute,
private router: Router,
private userService: UserService,
private listingsService: ListingsService,
public selectOptions: SelectOptionsService,
private sanitizer: DomSanitizer,
private imageService: ImageService,
public historyService: HistoryService,
public authService: AuthService,
private auditService: AuditService,
private emailService: EMailService,
private messageService: MessageService,
private cdref: ChangeDetectorRef,
) { }
async ngOnInit() {
this.user = await this.userService.getById(this.id);
const results = await Promise.all([await this.listingsService.getListingsByEmail(this.user.email, 'business'), await this.listingsService.getListingsByEmail(this.user.email, 'commercialProperty')]);
// Zuweisen der Ergebnisse zu den Member-Variablen der Klasse
this.businessListings = results[0];
this.commercialPropListings = results[1] as CommercialPropertyListing[];
const token = await this.authService.getToken();
this.keycloakUser = map2User(token);
this.companyOverview = this.sanitizer.bypassSecurityTrustHtml(this.user.companyOverview ? this.user.companyOverview : '');
this.offeredServices = this.sanitizer.bypassSecurityTrustHtml(this.user.offeredServices ? this.user.offeredServices : '');
}
/**
* Toggle professional favorite status
*/
async toggleFavorite() {
try {
const isFavorited = this.user.favoritesForUser?.includes(this.keycloakUser.email);
if (isFavorited) {
// Remove from favorites
await this.listingsService.removeFavorite(this.user.id, 'user');
this.user.favoritesForUser = this.user.favoritesForUser.filter(
email => email !== this.keycloakUser.email
);
} else {
// Add to favorites
await this.listingsService.addToFavorites(this.user.id, 'user');
if (!this.user.favoritesForUser) {
this.user.favoritesForUser = [];
}
this.user.favoritesForUser.push(this.keycloakUser.email);
this.auditService.createEvent(this.user.id, 'favorite', this.keycloakUser?.email);
}
this.cdref.detectChanges();
} catch (error) {
console.error('Error toggling favorite', error);
}
}
isAlreadyFavorite(): boolean {
if (!this.keycloakUser?.email || !this.user?.favoritesForUser) return false;
return this.user.favoritesForUser.includes(this.keycloakUser.email);
}
/**
* Show email sharing modal
*/
async showShareByEMail() {
const result = await this.emailService.showShareByEMail({
yourEmail: this.keycloakUser ? this.keycloakUser.email : '',
yourName: this.keycloakUser ? `${this.keycloakUser.firstName} ${this.keycloakUser.lastName}` : '',
recipientEmail: '',
url: environment.mailinfoUrl,
listingTitle: `${this.user.firstname} ${this.user.lastname} - ${this.user.companyName}`,
id: this.user.id,
type: 'user',
});
if (result) {
this.auditService.createEvent(this.user.id, 'email', this.keycloakUser?.email, <ShareByEMail>result);
this.messageService.addMessage({
severity: 'success',
text: 'Your Email has been sent',
duration: 5000,
});
}
}
/**
* Create audit event
*/
createEvent(eventType: EventTypeEnum) {
this.auditService.createEvent(this.user.id, eventType, this.keycloakUser?.email);
}
/**
* Share to Facebook
*/
shareToFacebook() {
const url = encodeURIComponent(window.location.href);
window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`, '_blank', 'width=600,height=400');
this.createEvent('facebook');
}
/**
* Share to Twitter/X
*/
shareToTwitter() {
const url = encodeURIComponent(window.location.href);
const text = encodeURIComponent(`Check out ${this.user.firstname} ${this.user.lastname} - ${this.user.companyName}`);
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank', 'width=600,height=400');
this.createEvent('x');
}
/**
* Share to LinkedIn
*/
shareToLinkedIn() {
const url = encodeURIComponent(window.location.href);
window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${url}`, '_blank', 'width=600,height=400');
this.createEvent('linkedin');
}
}

View File

@@ -1,110 +1,110 @@
:host ::ng-deep p {
display: block;
//margin-top: 1em;
//margin-bottom: 1em;
margin-left: 0;
margin-right: 0;
font-size: 1rem; /* oder 1rem, abhängig vom Browser und den Standardeinstellungen */
line-height: 1.5;
min-height: 1.5em;
}
:host ::ng-deep h1 {
display: block;
font-size: 2em; /* etwa 32px */
margin-top: 0.67em;
margin-bottom: 0.67em;
margin-left: 0;
margin-right: 0;
font-weight: bold;
}
:host ::ng-deep h2 {
display: block;
font-size: 1.5em; /* etwa 24px */
margin-top: 0.83em;
margin-bottom: 0.83em;
margin-left: 0;
margin-right: 0;
font-weight: bold;
}
:host ::ng-deep h3 {
display: block;
font-size: 1.17em; /* etwa 18.72px */
margin-top: 1em;
margin-bottom: 1em;
margin-left: 0;
margin-right: 0;
font-weight: bold;
}
:host ::ng-deep ul {
display: block;
list-style-type: disc; /* listet Punkte (•) vor jedem Listenelement auf */
margin-top: 1em;
margin-bottom: 1em;
margin-left: 0;
margin-right: 0;
padding-left: 40px; /* Standard-Einrückung für Listen */
}
:host ::ng-deep li {
display: list-item; /* Zeigt das Element als Listenelement an */
margin-left: 0;
margin-right: 0;
margin-top: 0;
margin-bottom: 0;
padding-left: 0;
}
button.share {
font-size: 13px;
transform: translateY(-2px) scale(1.03);
margin-right: 4px;
margin-left: 2px;
border-radius: 4px;
cursor: pointer;
i {
font-size: 15px;
}
}
.share-edit {
background-color: #0088cc;
}
.share-save {
background-color: #e60023;
}
.share-email {
background-color: #ff961c;
}
.share-facebook {
background-color: #1877f2;
}
.share-twitter {
background-color: #000000;
}
.share-linkedin {
background-color: #0a66c2;
}
:host ::ng-deep .ng-select-container {
height: 42px !important;
border-radius: 0.5rem;
.ng-value-container .ng-input {
top: 10px;
}
}
/* details.scss */
/* Stil für das Adress-Info-Feld */
.address-control {
background: rgba(255, 255, 255, 0.9);
padding: 10px;
border-radius: 5px;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.65);
font-size: 14px;
line-height: 1.4;
}
.address-control a {
color: #007bff;
text-decoration: none;
}
.address-control a:hover {
text-decoration: underline;
}
:host ::ng-deep p {
display: block;
//margin-top: 1em;
//margin-bottom: 1em;
margin-left: 0;
margin-right: 0;
font-size: 1rem; /* oder 1rem, abhängig vom Browser und den Standardeinstellungen */
line-height: 1.5;
min-height: 1.5em;
}
:host ::ng-deep h1 {
display: block;
font-size: 2em; /* etwa 32px */
margin-top: 0.67em;
margin-bottom: 0.67em;
margin-left: 0;
margin-right: 0;
font-weight: bold;
}
:host ::ng-deep h2 {
display: block;
font-size: 1.5em; /* etwa 24px */
margin-top: 0.83em;
margin-bottom: 0.83em;
margin-left: 0;
margin-right: 0;
font-weight: bold;
}
:host ::ng-deep h3 {
display: block;
font-size: 1.17em; /* etwa 18.72px */
margin-top: 1em;
margin-bottom: 1em;
margin-left: 0;
margin-right: 0;
font-weight: bold;
}
:host ::ng-deep ul {
display: block;
list-style-type: disc; /* listet Punkte (•) vor jedem Listenelement auf */
margin-top: 1em;
margin-bottom: 1em;
margin-left: 0;
margin-right: 0;
padding-left: 40px; /* Standard-Einrückung für Listen */
}
:host ::ng-deep li {
display: list-item; /* Zeigt das Element als Listenelement an */
margin-left: 0;
margin-right: 0;
margin-top: 0;
margin-bottom: 0;
padding-left: 0;
}
button.share {
font-size: 13px;
transform: translateY(-2px) scale(1.03);
margin-right: 4px;
margin-left: 2px;
border-radius: 4px;
cursor: pointer;
i {
font-size: 15px;
}
}
.share-edit {
background-color: #0088cc;
}
.share-save {
background-color: #e60023;
}
.share-email {
background-color: #ff961c;
}
.share-facebook {
background-color: #1877f2;
}
.share-twitter {
background-color: #000000;
}
.share-linkedin {
background-color: #0a66c2;
}
:host ::ng-deep .ng-select-container {
height: 42px !important;
border-radius: 0.5rem;
.ng-value-container .ng-input {
top: 10px;
}
}
/* details.scss */
/* Stil für das Adress-Info-Feld */
.address-control {
background: rgba(255, 255, 255, 0.9);
padding: 10px;
border-radius: 5px;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.65);
font-size: 14px;
line-height: 1.4;
}
.address-control a {
color: #007bff;
text-decoration: none;
}
.address-control a:hover {
text-decoration: underline;
}

View File

@@ -1,206 +1,253 @@
<header class="w-full flex justify-between items-center p-4 bg-white top-0 z-10 h-16 md:h-20">
<img src="/assets/images/header-logo.png" alt="Logo" class="h-8 md:h-10 w-auto" />
<div class="hidden md:flex items-center space-x-4">
@if(user){
<a routerLink="/account" class="text-primary-600 border border-primary-600 px-3 py-2 rounded">Account</a>
} @else {
<!-- <a routerLink="/pricing" class="text-neutral-800">Pricing</a> -->
<a routerLink="/login" [queryParams]="{ mode: 'login' }" class="text-primary-600 border border-primary-600 px-3 py-2 rounded">Log In</a>
<a routerLink="/login" [queryParams]="{ mode: 'register' }" class="text-white bg-primary-600 px-4 py-2 rounded">Sign Up</a>
<!-- <a routerLink="/login" class="text-primary-500 hover:underline">Login/Register</a> -->
}
</div>
<button (click)="toggleMenu()" class="md:hidden text-neutral-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16m-7 6h7"></path>
</svg>
</button>
</header>
<div *ngIf="isMenuOpen" class="fixed inset-0 bg-neutral-800 bg-opacity-75 z-20">
<div class="flex flex-col items-center justify-center h-full">
<!-- <a href="#" class="text-white text-xl py-2">Pricing</a> -->
@if(user){
<a routerLink="/account" class="text-white text-xl py-2">Account</a>
} @else {
<a routerLink="/login" [queryParams]="{ mode: 'login' }" class="text-white text-xl py-2">Log In</a>
<a routerLink="/login" [queryParams]="{ mode: 'register' }" class="text-white text-xl py-2">Sign Up</a>
}
<button (click)="toggleMenu()" class="text-white mt-4">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
<!-- ==== ANPASSUNGEN START ==== -->
<!-- 1. px-4 für <main> (vorher px-2 sm:px-4) -->
<main class="flex flex-col items-center justify-center px-4 w-full flex-grow">
<div
class="bg-cover-custom pb-12 md:py-20 flex flex-col w-full rounded-xl lg:rounded-2xl md:drop-shadow-custom-md lg:drop-shadow-custom-lg min-h-[calc(100vh_-_20rem)] lg:min-h-[calc(100vh_-_10rem)] max-sm:bg-contain max-sm:bg-bottom max-sm:bg-no-repeat max-sm:min-h-[60vh] max-sm:bg-primary-600"
>
<div class="flex justify-center w-full">
<!-- 3. Für Mobile: m-2 statt max-w-xs; ab sm: wieder max-width und kein Margin -->
<div class="w-full m-2 sm:m-0 sm:max-w-md md:max-w-xl lg:max-w-2xl xl:max-w-3xl">
<!-- Hero-Container -->
<section class="relative">
<!-- Dein Hintergrundbild liegt hier per CSS oder absolutem <img> -->
<!-- 1) Overlay: sorgt für Kontrast auf hellem Himmel -->
<div aria-hidden="true" class="pointer-events-none absolute inset-0"></div>
<!-- 2) Textblock -->
<div class="relative z-10 mx-auto max-w-4xl px-6 sm:px-6 py-4 sm:py-16 text-center text-white">
<h1 class="text-[1.55rem] sm:text-4xl md:text-5xl lg:text-6xl font-extrabold tracking-tight leading-tight drop-shadow-[0_2px_6px_rgba(0,0,0,0.55)]">Buy & Sell Businesses and Commercial Properties</h1>
<p class="mt-3 sm:mt-4 text-base sm:text-lg md:text-xl lg:text-2xl font-medium text-white/90 drop-shadow-[0_1.5px_4px_rgba(0,0,0,0.6)]">
Find profitable businesses for sale, commercial real estate, and franchise opportunities across the United States
</p>
</div>
</section>
<!-- Restliche Anpassungen (Innenabstände, Button-Paddings etc.) bleiben wie im vorherigen Schritt -->
<div class="search-form-container bg-white bg-opacity-80 pb-4 md:pb-6 pt-2 px-3 sm:px-4 md:px-6 rounded-lg shadow-lg w-full" [ngClass]="{ 'pt-6': aiSearch }">
@if(!aiSearch){
<div class="text-sm lg:text-base mb-1 text-center text-neutral-500 border-neutral-200 dark:text-neutral-400 dark:border-neutral-700 flex justify-between">
<ul class="flex flex-wrap -mb-px w-full">
<li class="w-[33%]">
<a
(click)="changeTab('business')"
[ngClass]="
activeTabAction === 'business'
? ['text-primary-600', 'border-primary-600', 'active', 'dark:text-primary-500', 'dark:border-primary-500']
: ['border-transparent', 'hover:text-neutral-600', 'hover:border-neutral-300', 'dark:hover:text-neutral-300']
"
class="tab-link hover:cursor-pointer inline-flex items-center justify-center px-1 py-2 md:p-4 border-b-2 rounded-t-lg"
>
<img src="/assets/images/business_logo.png" alt="Search businesses for sale" class="tab-icon w-6 h-6 md:w-7 md:h-7 mr-1 md:mr-2 object-contain" width="28" height="28" />
<span>Businesses</span>
</a>
</li>
@if ((numberOfCommercial$ | async) > 0) {
<li class="w-[33%]">
<a
(click)="changeTab('commercialProperty')"
[ngClass]="
activeTabAction === 'commercialProperty'
? ['text-primary-600', 'border-primary-600', 'active', 'dark:text-primary-500', 'dark:border-primary-500']
: ['border-transparent', 'hover:text-neutral-600', 'hover:border-neutral-300', 'dark:hover:text-neutral-300']
"
class="tab-link hover:cursor-pointer inline-flex items-center justify-center px-1 py-2 md:p-4 border-b-2 rounded-t-lg"
>
<img src="/assets/images/properties_logo.png" alt="Search commercial properties for sale" class="tab-icon w-6 h-6 md:w-7 md:h-7 mr-1 md:mr-2 object-contain" width="28" height="28" />
<span>Properties</span>
</a>
</li>
}
<li class="w-[33%]">
<a
(click)="changeTab('broker')"
[ngClass]="
activeTabAction === 'broker'
? ['text-primary-600', 'border-primary-600', 'active', 'dark:text-primary-500', 'dark:border-primary-500']
: ['border-transparent', 'hover:text-neutral-600', 'hover:border-neutral-300', 'dark:hover:text-neutral-300']
"
class="tab-link hover:cursor-pointer inline-flex items-center justify-center px-1 py-2 md:p-4 border-b-2 rounded-t-lg"
>
<img
src="/assets/images/icon_professionals.png"
alt="Search business professionals and brokers"
class="tab-icon w-6 h-6 md:w-7 md:h-7 mr-1 md:mr-2 object-contain bg-transparent"
style="mix-blend-mode: darken"
/>
<span>Professionals</span>
</a>
</li>
</ul>
</div>
} @if(criteria && !aiSearch){
<div class="w-full max-w-3xl mx-auto bg-white rounded-lg flex flex-col md:flex-row md:border md:border-neutral-300">
<div class="md:flex-none md:w-48 flex-1 md:border-r border-neutral-300 overflow-hidden mb-2 md:mb-0">
<div class="relative max-sm:border border-neutral-300 rounded-md">
<select
class="appearance-none bg-transparent w-full py-4 px-4 pr-8 focus:outline-none md:border-none rounded-md md:rounded-none min-h-[52px]"
[ngModel]="criteria.types"
(ngModelChange)="onTypesChange($event)"
[ngClass]="{ 'placeholder-selected': criteria.types.length === 0 }"
>
<option [value]="[]">{{ getPlaceholderLabel() }}</option>
@for(type of getTypes(); track type){
<option [value]="type.value">{{ type.name }}</option>
}
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-neutral-700">
<i class="fas fa-chevron-down text-xs"></i>
</div>
</div>
</div>
<div class="md:flex-auto md:w-36 flex-grow md:border-r border-neutral-300 mb-2 md:mb-0">
<div class="relative max-sm:border border-neutral-300 rounded-md">
<ng-select
class="custom md:border-none rounded-md md:rounded-none"
[multiple]="false"
[hideSelected]="true"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="cityLoading"
typeToSearchText="Please enter 2 or more characters"
[typeahead]="cityInput$"
[ngModel]="cityOrState"
(ngModelChange)="setCityOrState($event)"
placeholder="Enter City or State ..."
groupBy="type"
>
@for (city of cities$ | async; track city.id) { @let state = city.type==='city'?city.content.state:''; @let separator = city.type==='city'?' - ':'';
<ng-option [value]="city">{{ city.content.name }}{{ separator }}{{ state }}</ng-option>
}
</ng-select>
</div>
</div>
@if (criteria.radius && !aiSearch){
<div class="md:flex-none md:w-36 flex-1 md:border-r border-neutral-300 mb-2 md:mb-0">
<div class="relative max-sm:border border-neutral-300 rounded-md">
<select
class="appearance-none bg-transparent w-full py-4 px-4 pr-8 focus:outline-none md:border-none rounded-md md:rounded-none min-h-[52px]"
(ngModelChange)="onRadiusChange($event)"
[ngModel]="criteria.radius"
[ngClass]="{ 'placeholder-selected': !criteria.radius }"
>
<option [value]="null">City Radius</option>
@for(dist of selectOptions.distances; track dist){
<option [value]="dist.value">{{ dist.name }}</option>
}
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-neutral-700">
<i class="fas fa-chevron-down text-xs"></i>
</div>
</div>
</div>
}
<div class="bg-primary-500 hover:bg-primary-600 max-sm:rounded-md search-button">
@if( numberOfResults$){
<button class="w-full h-full text-white font-bold py-2 px-4 md:py-3 md:px-6 focus:outline-none rounded-md md:rounded-none min-h-[52px] flex items-center justify-center gap-2" (click)="search()">
<i class="fas fa-search"></i>
<span>Search {{ numberOfResults$ | async }}</span>
</button>
}@else {
<button class="w-full h-full text-white font-bold py-2 px-4 md:py-3 md:px-6 focus:outline-none rounded-md md:rounded-none min-h-[52px] flex items-center justify-center gap-2" (click)="search()">
<i class="fas fa-search"></i>
<span>Search</span>
</button>
}
</div>
</div>
}
</div>
</div>
</div>
</div>
<!-- FAQ Section for SEO/AEO -->
<!-- <div class="w-full px-4 mt-12 max-w-4xl mx-auto">
<app-faq [faqItems]="faqItems"></app-faq>
</div> -->
</main>
<!-- ==== ANPASSUNGEN ENDE ==== -->
<header class="w-full flex justify-between items-center p-4 bg-white top-0 z-10 h-16 md:h-20">
<img src="/assets/images/header-logo.png" alt="Logo" class="h-8 md:h-10 w-auto" />
<div class="hidden md:flex items-center space-x-4">
@if(user){
<a routerLink="/account" class="text-primary-600 border border-primary-600 px-3 py-2 rounded">Account</a>
} @else {
<!-- <a routerLink="/pricing" class="text-neutral-800">Pricing</a> -->
<a routerLink="/login" [queryParams]="{ mode: 'login' }" class="text-primary-600 border border-primary-600 px-3 py-2 rounded">Log In</a>
<a routerLink="/login" [queryParams]="{ mode: 'register' }" class="text-white bg-primary-600 px-4 py-2 rounded">Sign Up</a>
<!-- <a routerLink="/login" class="text-primary-500 hover:underline">Login/Register</a> -->
}
</div>
<button
(click)="toggleMenu()"
class="md:hidden text-neutral-600"
aria-label="Open navigation menu"
[attr.aria-expanded]="isMenuOpen"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16m-7 6h7"></path>
</svg>
</button>
</header>
<div *ngIf="isMenuOpen" class="fixed inset-0 bg-neutral-800 bg-opacity-75 z-20">
<div class="flex flex-col items-center justify-center h-full">
<!-- <a href="#" class="text-white text-xl py-2">Pricing</a> -->
@if(user){
<a routerLink="/account" class="text-white text-xl py-2">Account</a>
} @else {
<a routerLink="/login" [queryParams]="{ mode: 'login' }" class="text-white text-xl py-2">Log In</a>
<a routerLink="/login" [queryParams]="{ mode: 'register' }" class="text-white text-xl py-2">Sign Up</a>
}
<button
(click)="toggleMenu()"
class="text-white mt-4"
aria-label="Close navigation menu"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
<!-- ==== ANPASSUNGEN START ==== -->
<!-- 1. px-4 für <main> (vorher px-2 sm:px-4) -->
<main class="flex flex-col items-center justify-center px-4 w-full flex-grow">
<div
class="relative overflow-hidden pb-12 md:py-20 flex flex-col w-full rounded-xl lg:rounded-2xl md:drop-shadow-custom-md lg:drop-shadow-custom-lg min-h-[calc(100vh_-_20rem)] lg:min-h-[calc(100vh_-_10rem)] max-sm:min-h-[60vh] max-sm:bg-primary-600"
>
<!-- Optimized Background Image -->
<picture class="absolute inset-0 w-full h-full z-0 pointer-events-none">
<source srcset="/assets/images/flags_bg.avif" type="image/avif">
<img
width="2500"
height="1285"
fetchpriority="high"
loading="eager"
src="/assets/images/flags_bg.jpg"
alt=""
class="w-full h-full object-cover"
>
</picture>
<!-- Gradient Overlay -->
<div class="absolute inset-0 bg-gradient-to-b from-black/35 via-black/15 to-transparent z-0 pointer-events-none"></div>
<div class="flex justify-center w-full relative z-10">
<!-- 3. Für Mobile: m-2 statt max-w-xs; ab sm: wieder max-width und kein Margin -->
<div class="w-full m-2 sm:m-0 sm:max-w-md md:max-w-xl lg:max-w-2xl xl:max-w-3xl">
<!-- Hero-Container -->
<section class="relative">
<!-- Dein Hintergrundbild liegt hier per CSS oder absolutem <img> -->
<!-- 1) Overlay: sorgt für Kontrast auf hellem Himmel (Previous overlay removed, using new global overlay) -->
<!-- <div aria-hidden="true" class="pointer-events-none absolute inset-0"></div> -->
<!-- 2) Textblock -->
<div class="relative z-10 mx-auto max-w-4xl px-6 sm:px-6 py-4 sm:py-16 text-center text-white">
<h1 class="text-[1.55rem] sm:text-4xl md:text-5xl lg:text-6xl font-extrabold tracking-tight leading-tight drop-shadow-[0_2px_6px_rgba(0,0,0,0.55)]">Buy & Sell Businesses and Commercial Properties</h1>
<p class="mt-3 sm:mt-4 text-base sm:text-lg md:text-xl lg:text-2xl font-medium text-white/90 drop-shadow-[0_1.5px_4px_rgba(0,0,0,0.6)]">
Find profitable businesses for sale, commercial real estate, and franchise opportunities across the United States
</p>
</div>
</section>
<!-- Restliche Anpassungen (Innenabstände, Button-Paddings etc.) bleiben wie im vorherigen Schritt -->
<div class="search-form-container bg-white bg-opacity-80 pb-4 md:pb-6 pt-2 px-3 sm:px-4 md:px-6 rounded-lg shadow-lg w-full" [ngClass]="{ 'pt-6': aiSearch }">
@if(!aiSearch){
<div class="text-sm lg:text-base mb-1 text-center text-neutral-500 border-neutral-200 dark:text-neutral-400 dark:border-neutral-700 flex justify-between">
<ul class="flex flex-wrap -mb-px w-full" role="tablist">
<li class="w-[33%]" role="presentation">
<button
type="button"
role="tab"
[attr.aria-selected]="activeTabAction === 'business'"
(click)="changeTab('business')"
[ngClass]="
activeTabAction === 'business'
? ['text-primary-600', 'border-primary-600', 'active', 'dark:text-primary-500', 'dark:border-primary-500']
: ['border-transparent', 'hover:text-neutral-600', 'hover:border-neutral-300', 'dark:hover:text-neutral-300']
"
class="tab-link w-full hover:cursor-pointer inline-flex items-center justify-center px-3 py-3 md:p-4 border-b-2 rounded-t-lg bg-transparent"
>
<img src="/assets/images/business_logo.png" alt="" aria-hidden="true" class="tab-icon w-6 h-6 md:w-7 md:h-7 mr-1 md:mr-2 object-contain" width="28" height="28" />
<span>Businesses</span>
</button>
</li>
@if ((numberOfCommercial$ | async) > 0) {
<li class="w-[33%]" role="presentation">
<button
type="button"
role="tab"
[attr.aria-selected]="activeTabAction === 'commercialProperty'"
(click)="changeTab('commercialProperty')"
[ngClass]="
activeTabAction === 'commercialProperty'
? ['text-primary-600', 'border-primary-600', 'active', 'dark:text-primary-500', 'dark:border-primary-500']
: ['border-transparent', 'hover:text-neutral-600', 'hover:border-neutral-300', 'dark:hover:text-neutral-300']
"
class="tab-link w-full hover:cursor-pointer inline-flex items-center justify-center px-3 py-3 md:p-4 border-b-2 rounded-t-lg bg-transparent"
>
<img src="/assets/images/properties_logo.png" alt="" aria-hidden="true" class="tab-icon w-6 h-6 md:w-7 md:h-7 mr-1 md:mr-2 object-contain" width="28" height="28" />
<span>Properties</span>
</button>
</li>
}
<li class="w-[33%]" role="presentation">
<button
type="button"
role="tab"
[attr.aria-selected]="activeTabAction === 'broker'"
(click)="changeTab('broker')"
[ngClass]="
activeTabAction === 'broker'
? ['text-primary-600', 'border-primary-600', 'active', 'dark:text-primary-500', 'dark:border-primary-500']
: ['border-transparent', 'hover:text-neutral-600', 'hover:border-neutral-300', 'dark:hover:text-neutral-300']
"
class="tab-link w-full hover:cursor-pointer inline-flex items-center justify-center px-3 py-3 md:p-4 border-b-2 rounded-t-lg bg-transparent"
>
<img
src="/assets/images/icon_professionals.png"
alt=""
aria-hidden="true"
class="tab-icon w-6 h-6 md:w-7 md:h-7 mr-1 md:mr-2 object-contain"
style="mix-blend-mode: darken"
width="28" height="28"
/>
<span>Professionals</span>
</button>
</li>
</ul>
</div>
} @if(criteria && !aiSearch){
<div class="w-full max-w-3xl mx-auto bg-white rounded-lg flex flex-col md:flex-row md:border md:border-neutral-300">
<div class="md:flex-none md:w-48 flex-1 md:border-r border-neutral-300 overflow-hidden mb-2 md:mb-0">
<div class="relative max-sm:border border-neutral-300 rounded-md">
<label for="type-filter" class="sr-only">Filter by type</label>
<select
id="type-filter"
aria-label="Filter by type"
class="appearance-none bg-transparent w-full py-4 px-4 pr-8 focus:outline-none md:border-none rounded-md md:rounded-none min-h-[52px]"
[ngModel]="criteria.types"
(ngModelChange)="onTypesChange($event)"
[ngClass]="{ 'placeholder-selected': criteria.types.length === 0 }"
>
<option [value]="[]">{{ getPlaceholderLabel() }}</option>
@for(type of getTypes(); track type){
<option [value]="type.value">{{ type.name }}</option>
}
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-neutral-700">
<i class="fas fa-chevron-down text-xs"></i>
</div>
</div>
</div>
<div class="md:flex-auto md:w-36 flex-grow md:border-r border-neutral-300 mb-2 md:mb-0">
<div class="relative max-sm:border border-neutral-300 rounded-md">
<label for="location-search" class="sr-only">Search by city or state</label>
<ng-select
class="custom md:border-none rounded-md md:rounded-none"
[multiple]="false"
[hideSelected]="true"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="cityLoading"
typeToSearchText="Please enter 2 or more characters"
[typeahead]="cityInput$"
[ngModel]="cityOrState"
(ngModelChange)="setCityOrState($event)"
placeholder="Enter City or State ..."
groupBy="type"
labelForId="location-search"
aria-label="Search by city or state"
>
@for (city of cities$ | async; track city.id) { @let state = city.type==='city'?city.content.state:''; @let separator = city.type==='city'?' - ':'';
<ng-option [value]="city">{{ city.content.name }}{{ separator }}{{ state }}</ng-option>
}
</ng-select>
</div>
</div>
@if (criteria.radius && !aiSearch){
<div class="md:flex-none md:w-36 flex-1 md:border-r border-neutral-300 mb-2 md:mb-0">
<div class="relative max-sm:border border-neutral-300 rounded-md">
<label for="radius-filter" class="sr-only">Filter by radius</label>
<select
id="radius-filter"
aria-label="Filter by radius"
class="appearance-none bg-transparent w-full py-4 px-4 pr-8 focus:outline-none md:border-none rounded-md md:rounded-none min-h-[52px]"
(ngModelChange)="onRadiusChange($event)"
[ngModel]="criteria.radius"
[ngClass]="{ 'placeholder-selected': !criteria.radius }"
>
<option [value]="null">City Radius</option>
@for(dist of selectOptions.distances; track dist){
<option [value]="dist.value">{{ dist.name }}</option>
}
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-neutral-700">
<i class="fas fa-chevron-down text-xs"></i>
</div>
</div>
</div>
}
<div class="bg-primary-500 hover:bg-primary-600 max-sm:rounded-md search-button">
@if( numberOfResults$){
<button aria-label="Search listings" class="w-full h-full text-white font-bold py-2 px-4 md:py-3 md:px-6 focus:outline-none rounded-md md:rounded-none min-h-[52px] flex items-center justify-center gap-2" (click)="search()">
<i class="fas fa-search" aria-hidden="true"></i>
<span>Search {{ numberOfResults$ | async }}</span>
</button>
}@else {
<button aria-label="Search listings" class="w-full h-full text-white font-bold py-2 px-4 md:py-3 md:px-6 focus:outline-none rounded-md md:rounded-none min-h-[52px] flex items-center justify-center gap-2" (click)="search()">
<i class="fas fa-search" aria-hidden="true"></i>
<span>Search</span>
</button>
}
</div>
</div>
}
</div>
</div>
</div>
</div>
<!-- FAQ Section for SEO/AEO -->
<!-- <div class="w-full px-4 mt-12 max-w-4xl mx-auto">
<app-faq [faqItems]="faqItems"></app-faq>
</div> -->
</main>
<!-- ==== ANPASSUNGEN ENDE ==== -->

View File

@@ -1,271 +1,252 @@
.bg-cover-custom {
position: relative;
// Prioritize AVIF format (69KB) over JPG (26MB)
background-image: url('/assets/images/flags_bg.avif');
background-size: cover;
background-position: center;
border-radius: 20px;
// Fallback for browsers that don't support AVIF
@supports not (background-image: url('/assets/images/flags_bg.avif')) {
background-image: url('/assets/images/flags_bg.jpg');
}
// Add gradient overlay for better text contrast
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.35) 0%, rgba(0, 0, 0, 0.15) 40%, rgba(0, 0, 0, 0.05) 70%, rgba(0, 0, 0, 0) 100%);
border-radius: 20px;
pointer-events: none;
z-index: 1;
}
// Ensure content stays above overlay
> * {
position: relative;
z-index: 2;
}
}
select:not([size]) {
background-image: unset;
}
[type='text'],
[type='email'],
[type='url'],
[type='password'],
[type='number'],
[type='date'],
[type='datetime-local'],
[type='month'],
[type='search'],
[type='tel'],
[type='time'],
[type='week'],
[multiple],
textarea,
select {
border: unset;
}
.toggle-checkbox:checked {
right: 0;
border-color: rgb(125 211 252);
}
.toggle-checkbox:checked + .toggle-label {
background-color: rgb(125 211 252);
}
:host ::ng-deep .ng-select.ng-select-single .ng-select-container {
min-height: 52px;
border: none;
background-color: transparent;
.ng-value-container .ng-input {
top: 12px;
}
span.ng-arrow-wrapper {
display: none;
}
}
select {
color: #000; /* Standard-Textfarbe für das Dropdown */
// background-color: #fff; /* Hintergrundfarbe für das Dropdown */
}
select option {
color: #000; /* Textfarbe für Dropdown-Optionen */
}
select.placeholder-selected {
color: #999; /* Farbe für den Platzhalter */
}
input::placeholder {
color: #555; /* Dunkleres Grau */
opacity: 1; /* Stellt sicher, dass die Deckkraft 100% ist */
}
/* Stellt sicher, dass die Optionen im Dropdown immer schwarz sind */
select:focus option,
select:hover option {
color: #000 !important;
}
input[type='text'][name='aiSearchText'] {
padding: 14px; /* Innerer Abstand */
font-size: 16px; /* Schriftgröße anpassen */
box-sizing: border-box; /* Padding und Border in die Höhe und Breite einrechnen */
height: 48px;
}
// Enhanced Search Button Styling
.search-button {
position: relative;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
filter: brightness(1.05);
}
&:active {
transform: scale(0.98);
}
// Ripple effect
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition:
width 0.6s,
height 0.6s;
pointer-events: none;
}
&:active::after {
width: 300px;
height: 300px;
}
}
// Tab Icon Styling
.tab-icon {
font-size: 1rem;
margin-right: 0.5rem;
transition: transform 0.2s ease;
}
.tab-link {
transition: all 0.2s ease-in-out;
&:hover .tab-icon {
transform: scale(1.15);
}
}
// Input Field Hover Effects
select,
.ng-select {
transition: all 0.2s ease-in-out;
&:hover {
background-color: rgba(243, 244, 246, 0.8);
}
&:focus,
&:focus-within {
background-color: white;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
}
// Smooth tab transitions
.tab-content {
animation: fadeInUp 0.3s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Trust section container - more prominent
.trust-section-container {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
transition:
box-shadow 0.3s ease,
transform 0.3s ease;
&:hover {
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12);
}
}
// Trust badge animations - subtle lowkey style
.trust-badge {
transition: opacity 0.2s ease;
&:hover {
opacity: 0.8;
}
}
.trust-icon {
transition:
background-color 0.2s ease,
color 0.2s ease;
}
.trust-badge:hover .trust-icon {
background-color: rgb(229, 231, 235); // gray-200
color: rgb(75, 85, 99); // gray-600
}
// Stat counter animation - minimal
.stat-number {
transition: color 0.2s ease;
&:hover {
color: rgb(55, 65, 81); // gray-700 darker
}
}
// Search form container enhancement
.search-form-container {
transition: all 0.3s ease;
// KEIN backdrop-filter hier!
background-color: rgba(255, 255, 255, 0.95) !important;
border: 1px solid rgba(0, 0, 0, 0.1); // Dunklerer Rand für Kontrast
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
// Falls Firefox das Element "vergisst", erzwingen wir eine Ebene
transform: translateZ(0);
opacity: 1 !important;
visibility: visible !important;
}
// Header button improvements
header {
a {
transition: all 0.2s ease-in-out;
&.text-blue-600.border.border-blue-600 {
// Log In button
&:hover {
background-color: rgba(37, 99, 235, 0.05);
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.15);
}
&:active {
transform: scale(0.98);
}
}
&.bg-blue-600 {
// Register button
&:hover {
background-color: rgb(29, 78, 216);
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
filter: brightness(1.05);
}
&:active {
transform: scale(0.98);
}
}
}
}
select:not([size]) {
background-image: unset;
}
[type='text'],
[type='email'],
[type='url'],
[type='password'],
[type='number'],
[type='date'],
[type='datetime-local'],
[type='month'],
[type='search'],
[type='tel'],
[type='time'],
[type='week'],
[multiple],
textarea,
select {
border: unset;
}
.toggle-checkbox:checked {
right: 0;
border-color: rgb(125 211 252);
}
.toggle-checkbox:checked + .toggle-label {
background-color: rgb(125 211 252);
}
:host ::ng-deep .ng-select.ng-select-single .ng-select-container {
min-height: 52px;
border: none;
background-color: transparent;
.ng-value-container .ng-input {
top: 12px;
}
span.ng-arrow-wrapper {
display: none;
}
}
select {
color: #000; /* Standard-Textfarbe für das Dropdown */
// background-color: #fff; /* Hintergrundfarbe für das Dropdown */
}
select option {
color: #000; /* Textfarbe für Dropdown-Optionen */
}
select.placeholder-selected {
color: #999; /* Farbe für den Platzhalter */
}
input::placeholder {
color: #555; /* Dunkleres Grau */
opacity: 1; /* Stellt sicher, dass die Deckkraft 100% ist */
}
/* Stellt sicher, dass die Optionen im Dropdown immer schwarz sind */
select:focus option,
select:hover option {
color: #000 !important;
}
input[type='text'][name='aiSearchText'] {
padding: 14px; /* Innerer Abstand */
font-size: 16px; /* Schriftgröße anpassen */
box-sizing: border-box; /* Padding und Border in die Höhe und Breite einrechnen */
height: 48px;
}
// Enhanced Search Button Styling
.search-button {
position: relative;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
filter: brightness(1.05);
}
&:active {
transform: scale(0.98);
}
// Ripple effect
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition:
width 0.6s,
height 0.6s;
pointer-events: none;
}
&:active::after {
width: 300px;
height: 300px;
}
}
// Tab Icon Styling
.tab-icon {
font-size: 1rem;
margin-right: 0.5rem;
transition: transform 0.2s ease;
}
.tab-link {
transition: all 0.2s ease-in-out;
&:hover .tab-icon {
transform: scale(1.15);
}
}
// Input Field Hover Effects
select,
.ng-select {
transition: all 0.2s ease-in-out;
&:hover {
background-color: rgba(243, 244, 246, 0.8);
}
&:focus,
&:focus-within {
background-color: white;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
}
// Smooth tab transitions
.tab-content {
animation: fadeInUp 0.3s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// Trust section container - more prominent
.trust-section-container {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
transition:
box-shadow 0.3s ease,
transform 0.3s ease;
&:hover {
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12);
}
}
// Trust badge animations - subtle lowkey style
.trust-badge {
transition: opacity 0.2s ease;
&:hover {
opacity: 0.8;
}
}
.trust-icon {
transition:
background-color 0.2s ease,
color 0.2s ease;
}
.trust-badge:hover .trust-icon {
background-color: rgb(229, 231, 235); // gray-200
color: rgb(75, 85, 99); // gray-600
}
// Stat counter animation - minimal
.stat-number {
transition: color 0.2s ease;
&:hover {
color: rgb(55, 65, 81); // gray-700 darker
}
}
// Search form container enhancement
.search-form-container {
transition: all 0.3s ease;
// KEIN backdrop-filter hier!
background-color: rgba(255, 255, 255, 0.95) !important;
border: 1px solid rgba(0, 0, 0, 0.1); // Dunklerer Rand für Kontrast
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
// Falls Firefox das Element "vergisst", erzwingen wir eine Ebene
transform: translateZ(0);
opacity: 1 !important;
visibility: visible !important;
}
// Header button improvements
header {
a {
transition: all 0.2s ease-in-out;
&.text-blue-600.border.border-blue-600 {
// Log In button
&:hover {
background-color: rgba(37, 99, 235, 0.05);
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.15);
}
&:active {
transform: scale(0.98);
}
}
&.bg-blue-600 {
// Register button
&:hover {
background-color: rgb(29, 78, 216);
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
filter: brightness(1.05);
}
&:active {
transform: scale(0.98);
}
}
}
}
// Screen reader only - visually hidden but accessible
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}

View File

@@ -1,345 +1,345 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, ElementRef, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { NgSelectModule } from '@ng-select/ng-select';
import { UntilDestroy } from '@ngneat/until-destroy';
import { catchError, concat, distinctUntilChanged, Observable, of, Subject, switchMap, tap } from 'rxjs';
import { BusinessListingCriteria, CityAndStateResult, CommercialPropertyListingCriteria, GeoResult, KeycloakUser, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
import { FaqComponent, FAQItem } from '../../components/faq/faq.component';
import { ModalService } from '../../components/search-modal/modal.service';
import { TooltipComponent } from '../../components/tooltip/tooltip.component';
import { AiService } from '../../services/ai.service';
import { AuthService } from '../../services/auth.service';
import { FilterStateService } from '../../services/filter-state.service';
import { GeoService } from '../../services/geo.service';
import { ListingsService } from '../../services/listings.service';
import { SearchService } from '../../services/search.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { SeoService } from '../../services/seo.service';
import { UserService } from '../../services/user.service';
import { map2User } from '../../utils/utils';
@UntilDestroy()
@Component({
selector: 'app-home',
standalone: true,
imports: [CommonModule, FormsModule, RouterModule, NgSelectModule, FaqComponent],
templateUrl: './home.component.html',
styleUrl: './home.component.scss',
})
export class HomeComponent {
placeholders: string[] = ['Property close to Houston less than 10M', 'Franchise business in Austin price less than 500K'];
activeTabAction: 'business' | 'commercialProperty' | 'broker' = 'business';
type: string;
maxPrice: string;
minPrice: string;
criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
states = [];
isMenuOpen = false;
user: KeycloakUser;
prompt: string;
cities$: Observable<CityAndStateResult[]>;
cityLoading = false;
cityInput$ = new Subject<string>();
cityOrState = undefined;
numberOfResults$: Observable<number>;
numberOfBroker$: Observable<number>;
numberOfCommercial$: Observable<number>;
aiSearch = false;
aiSearchText = '';
aiSearchFailed = false;
loadingAi = false;
@ViewChild('aiSearchInput', { static: false }) searchInput!: ElementRef;
typingSpeed: number = 100;
pauseTime: number = 2000;
index: number = 0;
charIndex: number = 0;
typingInterval: any;
showInput: boolean = true;
tooltipTargetBeta = 'tooltipTargetBeta';
// FAQ data optimized for AEO (Answer Engine Optimization) and Featured Snippets
faqItems: FAQItem[] = [
{
question: 'How do I buy a business on BizMatch?',
answer: '<p><strong>Buying a business on BizMatch involves 6 simple steps:</strong></p><ol><li><strong>Browse Listings:</strong> Search our marketplace using filters for industry, location, and price range</li><li><strong>Review Details:</strong> Examine financial information, business operations, and growth potential</li><li><strong>Contact Seller:</strong> Reach out directly through our secure messaging platform</li><li><strong>Due Diligence:</strong> Review financial statements, contracts, and legal documents</li><li><strong>Negotiate Terms:</strong> Work with the seller to agree on price and transition details</li><li><strong>Close Deal:</strong> Complete the purchase with legal and financial advisors</li></ol><p>We recommend working with experienced business brokers and conducting thorough due diligence before making any purchase.</p>'
},
{
question: 'How much does it cost to list a business for sale?',
answer: '<p><strong>BizMatch offers flexible pricing options:</strong></p><ul><li><strong>Free Basic Listing:</strong> Post your business with essential details at no cost</li><li><strong>Premium Listing:</strong> Enhanced visibility with featured placement and priority support</li><li><strong>Broker Packages:</strong> Professional tools for business brokers and agencies</li></ul><p>Contact our team for detailed pricing information tailored to your specific needs.</p>'
},
{
question: 'What types of businesses can I find on BizMatch?',
answer: '<p><strong>BizMatch features businesses across all major industries:</strong></p><ul><li><strong>Food & Hospitality:</strong> Restaurants, cafes, bars, hotels, catering services</li><li><strong>Retail:</strong> Stores, boutiques, online shops, franchises</li><li><strong>Service Businesses:</strong> Consulting firms, cleaning services, healthcare practices</li><li><strong>Manufacturing:</strong> Production facilities, distribution centers, warehouses</li><li><strong>E-commerce:</strong> Online businesses, digital products, subscription services</li><li><strong>Commercial Real Estate:</strong> Office buildings, retail spaces, industrial properties</li></ul><p>Our marketplace serves all business sizes from small local operations to large enterprises across the United States.</p>'
},
{
question: 'How do I know if a business listing is legitimate?',
answer: '<p><strong>Yes, BizMatch verifies all listings.</strong> Here\'s how we ensure legitimacy:</p><ol><li><strong>Seller Verification:</strong> All users must verify their identity and contact information</li><li><strong>Listing Review:</strong> Our team reviews each listing for completeness and accuracy</li><li><strong>Documentation Check:</strong> We verify business registration and ownership documents</li><li><strong>Transparent Communication:</strong> All conversations are logged through our secure platform</li></ol><p><strong>Additional steps you should take:</strong></p><ul><li>Review financial statements and tax returns</li><li>Visit the business location in person</li><li>Consult with legal and financial advisors</li><li>Work with licensed business brokers when appropriate</li><li>Conduct background checks on sellers</li></ul>'
},
{
question: 'Can I sell commercial property on BizMatch?',
answer: '<p><strong>Yes!</strong> BizMatch is a full-service marketplace for both businesses and commercial real estate.</p><p><strong>Property types you can list:</strong></p><ul><li>Office buildings and professional spaces</li><li>Retail locations and shopping centers</li><li>Warehouses and distribution facilities</li><li>Industrial properties and manufacturing plants</li><li>Mixed-use developments</li><li>Land for commercial development</li></ul><p>Our platform connects you with qualified buyers, investors, and commercial real estate professionals actively searching for investment opportunities.</p>'
},
{
question: 'What information should I include when listing my business?',
answer: '<p><strong>A complete listing should include these essential details:</strong></p><ol><li><strong>Financial Information:</strong> Asking price, annual revenue, cash flow, profit margins</li><li><strong>Business Operations:</strong> Years established, number of employees, hours of operation</li><li><strong>Description:</strong> Detailed overview of products/services, customer base, competitive advantages</li><li><strong>Industry Category:</strong> Specific business type and market segment</li><li><strong>Location Details:</strong> City, state, demographic information</li><li><strong>Assets Included:</strong> Equipment, inventory, real estate, intellectual property</li><li><strong>Visual Content:</strong> High-quality photos of business premises and operations</li><li><strong>Growth Potential:</strong> Expansion opportunities and market trends</li></ol><p><strong>Pro tip:</strong> The more detailed and transparent your listing, the more interest it will generate from serious, qualified buyers.</p>'
},
{
question: 'How long does it take to sell a business?',
answer: '<p><strong>Most businesses sell within 6 to 12 months.</strong> The timeline varies based on several factors:</p><p><strong>Factors that speed up sales:</strong></p><ul><li>Realistic pricing based on professional valuation</li><li>Complete and organized financial documentation</li><li>Strong business performance and growth trends</li><li>Attractive location and market conditions</li><li>Experienced business broker representation</li><li>Flexible seller terms and financing options</li></ul><p><strong>Timeline breakdown:</strong></p><ol><li><strong>Months 1-2:</strong> Preparation and listing creation</li><li><strong>Months 3-6:</strong> Marketing and buyer qualification</li><li><strong>Months 7-10:</strong> Negotiations and due diligence</li><li><strong>Months 11-12:</strong> Closing and transition</li></ol>'
},
{
question: 'What is business valuation and why is it important?',
answer: '<p><strong>Business valuation is the process of determining the economic worth of a company.</strong> It calculates the fair market value based on financial performance, assets, and market conditions.</p><p><strong>Why valuation matters:</strong></p><ul><li><strong>Realistic Pricing:</strong> Attracts serious buyers and prevents extended time on market</li><li><strong>Negotiation Power:</strong> Provides data-driven justification for asking price</li><li><strong>Buyer Confidence:</strong> Professional valuations increase trust and credibility</li><li><strong>Financing Approval:</strong> Banks require valuations for business acquisition loans</li></ul><p><strong>Valuation methods include:</strong></p><ol><li><strong>Asset-Based:</strong> Total value of business assets minus liabilities</li><li><strong>Income-Based:</strong> Projected future earnings and cash flow</li><li><strong>Market-Based:</strong> Comparison to similar business sales</li><li><strong>Multiple of Earnings:</strong> Revenue or profit multiplied by industry-standard factor</li></ol>'
},
{
question: 'Do I need a business broker to buy or sell a business?',
answer: '<p><strong>No, but brokers are highly recommended.</strong> You can conduct transactions directly through BizMatch, but professional brokers provide significant advantages:</p><p><strong>Benefits of using a business broker:</strong></p><ul><li><strong>Expert Valuation:</strong> Accurate pricing based on market data and analysis</li><li><strong>Marketing Expertise:</strong> Professional listing creation and buyer outreach</li><li><strong>Qualified Buyers:</strong> Pre-screening to ensure financial capability and serious interest</li><li><strong>Negotiation Skills:</strong> Experience handling complex deal structures and terms</li><li><strong>Confidentiality:</strong> Protect sensitive information during the sales process</li><li><strong>Legal Compliance:</strong> Navigate regulations, contracts, and disclosures</li><li><strong>Time Savings:</strong> Handle paperwork, communications, and coordination</li></ul><p>BizMatch connects you with licensed brokers in your area, or you can manage the transaction yourself using our secure platform and resources.</p>'
},
{
question: 'What financing options are available for buying a business?',
answer: '<p><strong>Business buyers have multiple financing options:</strong></p><ol><li><strong>SBA 7(a) Loans:</strong> Government-backed loans with favorable terms<ul><li>Down payment as low as 10%</li><li>Loan amounts up to $5 million</li><li>Competitive interest rates</li><li>Terms up to 10-25 years</li></ul></li><li><strong>Conventional Bank Financing:</strong> Traditional business acquisition loans<ul><li>Typically require 20-30% down payment</li><li>Based on creditworthiness and business performance</li></ul></li><li><strong>Seller Financing:</strong> Owner provides loan to buyer<ul><li>More flexible terms and requirements</li><li>Often combined with other financing</li><li>Typically 10-30% of purchase price</li></ul></li><li><strong>Investor Partnerships:</strong> Equity financing from partners<ul><li>Shared ownership and profits</li><li>No personal debt obligation</li></ul></li><li><strong>Personal Savings:</strong> Self-funded purchase<ul><li>No interest or loan payments</li><li>Full ownership from day one</li></ul></li></ol><p><strong>Most buyers use a combination of these options</strong> to structure the optimal deal for their situation.</p>'
}
];
constructor(
private router: Router,
private modalService: ModalService,
private searchService: SearchService,
private activatedRoute: ActivatedRoute,
public selectOptions: SelectOptionsService,
private geoService: GeoService,
public cdRef: ChangeDetectorRef,
private listingService: ListingsService,
private userService: UserService,
private aiService: AiService,
private authService: AuthService,
private filterStateService: FilterStateService,
private seoService: SeoService,
) { }
async ngOnInit() {
// Flowbite is now initialized once in AppComponent
// Set SEO meta tags for home page
this.seoService.updateMetaTags({
title: 'BizMatch - Buy & Sell Businesses and Commercial Properties',
description: 'Find profitable businesses for sale, commercial real estate, and franchise opportunities across the United States. Browse thousands of listings from verified sellers and brokers.',
keywords: 'business for sale, businesses for sale, buy business, sell business, commercial property, commercial real estate, franchise opportunities, business broker, business marketplace',
type: 'website'
});
// Add Organization schema for brand identity and FAQ schema for AEO
const organizationSchema = this.seoService.generateOrganizationSchema();
const faqSchema = this.seoService.generateFAQPageSchema(
this.faqItems.map(item => ({
question: item.question,
answer: item.answer
}))
);
// Add HowTo schema for buying a business
const howToSchema = this.seoService.generateHowToSchema({
name: 'How to Buy a Business on BizMatch',
description: 'Step-by-step guide to finding and purchasing your ideal business through BizMatch marketplace',
totalTime: 'PT45M',
steps: [
{
name: 'Browse Business Listings',
text: 'Search through thousands of verified business listings using our advanced filters. Filter by industry, location, price range, revenue, and more to find businesses that match your criteria.'
},
{
name: 'Review Business Details',
text: 'Examine the business financials, including annual revenue, cash flow, asking price, and years established. Read the detailed business description and view photos of the operation.'
},
{
name: 'Contact the Seller',
text: 'Use our secure messaging system to contact the seller or business broker directly. Request additional information, financial documents, or schedule a site visit to see the business in person.'
},
{
name: 'Conduct Due Diligence',
text: 'Review all financial statements, tax returns, lease agreements, and legal documents. Verify the business information, inspect the physical location, and consult with legal and financial advisors.'
},
{
name: 'Make an Offer',
text: 'Submit a formal offer based on your valuation and due diligence findings. Negotiate terms including purchase price, payment structure, transition period, and any contingencies.'
},
{
name: 'Close the Transaction',
text: 'Work with attorneys and escrow services to finalize all legal documents, transfer ownership, and complete the purchase. The seller will transfer assets, train you on operations, and help with the transition.'
}
]
});
// Add SearchBox schema for Sitelinks Search
const searchBoxSchema = this.seoService.generateSearchBoxSchema();
this.seoService.injectMultipleSchemas([organizationSchema, faqSchema, howToSchema, searchBoxSchema]);
// Clear all filters and sort options on initial load
this.filterStateService.resetCriteria('businessListings');
this.filterStateService.resetCriteria('commercialPropertyListings');
this.filterStateService.resetCriteria('brokerListings');
this.filterStateService.updateSortBy('businessListings', null);
this.filterStateService.updateSortBy('commercialPropertyListings', null);
this.filterStateService.updateSortBy('brokerListings', null);
// Initialize criteria for the default tab
this.criteria = this.filterStateService.getCriteria('businessListings');
this.numberOfBroker$ = this.userService.getNumberOfBroker(this.filterStateService.getCriteria('brokerListings') as UserListingCriteria);
this.numberOfCommercial$ = this.listingService.getNumberOfListings('commercialProperty');
const token = await this.authService.getToken();
this.user = map2User(token);
this.loadCities();
this.setTotalNumberOfResults();
}
changeTab(tabname: 'business' | 'commercialProperty' | 'broker') {
this.activeTabAction = tabname;
this.cityOrState = null;
const tabToListingType = {
business: 'businessListings',
commercialProperty: 'commercialPropertyListings',
broker: 'brokerListings',
};
this.criteria = this.filterStateService.getCriteria(tabToListingType[tabname] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings');
this.setTotalNumberOfResults();
}
search() {
this.router.navigate([`${this.activeTabAction}Listings`]);
}
toggleMenu() {
this.isMenuOpen = !this.isMenuOpen;
}
onTypesChange(value) {
const tabToListingType = {
business: 'businessListings',
commercialProperty: 'commercialPropertyListings',
broker: 'brokerListings',
};
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
this.filterStateService.updateCriteria(listingType, { types: value === '' ? [] : [value] });
this.criteria = this.filterStateService.getCriteria(listingType);
this.setTotalNumberOfResults();
}
onRadiusChange(value) {
const tabToListingType = {
business: 'businessListings',
commercialProperty: 'commercialPropertyListings',
broker: 'brokerListings',
};
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
this.filterStateService.updateCriteria(listingType, { radius: value === 'null' ? null : parseInt(value) });
this.criteria = this.filterStateService.getCriteria(listingType);
this.setTotalNumberOfResults();
}
async openModal() {
const tabToListingType = {
business: 'businessListings',
commercialProperty: 'commercialPropertyListings',
broker: 'brokerListings',
};
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
const accepted = await this.modalService.showModal(this.criteria);
if (accepted) {
this.router.navigate([`${this.activeTabAction}Listings`]);
}
}
private loadCities() {
this.cities$ = concat(
of([]),
this.cityInput$.pipe(
distinctUntilChanged(),
tap(() => (this.cityLoading = true)),
switchMap(term =>
this.geoService.findCitiesAndStatesStartingWith(term).pipe(
catchError(() => of([])),
tap(() => (this.cityLoading = false)),
),
),
),
);
}
trackByFn(item: GeoResult) {
return item.id;
}
setCityOrState(cityOrState: CityAndStateResult) {
const tabToListingType = {
business: 'businessListings',
commercialProperty: 'commercialPropertyListings',
broker: 'brokerListings',
};
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
if (cityOrState) {
if (cityOrState.type === 'state') {
this.filterStateService.updateCriteria(listingType, { state: cityOrState.content.state_code, city: null, radius: null, searchType: 'exact' });
} else {
this.filterStateService.updateCriteria(listingType, {
city: cityOrState.content as GeoResult,
state: cityOrState.content.state,
searchType: 'radius',
radius: 20,
});
}
} else {
this.filterStateService.updateCriteria(listingType, { state: null, city: null, radius: null, searchType: 'exact' });
}
this.criteria = this.filterStateService.getCriteria(listingType);
this.setTotalNumberOfResults();
}
getTypes() {
if (this.criteria.criteriaType === 'businessListings') {
return this.selectOptions.typesOfBusiness;
} else if (this.criteria.criteriaType === 'commercialPropertyListings') {
return this.selectOptions.typesOfCommercialProperty;
} else {
return this.selectOptions.customerSubTypes;
}
}
getPlaceholderLabel() {
if (this.criteria.criteriaType === 'businessListings') {
return 'Business Type';
} else if (this.criteria.criteriaType === 'commercialPropertyListings') {
return 'Property Type';
} else {
return 'Professional Type';
}
}
setTotalNumberOfResults() {
if (this.criteria) {
console.log(`Getting total number of results for ${this.criteria.criteriaType}`);
const tabToListingType = {
business: 'businessListings',
commercialProperty: 'commercialPropertyListings',
broker: 'brokerListings',
};
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
if (this.criteria.criteriaType === 'businessListings' || this.criteria.criteriaType === 'commercialPropertyListings') {
this.numberOfResults$ = this.listingService.getNumberOfListings(this.criteria.criteriaType === 'businessListings' ? 'business' : 'commercialProperty');
} else if (this.criteria.criteriaType === 'brokerListings') {
this.numberOfResults$ = this.userService.getNumberOfBroker(this.filterStateService.getCriteria('brokerListings') as UserListingCriteria);
} else {
this.numberOfResults$ = of();
}
}
}
ngOnDestroy(): void {
clearTimeout(this.typingInterval);
}
}
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, ElementRef, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { NgSelectModule } from '@ng-select/ng-select';
import { UntilDestroy } from '@ngneat/until-destroy';
import { catchError, concat, distinctUntilChanged, Observable, of, Subject, switchMap, tap } from 'rxjs';
import { BusinessListingCriteria, CityAndStateResult, CommercialPropertyListingCriteria, GeoResult, KeycloakUser, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
import { FaqComponent, FAQItem } from '../../components/faq/faq.component';
import { ModalService } from '../../components/search-modal/modal.service';
import { TooltipComponent } from '../../components/tooltip/tooltip.component';
import { AiService } from '../../services/ai.service';
import { AuthService } from '../../services/auth.service';
import { FilterStateService } from '../../services/filter-state.service';
import { GeoService } from '../../services/geo.service';
import { ListingsService } from '../../services/listings.service';
import { SearchService } from '../../services/search.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { SeoService } from '../../services/seo.service';
import { UserService } from '../../services/user.service';
import { map2User } from '../../utils/utils';
@UntilDestroy()
@Component({
selector: 'app-home',
standalone: true,
imports: [CommonModule, FormsModule, RouterModule, NgSelectModule, FaqComponent],
templateUrl: './home.component.html',
styleUrl: './home.component.scss',
})
export class HomeComponent {
placeholders: string[] = ['Property close to Houston less than 10M', 'Franchise business in Austin price less than 500K'];
activeTabAction: 'business' | 'commercialProperty' | 'broker' = 'business';
type: string;
maxPrice: string;
minPrice: string;
criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
states = [];
isMenuOpen = false;
user: KeycloakUser;
prompt: string;
cities$: Observable<CityAndStateResult[]>;
cityLoading = false;
cityInput$ = new Subject<string>();
cityOrState = undefined;
numberOfResults$: Observable<number>;
numberOfBroker$: Observable<number>;
numberOfCommercial$: Observable<number>;
aiSearch = false;
aiSearchText = '';
aiSearchFailed = false;
loadingAi = false;
@ViewChild('aiSearchInput', { static: false }) searchInput!: ElementRef;
typingSpeed: number = 100;
pauseTime: number = 2000;
index: number = 0;
charIndex: number = 0;
typingInterval: any;
showInput: boolean = true;
tooltipTargetBeta = 'tooltipTargetBeta';
// FAQ data optimized for AEO (Answer Engine Optimization) and Featured Snippets
faqItems: FAQItem[] = [
{
question: 'How do I buy a business on BizMatch?',
answer: '<p><strong>Buying a business on BizMatch involves 6 simple steps:</strong></p><ol><li><strong>Browse Listings:</strong> Search our marketplace using filters for industry, location, and price range</li><li><strong>Review Details:</strong> Examine financial information, business operations, and growth potential</li><li><strong>Contact Seller:</strong> Reach out directly through our secure messaging platform</li><li><strong>Due Diligence:</strong> Review financial statements, contracts, and legal documents</li><li><strong>Negotiate Terms:</strong> Work with the seller to agree on price and transition details</li><li><strong>Close Deal:</strong> Complete the purchase with legal and financial advisors</li></ol><p>We recommend working with experienced business brokers and conducting thorough due diligence before making any purchase.</p>'
},
{
question: 'How much does it cost to list a business for sale?',
answer: '<p><strong>BizMatch offers flexible pricing options:</strong></p><ul><li><strong>Free Basic Listing:</strong> Post your business with essential details at no cost</li><li><strong>Premium Listing:</strong> Enhanced visibility with featured placement and priority support</li><li><strong>Broker Packages:</strong> Professional tools for business brokers and agencies</li></ul><p>Contact our team for detailed pricing information tailored to your specific needs.</p>'
},
{
question: 'What types of businesses can I find on BizMatch?',
answer: '<p><strong>BizMatch features businesses across all major industries:</strong></p><ul><li><strong>Food & Hospitality:</strong> Restaurants, cafes, bars, hotels, catering services</li><li><strong>Retail:</strong> Stores, boutiques, online shops, franchises</li><li><strong>Service Businesses:</strong> Consulting firms, cleaning services, healthcare practices</li><li><strong>Manufacturing:</strong> Production facilities, distribution centers, warehouses</li><li><strong>E-commerce:</strong> Online businesses, digital products, subscription services</li><li><strong>Commercial Real Estate:</strong> Office buildings, retail spaces, industrial properties</li></ul><p>Our marketplace serves all business sizes from small local operations to large enterprises across the United States.</p>'
},
{
question: 'How do I know if a business listing is legitimate?',
answer: '<p><strong>Yes, BizMatch verifies all listings.</strong> Here\'s how we ensure legitimacy:</p><ol><li><strong>Seller Verification:</strong> All users must verify their identity and contact information</li><li><strong>Listing Review:</strong> Our team reviews each listing for completeness and accuracy</li><li><strong>Documentation Check:</strong> We verify business registration and ownership documents</li><li><strong>Transparent Communication:</strong> All conversations are logged through our secure platform</li></ol><p><strong>Additional steps you should take:</strong></p><ul><li>Review financial statements and tax returns</li><li>Visit the business location in person</li><li>Consult with legal and financial advisors</li><li>Work with licensed business brokers when appropriate</li><li>Conduct background checks on sellers</li></ul>'
},
{
question: 'Can I sell commercial property on BizMatch?',
answer: '<p><strong>Yes!</strong> BizMatch is a full-service marketplace for both businesses and commercial real estate.</p><p><strong>Property types you can list:</strong></p><ul><li>Office buildings and professional spaces</li><li>Retail locations and shopping centers</li><li>Warehouses and distribution facilities</li><li>Industrial properties and manufacturing plants</li><li>Mixed-use developments</li><li>Land for commercial development</li></ul><p>Our platform connects you with qualified buyers, investors, and commercial real estate professionals actively searching for investment opportunities.</p>'
},
{
question: 'What information should I include when listing my business?',
answer: '<p><strong>A complete listing should include these essential details:</strong></p><ol><li><strong>Financial Information:</strong> Asking price, annual revenue, cash flow, profit margins</li><li><strong>Business Operations:</strong> Years established, number of employees, hours of operation</li><li><strong>Description:</strong> Detailed overview of products/services, customer base, competitive advantages</li><li><strong>Industry Category:</strong> Specific business type and market segment</li><li><strong>Location Details:</strong> City, state, demographic information</li><li><strong>Assets Included:</strong> Equipment, inventory, real estate, intellectual property</li><li><strong>Visual Content:</strong> High-quality photos of business premises and operations</li><li><strong>Growth Potential:</strong> Expansion opportunities and market trends</li></ol><p><strong>Pro tip:</strong> The more detailed and transparent your listing, the more interest it will generate from serious, qualified buyers.</p>'
},
{
question: 'How long does it take to sell a business?',
answer: '<p><strong>Most businesses sell within 6 to 12 months.</strong> The timeline varies based on several factors:</p><p><strong>Factors that speed up sales:</strong></p><ul><li>Realistic pricing based on professional valuation</li><li>Complete and organized financial documentation</li><li>Strong business performance and growth trends</li><li>Attractive location and market conditions</li><li>Experienced business broker representation</li><li>Flexible seller terms and financing options</li></ul><p><strong>Timeline breakdown:</strong></p><ol><li><strong>Months 1-2:</strong> Preparation and listing creation</li><li><strong>Months 3-6:</strong> Marketing and buyer qualification</li><li><strong>Months 7-10:</strong> Negotiations and due diligence</li><li><strong>Months 11-12:</strong> Closing and transition</li></ol>'
},
{
question: 'What is business valuation and why is it important?',
answer: '<p><strong>Business valuation is the process of determining the economic worth of a company.</strong> It calculates the fair market value based on financial performance, assets, and market conditions.</p><p><strong>Why valuation matters:</strong></p><ul><li><strong>Realistic Pricing:</strong> Attracts serious buyers and prevents extended time on market</li><li><strong>Negotiation Power:</strong> Provides data-driven justification for asking price</li><li><strong>Buyer Confidence:</strong> Professional valuations increase trust and credibility</li><li><strong>Financing Approval:</strong> Banks require valuations for business acquisition loans</li></ul><p><strong>Valuation methods include:</strong></p><ol><li><strong>Asset-Based:</strong> Total value of business assets minus liabilities</li><li><strong>Income-Based:</strong> Projected future earnings and cash flow</li><li><strong>Market-Based:</strong> Comparison to similar business sales</li><li><strong>Multiple of Earnings:</strong> Revenue or profit multiplied by industry-standard factor</li></ol>'
},
{
question: 'Do I need a business broker to buy or sell a business?',
answer: '<p><strong>No, but brokers are highly recommended.</strong> You can conduct transactions directly through BizMatch, but professional brokers provide significant advantages:</p><p><strong>Benefits of using a business broker:</strong></p><ul><li><strong>Expert Valuation:</strong> Accurate pricing based on market data and analysis</li><li><strong>Marketing Expertise:</strong> Professional listing creation and buyer outreach</li><li><strong>Qualified Buyers:</strong> Pre-screening to ensure financial capability and serious interest</li><li><strong>Negotiation Skills:</strong> Experience handling complex deal structures and terms</li><li><strong>Confidentiality:</strong> Protect sensitive information during the sales process</li><li><strong>Legal Compliance:</strong> Navigate regulations, contracts, and disclosures</li><li><strong>Time Savings:</strong> Handle paperwork, communications, and coordination</li></ul><p>BizMatch connects you with licensed brokers in your area, or you can manage the transaction yourself using our secure platform and resources.</p>'
},
{
question: 'What financing options are available for buying a business?',
answer: '<p><strong>Business buyers have multiple financing options:</strong></p><ol><li><strong>SBA 7(a) Loans:</strong> Government-backed loans with favorable terms<ul><li>Down payment as low as 10%</li><li>Loan amounts up to $5 million</li><li>Competitive interest rates</li><li>Terms up to 10-25 years</li></ul></li><li><strong>Conventional Bank Financing:</strong> Traditional business acquisition loans<ul><li>Typically require 20-30% down payment</li><li>Based on creditworthiness and business performance</li></ul></li><li><strong>Seller Financing:</strong> Owner provides loan to buyer<ul><li>More flexible terms and requirements</li><li>Often combined with other financing</li><li>Typically 10-30% of purchase price</li></ul></li><li><strong>Investor Partnerships:</strong> Equity financing from partners<ul><li>Shared ownership and profits</li><li>No personal debt obligation</li></ul></li><li><strong>Personal Savings:</strong> Self-funded purchase<ul><li>No interest or loan payments</li><li>Full ownership from day one</li></ul></li></ol><p><strong>Most buyers use a combination of these options</strong> to structure the optimal deal for their situation.</p>'
}
];
constructor(
private router: Router,
private modalService: ModalService,
private searchService: SearchService,
private activatedRoute: ActivatedRoute,
public selectOptions: SelectOptionsService,
private geoService: GeoService,
public cdRef: ChangeDetectorRef,
private listingService: ListingsService,
private userService: UserService,
private aiService: AiService,
private authService: AuthService,
private filterStateService: FilterStateService,
private seoService: SeoService,
) { }
async ngOnInit() {
// Flowbite is now initialized once in AppComponent
// Set SEO meta tags for home page
this.seoService.updateMetaTags({
title: 'BizMatch - Buy & Sell Businesses and Commercial Properties',
description: 'Find profitable businesses for sale, commercial real estate, and franchise opportunities. Browse thousands of verified listings across the US.',
keywords: 'business for sale, businesses for sale, buy business, sell business, commercial property, commercial real estate, franchise opportunities, business broker, business marketplace',
type: 'website'
});
// Add Organization schema for brand identity and FAQ schema for AEO
const organizationSchema = this.seoService.generateOrganizationSchema();
const faqSchema = this.seoService.generateFAQPageSchema(
this.faqItems.map(item => ({
question: item.question,
answer: item.answer
}))
);
// Add HowTo schema for buying a business
const howToSchema = this.seoService.generateHowToSchema({
name: 'How to Buy a Business on BizMatch',
description: 'Step-by-step guide to finding and purchasing your ideal business through BizMatch marketplace',
totalTime: 'PT45M',
steps: [
{
name: 'Browse Business Listings',
text: 'Search through thousands of verified business listings using our advanced filters. Filter by industry, location, price range, revenue, and more to find businesses that match your criteria.'
},
{
name: 'Review Business Details',
text: 'Examine the business financials, including annual revenue, cash flow, asking price, and years established. Read the detailed business description and view photos of the operation.'
},
{
name: 'Contact the Seller',
text: 'Use our secure messaging system to contact the seller or business broker directly. Request additional information, financial documents, or schedule a site visit to see the business in person.'
},
{
name: 'Conduct Due Diligence',
text: 'Review all financial statements, tax returns, lease agreements, and legal documents. Verify the business information, inspect the physical location, and consult with legal and financial advisors.'
},
{
name: 'Make an Offer',
text: 'Submit a formal offer based on your valuation and due diligence findings. Negotiate terms including purchase price, payment structure, transition period, and any contingencies.'
},
{
name: 'Close the Transaction',
text: 'Work with attorneys and escrow services to finalize all legal documents, transfer ownership, and complete the purchase. The seller will transfer assets, train you on operations, and help with the transition.'
}
]
});
// Add SearchBox schema for Sitelinks Search
const searchBoxSchema = this.seoService.generateSearchBoxSchema();
this.seoService.injectMultipleSchemas([organizationSchema, faqSchema, howToSchema, searchBoxSchema]);
// Clear all filters and sort options on initial load
this.filterStateService.resetCriteria('businessListings');
this.filterStateService.resetCriteria('commercialPropertyListings');
this.filterStateService.resetCriteria('brokerListings');
this.filterStateService.updateSortBy('businessListings', null);
this.filterStateService.updateSortBy('commercialPropertyListings', null);
this.filterStateService.updateSortBy('brokerListings', null);
// Initialize criteria for the default tab
this.criteria = this.filterStateService.getCriteria('businessListings');
this.numberOfBroker$ = this.userService.getNumberOfBroker(this.filterStateService.getCriteria('brokerListings') as UserListingCriteria);
this.numberOfCommercial$ = this.listingService.getNumberOfListings('commercialProperty');
const token = await this.authService.getToken();
this.user = map2User(token);
this.loadCities();
this.setTotalNumberOfResults();
}
changeTab(tabname: 'business' | 'commercialProperty' | 'broker') {
this.activeTabAction = tabname;
this.cityOrState = null;
const tabToListingType = {
business: 'businessListings',
commercialProperty: 'commercialPropertyListings',
broker: 'brokerListings',
};
this.criteria = this.filterStateService.getCriteria(tabToListingType[tabname] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings');
this.setTotalNumberOfResults();
}
search() {
this.router.navigate([`${this.activeTabAction}Listings`]);
}
toggleMenu() {
this.isMenuOpen = !this.isMenuOpen;
}
onTypesChange(value) {
const tabToListingType = {
business: 'businessListings',
commercialProperty: 'commercialPropertyListings',
broker: 'brokerListings',
};
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
this.filterStateService.updateCriteria(listingType, { types: value === '' ? [] : [value] });
this.criteria = this.filterStateService.getCriteria(listingType);
this.setTotalNumberOfResults();
}
onRadiusChange(value) {
const tabToListingType = {
business: 'businessListings',
commercialProperty: 'commercialPropertyListings',
broker: 'brokerListings',
};
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
this.filterStateService.updateCriteria(listingType, { radius: value === 'null' ? null : parseInt(value) });
this.criteria = this.filterStateService.getCriteria(listingType);
this.setTotalNumberOfResults();
}
async openModal() {
const tabToListingType = {
business: 'businessListings',
commercialProperty: 'commercialPropertyListings',
broker: 'brokerListings',
};
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
const accepted = await this.modalService.showModal(this.criteria);
if (accepted) {
this.router.navigate([`${this.activeTabAction}Listings`]);
}
}
private loadCities() {
this.cities$ = concat(
of([]),
this.cityInput$.pipe(
distinctUntilChanged(),
tap(() => (this.cityLoading = true)),
switchMap(term =>
this.geoService.findCitiesAndStatesStartingWith(term).pipe(
catchError(() => of([])),
tap(() => (this.cityLoading = false)),
),
),
),
);
}
trackByFn(item: GeoResult) {
return item.id;
}
setCityOrState(cityOrState: CityAndStateResult) {
const tabToListingType = {
business: 'businessListings',
commercialProperty: 'commercialPropertyListings',
broker: 'brokerListings',
};
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
if (cityOrState) {
if (cityOrState.type === 'state') {
this.filterStateService.updateCriteria(listingType, { state: cityOrState.content.state_code, city: null, radius: null, searchType: 'exact' });
} else {
this.filterStateService.updateCriteria(listingType, {
city: cityOrState.content as GeoResult,
state: cityOrState.content.state,
searchType: 'radius',
radius: 20,
});
}
} else {
this.filterStateService.updateCriteria(listingType, { state: null, city: null, radius: null, searchType: 'exact' });
}
this.criteria = this.filterStateService.getCriteria(listingType);
this.setTotalNumberOfResults();
}
getTypes() {
if (this.criteria.criteriaType === 'businessListings') {
return this.selectOptions.typesOfBusiness;
} else if (this.criteria.criteriaType === 'commercialPropertyListings') {
return this.selectOptions.typesOfCommercialProperty;
} else {
return this.selectOptions.customerSubTypes;
}
}
getPlaceholderLabel() {
if (this.criteria.criteriaType === 'businessListings') {
return 'Business Type';
} else if (this.criteria.criteriaType === 'commercialPropertyListings') {
return 'Property Type';
} else {
return 'Professional Type';
}
}
setTotalNumberOfResults() {
if (this.criteria) {
console.log(`Getting total number of results for ${this.criteria.criteriaType}`);
const tabToListingType = {
business: 'businessListings',
commercialProperty: 'commercialPropertyListings',
broker: 'brokerListings',
};
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
if (this.criteria.criteriaType === 'businessListings' || this.criteria.criteriaType === 'commercialPropertyListings') {
this.numberOfResults$ = this.listingService.getNumberOfListings(this.criteria.criteriaType === 'businessListings' ? 'business' : 'commercialProperty');
} else if (this.criteria.criteriaType === 'brokerListings') {
this.numberOfResults$ = this.userService.getNumberOfBroker(this.filterStateService.getCriteria('brokerListings') as UserListingCriteria);
} else {
this.numberOfResults$ = of();
}
}
}
ngOnDestroy(): void {
clearTimeout(this.typingInterval);
}
}

View File

@@ -1,201 +1,201 @@
<div class="container mx-auto px-4 py-8 max-w-4xl">
<div class="bg-white rounded-lg drop-shadow-custom-bg p-6 md:p-8 relative">
<button
(click)="goBack()"
class="absolute top-4 right-4 md:top-6 md:right-6 w-10 h-10 flex items-center justify-center rounded-full bg-red-600 hover:bg-red-700 text-white transition-colors duration-200"
aria-label="Go back"
>
<i class="fas fa-arrow-left text-lg"></i>
</button>
<h1 class="text-3xl font-bold text-neutral-900 mb-6 pr-14">Privacy Statement</h1>
<section id="content" role="main">
<article class="post page">
<section class="entry-content">
<div class="container">
<p class="font-bold mb-4">Privacy Policy</p>
<p class="mb-4">We are committed to protecting your privacy. We have established this statement as a testament to our commitment to your privacy.</p>
<p class="mb-4">This Privacy Policy relates to the use of any personal information you provide to us through this websites.</p>
<p class="mb-4">
By accepting the Privacy Policy during registration or the sending of an enquiry, you expressly consent to our collection, storage, use and disclosure of your personal information as described in this Privacy
Policy.
</p>
<p class="mb-4">
We may update our Privacy Policy from time to time. Our Privacy Policy was last updated in Febuary 2018 and is effective upon acceptance for new users. By continuing to use our websites or otherwise
continuing to deal with us, you accept this Privacy Policy.
</p>
<p class="font-bold mb-4 mt-6">Collection of personal information</p>
<p class="mb-4">Anyone can browse our websites without revealing any personally identifiable information.</p>
<p class="mb-4">However, should you wish to contact a business for sale, a franchise opportunity or an intermediary, we will require you to provide some personal information.</p>
<p class="mb-4">Should you wish to advertise your services, your business (es) or your franchise opportunity, we will require you to provide some personal information.</p>
<p class="mb-4">By providing personal information, you are consenting to the transfer and storage of that information on our servers located in the United States.</p>
<p class="mb-4">We may collect and store the following personal information:</p>
<p class="mb-4">
Your name, email address, physical address, telephone numbers, and (depending on the service used), your business information, financial information, such as credit / payment card details;<br />
transactional information based on your activities on the site; information that you disclose in a forum on any of our websites, feedback, correspondence through our websites, and correspondence sent to
us;<br />
other information from your interaction with our websites, services, content and advertising, including computer and connection information, statistics on page views, traffic to and from the sites, ad data,
IP address and standard web log information;<br />
supplemental information from third parties (for example, if you incur a debt, we will generally conduct a credit check by obtaining additional information about you from a credit bureau, as permitted by law;
or if the information you provide cannot be verified,<br />
we may ask you to send us additional information, or to answer additional questions online to help verify your information).
</p>
<p class="font-bold mb-4 mt-6">How we use your information</p>
<p class="mb-4">
The primary reason we collect your personal information is to improve the services we deliver to you through our website. By registering or sending an enquiry through our website, you agree that we may use
your personal information to:<br />
provide the services and customer support you request;<br />
connect you with relevant parties:<br />
If you are a buyer we will pass some or all of your details on to the seller / intermediary along with any message you have typed. This allows the seller to contact you in order to pursue a possible sale of a
business;<br />
If you are a seller / intermediary, we will disclose your details where you have given us permission to do so;<br />
resolve disputes, collect fees, and troubleshoot problems;<br />
prevent potentially prohibited or illegal activities, and enforce our Terms and Conditions;<br />
customize, measure and improve our services, conduct internal market research, provide content and advertising;<br />
tell you about other Biz-Match products and services, target marketing, send you service updates, and promotional offers based on your communication preferences.
</p>
<p class="font-bold mb-4 mt-6">Our disclosure of your information</p>
<p class="mb-4">
We may disclose personal information to respond to legal requirements, enforce our policies, respond to claims that a listing or other content infringes the rights of others, or protect anyone's rights,
property, or safety.
</p>
<p class="mb-4">
We may also share your personal information with<br />
When you select to register an account as a business buyer, you provide your personal details and we will pass this on to a seller of a business or franchise when you request more information.
</p>
<p class="mb-4">
When you select to register an account as a business broker or seller on the site, we provide a public platform on which to establish your business profile. This profile consists of pertinent facts about your
business along with your personal information; namely, the contact information you provide to facilitate contact between you and other users' of the site. Direct email addresses and telephone numbers will not
be publicly displayed unless you specifically include it on your profile.
</p>
<p class="mb-4">
The information a user includes within the forums provided on the site is publicly available to other users' of the site. Please be aware that any personal information you elect to provide in a public forum
may be used to send you unsolicited messages; we are not responsible for the personal information a user elects to disclose within their public profile, or in the private communications that users' engage in
on the site.
</p>
<p class="mb-4">
We post testimonials on the site obtained from users'. These testimonials may include the name, city, state or region and business of the user. We obtain permission from our users' prior to posting their
testimonials on the site. We are not responsible for any personal information a user selects to include within their testimonial.
</p>
<p class="mb-4">
When you elect to email a friend about the site, or a particular business, we request the third party's email address to send this one time email. We do not share this information with any third parties for
their promotional purposes and only store the information to gauge the effectiveness of our referral program.
</p>
<p class="mb-4">We may share your personal information with our service providers where necessary. We employ the services of a payment processor to fulfil payment for services purchased on the site.</p>
<p class="mb-4">
We works with a number of partners or affiliates, where we provide marketing services for these companies. These third party agents collect your personal information to facilitate your service request and the
information submitted here is governed by their privacy policy.
</p>
<p class="font-bold mb-4 mt-6">Masking Policy</p>
<p class="mb-4">
In some cases, where the third party agent collects your information, the affiliate portal may appear within a BizMatch.net frame. It is presented as a BizMatch.net page for a streamlined user interface
however the data collected on such pages is governed by the third party agent's privacy policy.
</p>
<p class="font-bold mb-4 mt-6">Legal Disclosure</p>
<p class="mb-4">
In certain circumstances, we may be legally required to disclose information collected on the site to law enforcement, government agencies or other third parties. We reserve the right to disclose information
to our service providers and to law enforcement or government agencies where a formal request such as in response to a court order, subpoena or judicial proceeding is made. Where we believe in good faith that
disclosure of information is necessary to prevent imminent physical or financial harm, or loss, or in protecting against illegal activity on the site, we reserve to disclose information.
</p>
<p class="mb-4">
Should the company undergo the merger, acquisition or sale of some or all of its assets, your personal information may likely be a part of the transferred assets. In such an event, your personal information
on the site, would be governed by this privacy statement; any changes to the privacy practices governing your information as a result of transfer would be relayed to you by means of a prominent notice on the
Site, or by email.
</p>
<p class="font-bold mb-4 mt-6">Using information from BizMatch.net website</p>
<p class="mb-4">
In certain cases, (where you are receiving contact details of buyers interested in your business opportunity or a business opportunity you represent), you must comply with data protection laws, and give other
users a chance to remove themselves from your database and a chance to review what information you have collected about them.
</p>
<p class="font-bold mb-4 mt-6">You agree to use BizMatch.net user information only for:</p>
<p class="mb-4">
BizMatch.net transaction-related purposes that are not unsolicited commercial messages;<br />
using services offered through BizMatch.net, or<br />
other purposes that a user expressly chooses.
</p>
<p class="font-bold mb-4 mt-6">Marketing</p>
<p class="mb-4">
We do not sell or rent your personal information to third parties for their marketing purposes without your explicit consent. Where you explicitly express your consent at the point of collection to receive
offers from third party partners or affiliates, we will communicate to you on their behalf. We will not pass your information on.
</p>
<p class="mb-4">
You will receive email marketing communications from us throughout the duration of your relationship with our websites. If you do not wish to receive marketing communications from us you may unsubscribe and /
or change your preferences at any time by following instructions included within a communication or emailing Customer Services.
</p>
<p class="mb-4">If you have an account with one of our websites you can also log in and click the email preferences link to unsubscribe and / or change your preferences.</p>
<p class="mb-4">
Please note that we reserve the right to send all website users notifications and administrative emails where necessary which are considered a part of the service. Given that these messages aren't promotional
in nature, you will be unable to opt-out of them.
</p>
<p class="font-bold mb-4 mt-6">Cookies</p>
<p class="mb-4">
A cookie is a small text file written to your hard drive that contains information about you. Cookies do not contain any personal information about users. Once you close your browser or log out of the
website, the cookie simply terminates. We use cookies so that we can personalise your experience of our websites.
</p>
<p class="mb-4">
If you set up your browser to reject the cookie, you may still use the website however; doing so may interfere with your use of some aspects of our websites. Some of our business partners use cookies on our
site (for example, advertisers). We have no access to or control over these cookies.
</p>
<p class="mb-4">For more information about how BizMatch.net uses cookies please read our Cookie Policy.</p>
<p class="font-bold mb-4 mt-6">Spam, spyware or spoofing</p>
<p class="mb-4">
We and our users do not tolerate spam. Make sure to set your email preferences so we can communicate with you, as you prefer. Please add us to your safe senders list. To report spam or spoof emails, please
contact us using the contact information provided in the Contact Us section of this privacy statement.
</p>
<p class="mb-4">
You may not use our communication tools to send spam or otherwise send content that would breach our Terms and Conditions. We automatically scan and may manually filter messages to check for spam, viruses,
phishing attacks and other malicious activity or illegal or prohibited content. We may also store these messages for back up purposes only.
</p>
<p class="mb-4">
If you send an email to an email address that is not registered in our community, we do not permanently store that email or use that email address for any marketing purpose. We do not rent or sell these email
addresses.
</p>
<p class="font-bold mb-4 mt-6">Account protection</p>
<p class="mb-4">
Your password is the key to your account. Make sure this is stored safely. Use unique numbers, letters and special characters, and do not disclose your password to anyone. If you do share your password or
your personal information with others, remember that you are responsible for all actions taken in the name of your account. If you lose control of your password, you may lose substantial control over your
personal information and may be subject to legally binding actions taken on your behalf. Therefore, if your password has been compromised for any reason, you should immediately notify us and change your
password.
</p>
<p class="font-bold mb-4 mt-6">Accessing, reviewing and changing your personal information</p>
<p class="mb-4">You can view and amend your personal information at any time by logging in to your account online. You must promptly update your personal information if it changes or is inaccurate.</p>
<p class="mb-4">If at any time you wish to close your account, please contact Customer Services and instruct us to do so. We will process your request as soon as we can.</p>
<p class="mb-4">You may also contact us at any time to find out what information we hold about you, what we do with it and ask us to update it for you.</p>
<p class="mb-4">
We do retain personal information from closed accounts to comply with law, prevent fraud, collect any fees owed, resolve disputes, troubleshoot problems, assist with any investigations, enforce our Terms and
Conditions, and take other actions otherwise permitted by law.
</p>
<p class="font-bold mb-4 mt-6">Security</p>
<p class="mb-4">
Your information is stored on our servers located in the USA. We treat data as an asset that must be protected and use a variety of tools (encryption, passwords, physical security, etc.) to protect your
personal information against unauthorized access and disclosure. However, no method of security is 100% effective and while we take every measure to protect your personal information, we make no guarantees of
its absolute security.
</p>
<p class="mb-4">We employ the use of SSL encryption during the transmission of sensitive data across our websites.</p>
<p class="font-bold mb-4 mt-6">Third parties</p>
<p class="mb-4">
Except as otherwise expressly included in this Privacy Policy, this document addresses only the use and disclosure of information we collect from you. If you disclose your information to others, whether they
are buyers or sellers on our websites or other sites throughout the internet, different rules may apply to their use or disclosure of the information you disclose to them. Dynamis does not control the privacy
policies of third parties, and you are subject to the privacy policies of those third parties where applicable.
</p>
<p class="mb-4">We encourage you to ask questions before you disclose your personal information to others.</p>
<p class="font-bold mb-4 mt-6">General</p>
<p class="mb-4">
We may change this Privacy Policy from time to time as we add new products and applications, as we improve our current offerings, and as technologies and laws change. You can determine when this Privacy
Policy was last revised by referring to the "Last Updated" legend at the top of this page.
</p>
<p class="mb-4">
Any changes will become effective upon our posting of the revised Privacy Policy on our affected websites. We will provide notice to you if these changes are material and, where required by applicable law, we
will obtain your consent. This notice may be provided by email, by posting notice of the changes on our affected websites or by other means, consistent with applicable laws.
</p>
<p class="font-bold mb-4 mt-6">Contact Us</p>
<p class="mb-4">
If you have any questions or comments about our privacy policy, and you can't find the answer to your question on our help pages, please contact us using this form or email support&#64;bizmatch.net, or write
to us at BizMatch, 715 S. Tanahua, Corpus Christi, TX 78401.)
</p>
</div>
</section>
</article>
</section>
</div>
</div>
<div class="container mx-auto px-4 py-8 max-w-4xl">
<div class="bg-white rounded-lg drop-shadow-custom-bg p-6 md:p-8 relative">
<button
(click)="goBack()"
class="absolute top-4 right-4 md:top-6 md:right-6 w-10 h-10 flex items-center justify-center rounded-full bg-red-600 hover:bg-red-700 text-white transition-colors duration-200"
aria-label="Go back"
>
<i class="fas fa-arrow-left text-lg"></i>
</button>
<h1 class="text-3xl font-bold text-neutral-900 mb-6 pr-14">Privacy Statement</h1>
<section id="content" role="main">
<article class="post page">
<section class="entry-content">
<div class="container">
<p class="font-bold mb-4">Privacy Policy</p>
<p class="mb-4">We are committed to protecting your privacy. We have established this statement as a testament to our commitment to your privacy.</p>
<p class="mb-4">This Privacy Policy relates to the use of any personal information you provide to us through this websites.</p>
<p class="mb-4">
By accepting the Privacy Policy during registration or the sending of an enquiry, you expressly consent to our collection, storage, use and disclosure of your personal information as described in this Privacy
Policy.
</p>
<p class="mb-4">
We may update our Privacy Policy from time to time. Our Privacy Policy was last updated in Febuary 2018 and is effective upon acceptance for new users. By continuing to use our websites or otherwise
continuing to deal with us, you accept this Privacy Policy.
</p>
<p class="font-bold mb-4 mt-6">Collection of personal information</p>
<p class="mb-4">Anyone can browse our websites without revealing any personally identifiable information.</p>
<p class="mb-4">However, should you wish to contact a business for sale, a franchise opportunity or an intermediary, we will require you to provide some personal information.</p>
<p class="mb-4">Should you wish to advertise your services, your business (es) or your franchise opportunity, we will require you to provide some personal information.</p>
<p class="mb-4">By providing personal information, you are consenting to the transfer and storage of that information on our servers located in the United States.</p>
<p class="mb-4">We may collect and store the following personal information:</p>
<p class="mb-4">
Your name, email address, physical address, telephone numbers, and (depending on the service used), your business information, financial information, such as credit / payment card details;<br />
transactional information based on your activities on the site; information that you disclose in a forum on any of our websites, feedback, correspondence through our websites, and correspondence sent to
us;<br />
other information from your interaction with our websites, services, content and advertising, including computer and connection information, statistics on page views, traffic to and from the sites, ad data,
IP address and standard web log information;<br />
supplemental information from third parties (for example, if you incur a debt, we will generally conduct a credit check by obtaining additional information about you from a credit bureau, as permitted by law;
or if the information you provide cannot be verified,<br />
we may ask you to send us additional information, or to answer additional questions online to help verify your information).
</p>
<p class="font-bold mb-4 mt-6">How we use your information</p>
<p class="mb-4">
The primary reason we collect your personal information is to improve the services we deliver to you through our website. By registering or sending an enquiry through our website, you agree that we may use
your personal information to:<br />
provide the services and customer support you request;<br />
connect you with relevant parties:<br />
If you are a buyer we will pass some or all of your details on to the seller / intermediary along with any message you have typed. This allows the seller to contact you in order to pursue a possible sale of a
business;<br />
If you are a seller / intermediary, we will disclose your details where you have given us permission to do so;<br />
resolve disputes, collect fees, and troubleshoot problems;<br />
prevent potentially prohibited or illegal activities, and enforce our Terms and Conditions;<br />
customize, measure and improve our services, conduct internal market research, provide content and advertising;<br />
tell you about other Biz-Match products and services, target marketing, send you service updates, and promotional offers based on your communication preferences.
</p>
<p class="font-bold mb-4 mt-6">Our disclosure of your information</p>
<p class="mb-4">
We may disclose personal information to respond to legal requirements, enforce our policies, respond to claims that a listing or other content infringes the rights of others, or protect anyone's rights,
property, or safety.
</p>
<p class="mb-4">
We may also share your personal information with<br />
When you select to register an account as a business buyer, you provide your personal details and we will pass this on to a seller of a business or franchise when you request more information.
</p>
<p class="mb-4">
When you select to register an account as a business broker or seller on the site, we provide a public platform on which to establish your business profile. This profile consists of pertinent facts about your
business along with your personal information; namely, the contact information you provide to facilitate contact between you and other users' of the site. Direct email addresses and telephone numbers will not
be publicly displayed unless you specifically include it on your profile.
</p>
<p class="mb-4">
The information a user includes within the forums provided on the site is publicly available to other users' of the site. Please be aware that any personal information you elect to provide in a public forum
may be used to send you unsolicited messages; we are not responsible for the personal information a user elects to disclose within their public profile, or in the private communications that users' engage in
on the site.
</p>
<p class="mb-4">
We post testimonials on the site obtained from users'. These testimonials may include the name, city, state or region and business of the user. We obtain permission from our users' prior to posting their
testimonials on the site. We are not responsible for any personal information a user selects to include within their testimonial.
</p>
<p class="mb-4">
When you elect to email a friend about the site, or a particular business, we request the third party's email address to send this one time email. We do not share this information with any third parties for
their promotional purposes and only store the information to gauge the effectiveness of our referral program.
</p>
<p class="mb-4">We may share your personal information with our service providers where necessary. We employ the services of a payment processor to fulfil payment for services purchased on the site.</p>
<p class="mb-4">
We works with a number of partners or affiliates, where we provide marketing services for these companies. These third party agents collect your personal information to facilitate your service request and the
information submitted here is governed by their privacy policy.
</p>
<p class="font-bold mb-4 mt-6">Masking Policy</p>
<p class="mb-4">
In some cases, where the third party agent collects your information, the affiliate portal may appear within a BizMatch.net frame. It is presented as a BizMatch.net page for a streamlined user interface
however the data collected on such pages is governed by the third party agent's privacy policy.
</p>
<p class="font-bold mb-4 mt-6">Legal Disclosure</p>
<p class="mb-4">
In certain circumstances, we may be legally required to disclose information collected on the site to law enforcement, government agencies or other third parties. We reserve the right to disclose information
to our service providers and to law enforcement or government agencies where a formal request such as in response to a court order, subpoena or judicial proceeding is made. Where we believe in good faith that
disclosure of information is necessary to prevent imminent physical or financial harm, or loss, or in protecting against illegal activity on the site, we reserve to disclose information.
</p>
<p class="mb-4">
Should the company undergo the merger, acquisition or sale of some or all of its assets, your personal information may likely be a part of the transferred assets. In such an event, your personal information
on the site, would be governed by this privacy statement; any changes to the privacy practices governing your information as a result of transfer would be relayed to you by means of a prominent notice on the
Site, or by email.
</p>
<p class="font-bold mb-4 mt-6">Using information from BizMatch.net website</p>
<p class="mb-4">
In certain cases, (where you are receiving contact details of buyers interested in your business opportunity or a business opportunity you represent), you must comply with data protection laws, and give other
users a chance to remove themselves from your database and a chance to review what information you have collected about them.
</p>
<p class="font-bold mb-4 mt-6">You agree to use BizMatch.net user information only for:</p>
<p class="mb-4">
BizMatch.net transaction-related purposes that are not unsolicited commercial messages;<br />
using services offered through BizMatch.net, or<br />
other purposes that a user expressly chooses.
</p>
<p class="font-bold mb-4 mt-6">Marketing</p>
<p class="mb-4">
We do not sell or rent your personal information to third parties for their marketing purposes without your explicit consent. Where you explicitly express your consent at the point of collection to receive
offers from third party partners or affiliates, we will communicate to you on their behalf. We will not pass your information on.
</p>
<p class="mb-4">
You will receive email marketing communications from us throughout the duration of your relationship with our websites. If you do not wish to receive marketing communications from us you may unsubscribe and /
or change your preferences at any time by following instructions included within a communication or emailing Customer Services.
</p>
<p class="mb-4">If you have an account with one of our websites you can also log in and click the email preferences link to unsubscribe and / or change your preferences.</p>
<p class="mb-4">
Please note that we reserve the right to send all website users notifications and administrative emails where necessary which are considered a part of the service. Given that these messages aren't promotional
in nature, you will be unable to opt-out of them.
</p>
<p class="font-bold mb-4 mt-6">Cookies</p>
<p class="mb-4">
A cookie is a small text file written to your hard drive that contains information about you. Cookies do not contain any personal information about users. Once you close your browser or log out of the
website, the cookie simply terminates. We use cookies so that we can personalise your experience of our websites.
</p>
<p class="mb-4">
If you set up your browser to reject the cookie, you may still use the website however; doing so may interfere with your use of some aspects of our websites. Some of our business partners use cookies on our
site (for example, advertisers). We have no access to or control over these cookies.
</p>
<p class="mb-4">For more information about how BizMatch.net uses cookies please read our Cookie Policy.</p>
<p class="font-bold mb-4 mt-6">Spam, spyware or spoofing</p>
<p class="mb-4">
We and our users do not tolerate spam. Make sure to set your email preferences so we can communicate with you, as you prefer. Please add us to your safe senders list. To report spam or spoof emails, please
contact us using the contact information provided in the Contact Us section of this privacy statement.
</p>
<p class="mb-4">
You may not use our communication tools to send spam or otherwise send content that would breach our Terms and Conditions. We automatically scan and may manually filter messages to check for spam, viruses,
phishing attacks and other malicious activity or illegal or prohibited content. We may also store these messages for back up purposes only.
</p>
<p class="mb-4">
If you send an email to an email address that is not registered in our community, we do not permanently store that email or use that email address for any marketing purpose. We do not rent or sell these email
addresses.
</p>
<p class="font-bold mb-4 mt-6">Account protection</p>
<p class="mb-4">
Your password is the key to your account. Make sure this is stored safely. Use unique numbers, letters and special characters, and do not disclose your password to anyone. If you do share your password or
your personal information with others, remember that you are responsible for all actions taken in the name of your account. If you lose control of your password, you may lose substantial control over your
personal information and may be subject to legally binding actions taken on your behalf. Therefore, if your password has been compromised for any reason, you should immediately notify us and change your
password.
</p>
<p class="font-bold mb-4 mt-6">Accessing, reviewing and changing your personal information</p>
<p class="mb-4">You can view and amend your personal information at any time by logging in to your account online. You must promptly update your personal information if it changes or is inaccurate.</p>
<p class="mb-4">If at any time you wish to close your account, please contact Customer Services and instruct us to do so. We will process your request as soon as we can.</p>
<p class="mb-4">You may also contact us at any time to find out what information we hold about you, what we do with it and ask us to update it for you.</p>
<p class="mb-4">
We do retain personal information from closed accounts to comply with law, prevent fraud, collect any fees owed, resolve disputes, troubleshoot problems, assist with any investigations, enforce our Terms and
Conditions, and take other actions otherwise permitted by law.
</p>
<p class="font-bold mb-4 mt-6">Security</p>
<p class="mb-4">
Your information is stored on our servers located in the USA. We treat data as an asset that must be protected and use a variety of tools (encryption, passwords, physical security, etc.) to protect your
personal information against unauthorized access and disclosure. However, no method of security is 100% effective and while we take every measure to protect your personal information, we make no guarantees of
its absolute security.
</p>
<p class="mb-4">We employ the use of SSL encryption during the transmission of sensitive data across our websites.</p>
<p class="font-bold mb-4 mt-6">Third parties</p>
<p class="mb-4">
Except as otherwise expressly included in this Privacy Policy, this document addresses only the use and disclosure of information we collect from you. If you disclose your information to others, whether they
are buyers or sellers on our websites or other sites throughout the internet, different rules may apply to their use or disclosure of the information you disclose to them. Dynamis does not control the privacy
policies of third parties, and you are subject to the privacy policies of those third parties where applicable.
</p>
<p class="mb-4">We encourage you to ask questions before you disclose your personal information to others.</p>
<p class="font-bold mb-4 mt-6">General</p>
<p class="mb-4">
We may change this Privacy Policy from time to time as we add new products and applications, as we improve our current offerings, and as technologies and laws change. You can determine when this Privacy
Policy was last revised by referring to the "Last Updated" legend at the top of this page.
</p>
<p class="mb-4">
Any changes will become effective upon our posting of the revised Privacy Policy on our affected websites. We will provide notice to you if these changes are material and, where required by applicable law, we
will obtain your consent. This notice may be provided by email, by posting notice of the changes on our affected websites or by other means, consistent with applicable laws.
</p>
<p class="font-bold mb-4 mt-6">Contact Us</p>
<p class="mb-4">
If you have any questions or comments about our privacy policy, and you can't find the answer to your question on our help pages, please contact us using this form or email support&#64;bizmatch.net, or write
to us at BizMatch, 715 S. Tanahua, Corpus Christi, TX 78401.)
</p>
</div>
</section>
</article>
</section>
</div>
</div>

View File

@@ -1 +1 @@
// Privacy Statement component styles
// Privacy Statement component styles

View File

@@ -1,30 +1,30 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule, Location } from '@angular/common';
import { SeoService } from '../../services/seo.service';
@Component({
selector: 'app-privacy-statement',
standalone: true,
imports: [CommonModule],
templateUrl: './privacy-statement.component.html',
styleUrls: ['./privacy-statement.component.scss']
})
export class PrivacyStatementComponent implements OnInit {
constructor(
private seoService: SeoService,
private location: Location
) {}
ngOnInit(): void {
// Set SEO meta tags for Privacy Statement page
this.seoService.updateMetaTags({
title: 'Privacy Statement - BizMatch',
description: 'Learn how BizMatch collects, uses, and protects your personal information. Read our privacy policy and data protection practices.',
type: 'website'
});
}
goBack(): void {
this.location.back();
}
}
import { Component, OnInit } from '@angular/core';
import { CommonModule, Location } from '@angular/common';
import { SeoService } from '../../services/seo.service';
@Component({
selector: 'app-privacy-statement',
standalone: true,
imports: [CommonModule],
templateUrl: './privacy-statement.component.html',
styleUrls: ['./privacy-statement.component.scss']
})
export class PrivacyStatementComponent implements OnInit {
constructor(
private seoService: SeoService,
private location: Location
) {}
ngOnInit(): void {
// Set SEO meta tags for Privacy Statement page
this.seoService.updateMetaTags({
title: 'Privacy Statement - BizMatch',
description: 'Learn how BizMatch collects, uses, and protects your personal information. Read our privacy policy and data protection practices.',
type: 'website'
});
}
goBack(): void {
this.location.back();
}
}

View File

@@ -1,150 +1,150 @@
<div class="container mx-auto px-4 py-8 max-w-4xl">
<div class="bg-white rounded-lg drop-shadow-custom-bg p-6 md:p-8 relative">
<button
(click)="goBack()"
class="absolute top-4 right-4 md:top-6 md:right-6 w-10 h-10 flex items-center justify-center rounded-full bg-red-600 hover:bg-red-700 text-white transition-colors duration-200"
aria-label="Go back"
>
<i class="fas fa-arrow-left text-lg"></i>
</button>
<h1 class="text-3xl font-bold text-neutral-900 mb-6 pr-14">Terms of Use</h1>
<section id="content" role="main">
<article class="post page">
<section class="entry-content">
<div class="container">
<p class="font-bold text-lg mb-4">AGREEMENT BETWEEN USER AND BizMatch</p>
<p class="mb-4">The BizMatch Web Site is comprised of various Web pages operated by BizMatch.</p>
<p class="mb-4">
The BizMatch Web Site is offered to you conditioned on your acceptance without modification of the terms, conditions, and notices contained herein. Your use of the BizMatch Web Site constitutes your
agreement to all such terms, conditions, and notices.
</p>
<p class="font-bold text-lg mb-4 mt-6">MODIFICATION OF THESE TERMS OF USE</p>
<p class="mb-4">
BizMatch reserves the right to change the terms, conditions, and notices under which the BizMatch Web Site is offered, including but not limited to the charges associated with the use of the BizMatch Web
Site.
</p>
<p class="font-bold text-lg mb-4 mt-6">LINKS TO THIRD PARTY SITES</p>
<p class="mb-4">
The BizMatch Web Site may contain links to other Web Sites ("Linked Sites"). The Linked Sites are not under the control of BizMatch and BizMatch is not responsible for the contents of any Linked Site,
including without limitation any link contained in a Linked Site, or any changes or updates to a Linked Site. BizMatch is not responsible for webcasting or any other form of transmission received from any
Linked Site. BizMatch is providing these links to you only as a convenience, and the inclusion of any link does not imply endorsement by BizMatch of the site or any association with its operators.
</p>
<p class="font-bold text-lg mb-4 mt-6">NO UNLAWFUL OR PROHIBITED USE</p>
<p class="mb-4">
As a condition of your use of the BizMatch Web Site, you warrant to BizMatch that you will not use the BizMatch Web Site for any purpose that is unlawful or prohibited by these terms, conditions, and
notices. You may not use the BizMatch Web Site in any manner which could damage, disable, overburden, or impair the BizMatch Web Site or interfere with any other party's use and enjoyment of the BizMatch
Web Site. You may not obtain or attempt to obtain any materials or information through any means not intentionally made available or provided for through the BizMatch Web Sites.
</p>
<p class="font-bold text-lg mb-4 mt-6">USE OF COMMUNICATION SERVICES</p>
<p class="mb-4">
The BizMatch Web Site may contain bulletin board services, chat areas, news groups, forums, communities, personal web pages, calendars, and/or other message or communication facilities designed to enable
you to communicate with the public at large or with a group (collectively, "Communication Services"), you agree to use the Communication Services only to post, send and receive messages and material that
are proper and related to the particular Communication Service. By way of example, and not as a limitation, you agree that when using a Communication Service, you will not:
</p>
<ul class="list-disc pl-6 mb-4 space-y-2">
<li>Defame, abuse, harass, stalk, threaten or otherwise violate the legal rights (such as rights of privacy and publicity) of others.</li>
<li>Publish, post, upload, distribute or disseminate any inappropriate, profane, defamatory, infringing, obscene, indecent or unlawful topic, name, material or information.</li>
<li>Upload files that contain software or other material protected by intellectual property laws (or by rights of privacy of publicity) unless you own or control the rights thereto or have received all necessary consents.</li>
<li>Upload files that contain viruses, corrupted files, or any other similar software or programs that may damage the operation of another's computer.</li>
<li>Advertise or offer to sell or buy any goods or services for any business purpose, unless such Communication Service specifically allows such messages.</li>
<li>Conduct or forward surveys, contests, pyramid schemes or chain letters.</li>
<li>Download any file posted by another user of a Communication Service that you know, or reasonably should know, cannot be legally distributed in such manner.</li>
<li>Falsify or delete any author attributions, legal or other proper notices or proprietary designations or labels of the origin or source of software or other material contained in a file that is uploaded.</li>
<li>Restrict or inhibit any other user from using and enjoying the Communication Services.</li>
<li>Violate any code of conduct or other guidelines which may be applicable for any particular Communication Service.</li>
<li>Harvest or otherwise collect information about others, including e-mail addresses, without their consent.</li>
<li>Violate any applicable laws or regulations.</li>
</ul>
<p class="mb-4">
BizMatch has no obligation to monitor the Communication Services. However, BizMatch reserves the right to review materials posted to a Communication Service and to remove any materials in its sole
discretion. BizMatch reserves the right to terminate your access to any or all of the Communication Services at any time without notice for any reason whatsoever.
</p>
<p class="mb-4">
BizMatch reserves the right at all times to disclose any information as necessary to satisfy any applicable law, regulation, legal process or governmental request, or to edit, refuse to post or to remove
any information or materials, in whole or in part, in BizMatch's sole discretion.
</p>
<p class="mb-4">
Always use caution when giving out any personally identifying information about yourself or your children in any Communication Service. BizMatch does not control or endorse the content, messages or
information found in any Communication Service and, therefore, BizMatch specifically disclaims any liability with regard to the Communication Services and any actions resulting from your participation in
any Communication Service. Managers and hosts are not authorized BizMatch spokespersons, and their views do not necessarily reflect those of BizMatch.
</p>
<p class="mb-4">
Materials uploaded to a Communication Service may be subject to posted limitations on usage, reproduction and/or dissemination. You are responsible for adhering to such limitations if you download the
materials.
</p>
<p class="font-bold text-lg mb-4 mt-6">MATERIALS PROVIDED TO BizMatch OR POSTED AT ANY BizMatch WEB SITE</p>
<p class="mb-4">
BizMatch does not claim ownership of the materials you provide to BizMatch (including feedback and suggestions) or post, upload, input or submit to any BizMatch Web Site or its associated services
(collectively "Submissions"). However, by posting, uploading, inputting, providing or submitting your Submission you are granting BizMatch, its affiliated companies and necessary sublicensees permission
to use your Submission in connection with the operation of their Internet businesses including, without limitation, the rights to: copy, distribute, transmit, publicly display, publicly perform,
reproduce, edit, translate and reformat your Submission; and to publish your name in connection with your Submission.
</p>
<p class="mb-4">
No compensation will be paid with respect to the use of your Submission, as provided herein. BizMatch is under no obligation to post or use any Submission you may provide and may remove any Submission at
any time in BizMatch's sole discretion.
</p>
<p class="mb-4">
By posting, uploading, inputting, providing or submitting your Submission you warrant and represent that you own or otherwise control all of the rights to your Submission as described in this section
including, without limitation, all the rights necessary for you to provide, post, upload, input or submit the Submissions.
</p>
<p class="font-bold text-lg mb-4 mt-6">LIABILITY DISCLAIMER</p>
<p class="mb-4">
THE INFORMATION, SOFTWARE, PRODUCTS, AND SERVICES INCLUDED IN OR AVAILABLE THROUGH THE BizMatch WEB SITE MAY INCLUDE INACCURACIES OR TYPOGRAPHICAL ERRORS. CHANGES ARE PERIODICALLY ADDED TO THE
INFORMATION HEREIN. BizMatch AND/OR ITS SUPPLIERS MAY MAKE IMPROVEMENTS AND/OR CHANGES IN THE BizMatch WEB SITE AT ANY TIME. ADVICE RECEIVED VIA THE BizMatch WEB SITE SHOULD NOT BE RELIED UPON FOR
PERSONAL, MEDICAL, LEGAL OR FINANCIAL DECISIONS AND YOU SHOULD CONSULT AN APPROPRIATE PROFESSIONAL FOR SPECIFIC ADVICE TAILORED TO YOUR SITUATION.
</p>
<p class="mb-4">
BizMatch AND/OR ITS SUPPLIERS MAKE NO REPRESENTATIONS ABOUT THE SUITABILITY, RELIABILITY, AVAILABILITY, TIMELINESS, AND ACCURACY OF THE INFORMATION, SOFTWARE, PRODUCTS, SERVICES AND RELATED GRAPHICS
CONTAINED ON THE BizMatch WEB SITE FOR ANY PURPOSE. TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, ALL SUCH INFORMATION, SOFTWARE, PRODUCTS, SERVICES AND RELATED GRAPHICS ARE PROVIDED "AS IS" WITHOUT
WARRANTY OR CONDITION OF ANY KIND. BizMatch AND/OR ITS SUPPLIERS HEREBY DISCLAIM ALL WARRANTIES AND CONDITIONS WITH REGARD TO THIS INFORMATION, SOFTWARE, PRODUCTS, SERVICES AND RELATED GRAPHICS, INCLUDING
ALL IMPLIED WARRANTIES OR CONDITIONS OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT.
</p>
<p class="mb-4">
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL BizMatch AND/OR ITS SUPPLIERS BE LIABLE FOR ANY DIRECT, INDIRECT, PUNITIVE, INCIDENTAL, SPECIAL, CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER INCLUDING, WITHOUT LIMITATION, DAMAGES FOR LOSS OF USE, DATA OR PROFITS, ARISING OUT OF OR IN ANY WAY CONNECTED WITH THE USE OR PERFORMANCE OF THE BizMatch WEB SITE, WITH THE DELAY OR INABILITY
TO USE THE BizMatch WEB SITE OR RELATED SERVICES, THE PROVISION OF OR FAILURE TO PROVIDE SERVICES, OR FOR ANY INFORMATION, SOFTWARE, PRODUCTS, SERVICES AND RELATED GRAPHICS OBTAINED THROUGH THE BizMatch
WEB SITE, OR OTHERWISE ARISING OUT OF THE USE OF THE BizMatch WEB SITE, WHETHER BASED ON CONTRACT, TORT, NEGLIGENCE, STRICT LIABILITY OR OTHERWISE, EVEN IF BizMatch OR ANY OF ITS SUPPLIERS HAS BEEN
ADVISED OF THE POSSIBILITY OF DAMAGES. BECAUSE SOME STATES/JURISDICTIONS DO NOT ALLOW THE EXCLUSION OR LIMITATION OF LIABILITY FOR CONSEQUENTIAL OR INCIDENTAL DAMAGES, THE ABOVE LIMITATION MAY NOT APPLY
TO YOU. IF YOU ARE DISSATISFIED WITH ANY PORTION OF THE BizMatch WEB SITE, OR WITH ANY OF THESE TERMS OF USE, YOUR SOLE AND EXCLUSIVE REMEDY IS TO DISCONTINUE USING THE BizMatch WEB SITE.
</p>
<p class="mb-4">SERVICE CONTACT : info&#64;bizmatch.net</p>
<p class="font-bold text-lg mb-4 mt-6">TERMINATION/ACCESS RESTRICTION</p>
<p class="mb-4">
BizMatch reserves the right, in its sole discretion, to terminate your access to the BizMatch Web Site and the related services or any portion thereof at any time, without notice. GENERAL To the maximum
extent permitted by law, this agreement is governed by the laws of the State of Washington, U.S.A. and you hereby consent to the exclusive jurisdiction and venue of courts in King County, Washington,
U.S.A. in all disputes arising out of or relating to the use of the BizMatch Web Site. Use of the BizMatch Web Site is unauthorized in any jurisdiction that does not give effect to all provisions of these
terms and conditions, including without limitation this paragraph. You agree that no joint venture, partnership, employment, or agency relationship exists between you and BizMatch as a result of this
agreement or use of the BizMatch Web Site. BizMatch's performance of this agreement is subject to existing laws and legal process, and nothing contained in this agreement is in derogation of BizMatch's
right to comply with governmental, court and law enforcement requests or requirements relating to your use of the BizMatch Web Site or information provided to or gathered by BizMatch with respect to such
use. If any part of this agreement is determined to be invalid or unenforceable pursuant to applicable law including, but not limited to, the warranty disclaimers and liability limitations set forth
above, then the invalid or unenforceable provision will be deemed superseded by a valid, enforceable provision that most closely matches the intent of the original provision and the remainder of the
agreement shall continue in effect. Unless otherwise specified herein, this agreement constitutes the entire agreement between the user and BizMatch with respect to the BizMatch Web Site and it supersedes
all prior or contemporaneous communications and proposals, whether electronic, oral or written, between the user and BizMatch with respect to the BizMatch Web Site. A printed version of this agreement and
of any notice given in electronic form shall be admissible in judicial or administrative proceedings based upon or relating to this agreement to the same extent and subject to the same conditions as
other business documents and records originally generated and maintained in printed form. It is the express wish to the parties that this agreement and all related documents be drawn up in English.
</p>
<p class="font-bold text-lg mb-4 mt-6">COPYRIGHT AND TRADEMARK NOTICES:</p>
<p class="mb-4">All contents of the BizMatch Web Site are: Copyright 2011 by Bizmatch Business Solutions and/or its suppliers. All rights reserved.</p>
<p class="font-bold text-lg mb-4 mt-6">TRADEMARKS</p>
<p class="mb-4">The names of actual companies and products mentioned herein may be the trademarks of their respective owners.</p>
<p class="mb-4">
The example companies, organizations, products, people and events depicted herein are fictitious. No association with any real company, organization, product, person, or event is intended or should be
inferred.
</p>
<p class="mb-4">Any rights not expressly granted herein are reserved.</p>
<p class="font-bold text-lg mb-4 mt-6">NOTICES AND PROCEDURE FOR MAKING CLAIMS OF COPYRIGHT INFRINGEMENT</p>
<p class="mb-4">
Pursuant to Title 17, United States Code, Section 512(c)(2), notifications of claimed copyright infringement under United States copyright law should be sent to Service Provider's Designated Agent. ALL
INQUIRIES NOT RELEVANT TO THE FOLLOWING PROCEDURE WILL RECEIVE NO RESPONSE. See Notice and Procedure for Making Claims of Copyright Infringement.
</p>
<p class="mb-4">
We reserve the right to update or revise these Terms of Use at any time without notice. Please check the Terms of Use periodically for changes. The revised terms will be effective immediately as
soon as they are posted on the WebSite and by continuing to use the Site you agree to be bound by the revised terms.
</p>
</div>
</section>
</article>
</section>
</div>
</div>
<div class="container mx-auto px-4 py-8 max-w-4xl">
<div class="bg-white rounded-lg drop-shadow-custom-bg p-6 md:p-8 relative">
<button
(click)="goBack()"
class="absolute top-4 right-4 md:top-6 md:right-6 w-10 h-10 flex items-center justify-center rounded-full bg-red-600 hover:bg-red-700 text-white transition-colors duration-200"
aria-label="Go back"
>
<i class="fas fa-arrow-left text-lg"></i>
</button>
<h1 class="text-3xl font-bold text-neutral-900 mb-6 pr-14">Terms of Use</h1>
<section id="content" role="main">
<article class="post page">
<section class="entry-content">
<div class="container">
<p class="font-bold text-lg mb-4">AGREEMENT BETWEEN USER AND BizMatch</p>
<p class="mb-4">The BizMatch Web Site is comprised of various Web pages operated by BizMatch.</p>
<p class="mb-4">
The BizMatch Web Site is offered to you conditioned on your acceptance without modification of the terms, conditions, and notices contained herein. Your use of the BizMatch Web Site constitutes your
agreement to all such terms, conditions, and notices.
</p>
<p class="font-bold text-lg mb-4 mt-6">MODIFICATION OF THESE TERMS OF USE</p>
<p class="mb-4">
BizMatch reserves the right to change the terms, conditions, and notices under which the BizMatch Web Site is offered, including but not limited to the charges associated with the use of the BizMatch Web
Site.
</p>
<p class="font-bold text-lg mb-4 mt-6">LINKS TO THIRD PARTY SITES</p>
<p class="mb-4">
The BizMatch Web Site may contain links to other Web Sites ("Linked Sites"). The Linked Sites are not under the control of BizMatch and BizMatch is not responsible for the contents of any Linked Site,
including without limitation any link contained in a Linked Site, or any changes or updates to a Linked Site. BizMatch is not responsible for webcasting or any other form of transmission received from any
Linked Site. BizMatch is providing these links to you only as a convenience, and the inclusion of any link does not imply endorsement by BizMatch of the site or any association with its operators.
</p>
<p class="font-bold text-lg mb-4 mt-6">NO UNLAWFUL OR PROHIBITED USE</p>
<p class="mb-4">
As a condition of your use of the BizMatch Web Site, you warrant to BizMatch that you will not use the BizMatch Web Site for any purpose that is unlawful or prohibited by these terms, conditions, and
notices. You may not use the BizMatch Web Site in any manner which could damage, disable, overburden, or impair the BizMatch Web Site or interfere with any other party's use and enjoyment of the BizMatch
Web Site. You may not obtain or attempt to obtain any materials or information through any means not intentionally made available or provided for through the BizMatch Web Sites.
</p>
<p class="font-bold text-lg mb-4 mt-6">USE OF COMMUNICATION SERVICES</p>
<p class="mb-4">
The BizMatch Web Site may contain bulletin board services, chat areas, news groups, forums, communities, personal web pages, calendars, and/or other message or communication facilities designed to enable
you to communicate with the public at large or with a group (collectively, "Communication Services"), you agree to use the Communication Services only to post, send and receive messages and material that
are proper and related to the particular Communication Service. By way of example, and not as a limitation, you agree that when using a Communication Service, you will not:
</p>
<ul class="list-disc pl-6 mb-4 space-y-2">
<li>Defame, abuse, harass, stalk, threaten or otherwise violate the legal rights (such as rights of privacy and publicity) of others.</li>
<li>Publish, post, upload, distribute or disseminate any inappropriate, profane, defamatory, infringing, obscene, indecent or unlawful topic, name, material or information.</li>
<li>Upload files that contain software or other material protected by intellectual property laws (or by rights of privacy of publicity) unless you own or control the rights thereto or have received all necessary consents.</li>
<li>Upload files that contain viruses, corrupted files, or any other similar software or programs that may damage the operation of another's computer.</li>
<li>Advertise or offer to sell or buy any goods or services for any business purpose, unless such Communication Service specifically allows such messages.</li>
<li>Conduct or forward surveys, contests, pyramid schemes or chain letters.</li>
<li>Download any file posted by another user of a Communication Service that you know, or reasonably should know, cannot be legally distributed in such manner.</li>
<li>Falsify or delete any author attributions, legal or other proper notices or proprietary designations or labels of the origin or source of software or other material contained in a file that is uploaded.</li>
<li>Restrict or inhibit any other user from using and enjoying the Communication Services.</li>
<li>Violate any code of conduct or other guidelines which may be applicable for any particular Communication Service.</li>
<li>Harvest or otherwise collect information about others, including e-mail addresses, without their consent.</li>
<li>Violate any applicable laws or regulations.</li>
</ul>
<p class="mb-4">
BizMatch has no obligation to monitor the Communication Services. However, BizMatch reserves the right to review materials posted to a Communication Service and to remove any materials in its sole
discretion. BizMatch reserves the right to terminate your access to any or all of the Communication Services at any time without notice for any reason whatsoever.
</p>
<p class="mb-4">
BizMatch reserves the right at all times to disclose any information as necessary to satisfy any applicable law, regulation, legal process or governmental request, or to edit, refuse to post or to remove
any information or materials, in whole or in part, in BizMatch's sole discretion.
</p>
<p class="mb-4">
Always use caution when giving out any personally identifying information about yourself or your children in any Communication Service. BizMatch does not control or endorse the content, messages or
information found in any Communication Service and, therefore, BizMatch specifically disclaims any liability with regard to the Communication Services and any actions resulting from your participation in
any Communication Service. Managers and hosts are not authorized BizMatch spokespersons, and their views do not necessarily reflect those of BizMatch.
</p>
<p class="mb-4">
Materials uploaded to a Communication Service may be subject to posted limitations on usage, reproduction and/or dissemination. You are responsible for adhering to such limitations if you download the
materials.
</p>
<p class="font-bold text-lg mb-4 mt-6">MATERIALS PROVIDED TO BizMatch OR POSTED AT ANY BizMatch WEB SITE</p>
<p class="mb-4">
BizMatch does not claim ownership of the materials you provide to BizMatch (including feedback and suggestions) or post, upload, input or submit to any BizMatch Web Site or its associated services
(collectively "Submissions"). However, by posting, uploading, inputting, providing or submitting your Submission you are granting BizMatch, its affiliated companies and necessary sublicensees permission
to use your Submission in connection with the operation of their Internet businesses including, without limitation, the rights to: copy, distribute, transmit, publicly display, publicly perform,
reproduce, edit, translate and reformat your Submission; and to publish your name in connection with your Submission.
</p>
<p class="mb-4">
No compensation will be paid with respect to the use of your Submission, as provided herein. BizMatch is under no obligation to post or use any Submission you may provide and may remove any Submission at
any time in BizMatch's sole discretion.
</p>
<p class="mb-4">
By posting, uploading, inputting, providing or submitting your Submission you warrant and represent that you own or otherwise control all of the rights to your Submission as described in this section
including, without limitation, all the rights necessary for you to provide, post, upload, input or submit the Submissions.
</p>
<p class="font-bold text-lg mb-4 mt-6">LIABILITY DISCLAIMER</p>
<p class="mb-4">
THE INFORMATION, SOFTWARE, PRODUCTS, AND SERVICES INCLUDED IN OR AVAILABLE THROUGH THE BizMatch WEB SITE MAY INCLUDE INACCURACIES OR TYPOGRAPHICAL ERRORS. CHANGES ARE PERIODICALLY ADDED TO THE
INFORMATION HEREIN. BizMatch AND/OR ITS SUPPLIERS MAY MAKE IMPROVEMENTS AND/OR CHANGES IN THE BizMatch WEB SITE AT ANY TIME. ADVICE RECEIVED VIA THE BizMatch WEB SITE SHOULD NOT BE RELIED UPON FOR
PERSONAL, MEDICAL, LEGAL OR FINANCIAL DECISIONS AND YOU SHOULD CONSULT AN APPROPRIATE PROFESSIONAL FOR SPECIFIC ADVICE TAILORED TO YOUR SITUATION.
</p>
<p class="mb-4">
BizMatch AND/OR ITS SUPPLIERS MAKE NO REPRESENTATIONS ABOUT THE SUITABILITY, RELIABILITY, AVAILABILITY, TIMELINESS, AND ACCURACY OF THE INFORMATION, SOFTWARE, PRODUCTS, SERVICES AND RELATED GRAPHICS
CONTAINED ON THE BizMatch WEB SITE FOR ANY PURPOSE. TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, ALL SUCH INFORMATION, SOFTWARE, PRODUCTS, SERVICES AND RELATED GRAPHICS ARE PROVIDED "AS IS" WITHOUT
WARRANTY OR CONDITION OF ANY KIND. BizMatch AND/OR ITS SUPPLIERS HEREBY DISCLAIM ALL WARRANTIES AND CONDITIONS WITH REGARD TO THIS INFORMATION, SOFTWARE, PRODUCTS, SERVICES AND RELATED GRAPHICS, INCLUDING
ALL IMPLIED WARRANTIES OR CONDITIONS OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT.
</p>
<p class="mb-4">
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL BizMatch AND/OR ITS SUPPLIERS BE LIABLE FOR ANY DIRECT, INDIRECT, PUNITIVE, INCIDENTAL, SPECIAL, CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER INCLUDING, WITHOUT LIMITATION, DAMAGES FOR LOSS OF USE, DATA OR PROFITS, ARISING OUT OF OR IN ANY WAY CONNECTED WITH THE USE OR PERFORMANCE OF THE BizMatch WEB SITE, WITH THE DELAY OR INABILITY
TO USE THE BizMatch WEB SITE OR RELATED SERVICES, THE PROVISION OF OR FAILURE TO PROVIDE SERVICES, OR FOR ANY INFORMATION, SOFTWARE, PRODUCTS, SERVICES AND RELATED GRAPHICS OBTAINED THROUGH THE BizMatch
WEB SITE, OR OTHERWISE ARISING OUT OF THE USE OF THE BizMatch WEB SITE, WHETHER BASED ON CONTRACT, TORT, NEGLIGENCE, STRICT LIABILITY OR OTHERWISE, EVEN IF BizMatch OR ANY OF ITS SUPPLIERS HAS BEEN
ADVISED OF THE POSSIBILITY OF DAMAGES. BECAUSE SOME STATES/JURISDICTIONS DO NOT ALLOW THE EXCLUSION OR LIMITATION OF LIABILITY FOR CONSEQUENTIAL OR INCIDENTAL DAMAGES, THE ABOVE LIMITATION MAY NOT APPLY
TO YOU. IF YOU ARE DISSATISFIED WITH ANY PORTION OF THE BizMatch WEB SITE, OR WITH ANY OF THESE TERMS OF USE, YOUR SOLE AND EXCLUSIVE REMEDY IS TO DISCONTINUE USING THE BizMatch WEB SITE.
</p>
<p class="mb-4">SERVICE CONTACT : info&#64;bizmatch.net</p>
<p class="font-bold text-lg mb-4 mt-6">TERMINATION/ACCESS RESTRICTION</p>
<p class="mb-4">
BizMatch reserves the right, in its sole discretion, to terminate your access to the BizMatch Web Site and the related services or any portion thereof at any time, without notice. GENERAL To the maximum
extent permitted by law, this agreement is governed by the laws of the State of Washington, U.S.A. and you hereby consent to the exclusive jurisdiction and venue of courts in King County, Washington,
U.S.A. in all disputes arising out of or relating to the use of the BizMatch Web Site. Use of the BizMatch Web Site is unauthorized in any jurisdiction that does not give effect to all provisions of these
terms and conditions, including without limitation this paragraph. You agree that no joint venture, partnership, employment, or agency relationship exists between you and BizMatch as a result of this
agreement or use of the BizMatch Web Site. BizMatch's performance of this agreement is subject to existing laws and legal process, and nothing contained in this agreement is in derogation of BizMatch's
right to comply with governmental, court and law enforcement requests or requirements relating to your use of the BizMatch Web Site or information provided to or gathered by BizMatch with respect to such
use. If any part of this agreement is determined to be invalid or unenforceable pursuant to applicable law including, but not limited to, the warranty disclaimers and liability limitations set forth
above, then the invalid or unenforceable provision will be deemed superseded by a valid, enforceable provision that most closely matches the intent of the original provision and the remainder of the
agreement shall continue in effect. Unless otherwise specified herein, this agreement constitutes the entire agreement between the user and BizMatch with respect to the BizMatch Web Site and it supersedes
all prior or contemporaneous communications and proposals, whether electronic, oral or written, between the user and BizMatch with respect to the BizMatch Web Site. A printed version of this agreement and
of any notice given in electronic form shall be admissible in judicial or administrative proceedings based upon or relating to this agreement to the same extent and subject to the same conditions as
other business documents and records originally generated and maintained in printed form. It is the express wish to the parties that this agreement and all related documents be drawn up in English.
</p>
<p class="font-bold text-lg mb-4 mt-6">COPYRIGHT AND TRADEMARK NOTICES:</p>
<p class="mb-4">All contents of the BizMatch Web Site are: Copyright 2011 by Bizmatch Business Solutions and/or its suppliers. All rights reserved.</p>
<p class="font-bold text-lg mb-4 mt-6">TRADEMARKS</p>
<p class="mb-4">The names of actual companies and products mentioned herein may be the trademarks of their respective owners.</p>
<p class="mb-4">
The example companies, organizations, products, people and events depicted herein are fictitious. No association with any real company, organization, product, person, or event is intended or should be
inferred.
</p>
<p class="mb-4">Any rights not expressly granted herein are reserved.</p>
<p class="font-bold text-lg mb-4 mt-6">NOTICES AND PROCEDURE FOR MAKING CLAIMS OF COPYRIGHT INFRINGEMENT</p>
<p class="mb-4">
Pursuant to Title 17, United States Code, Section 512(c)(2), notifications of claimed copyright infringement under United States copyright law should be sent to Service Provider's Designated Agent. ALL
INQUIRIES NOT RELEVANT TO THE FOLLOWING PROCEDURE WILL RECEIVE NO RESPONSE. See Notice and Procedure for Making Claims of Copyright Infringement.
</p>
<p class="mb-4">
We reserve the right to update or revise these Terms of Use at any time without notice. Please check the Terms of Use periodically for changes. The revised terms will be effective immediately as
soon as they are posted on the WebSite and by continuing to use the Site you agree to be bound by the revised terms.
</p>
</div>
</section>
</article>
</section>
</div>
</div>

View File

@@ -1 +1 @@
// Terms of Use component styles
// Terms of Use component styles

View File

@@ -1,30 +1,30 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule, Location } from '@angular/common';
import { SeoService } from '../../services/seo.service';
@Component({
selector: 'app-terms-of-use',
standalone: true,
imports: [CommonModule],
templateUrl: './terms-of-use.component.html',
styleUrls: ['./terms-of-use.component.scss']
})
export class TermsOfUseComponent implements OnInit {
constructor(
private seoService: SeoService,
private location: Location
) {}
ngOnInit(): void {
// Set SEO meta tags for Terms of Use page
this.seoService.updateMetaTags({
title: 'Terms of Use - BizMatch',
description: 'Read the terms and conditions for using BizMatch marketplace. Learn about user responsibilities, listing guidelines, and platform rules.',
type: 'website'
});
}
goBack(): void {
this.location.back();
}
}
import { Component, OnInit } from '@angular/core';
import { CommonModule, Location } from '@angular/common';
import { SeoService } from '../../services/seo.service';
@Component({
selector: 'app-terms-of-use',
standalone: true,
imports: [CommonModule],
templateUrl: './terms-of-use.component.html',
styleUrls: ['./terms-of-use.component.scss']
})
export class TermsOfUseComponent implements OnInit {
constructor(
private seoService: SeoService,
private location: Location
) {}
ngOnInit(): void {
// Set SEO meta tags for Terms of Use page
this.seoService.updateMetaTags({
title: 'Terms of Use - BizMatch',
description: 'Read the terms and conditions for using BizMatch marketplace. Learn about user responsibilities, listing guidelines, and platform rules.',
type: 'website'
});
}
goBack(): void {
this.location.back();
}
}

View File

@@ -1,159 +1,159 @@
<div class="flex flex-col md:flex-row">
<!-- Filter Panel for Desktop -->
<div class="hidden md:block w-full md:w-1/4 h-full bg-white shadow-lg p-6 overflow-y-auto z-10">
<app-search-modal-broker [isModal]="false"></app-search-modal-broker>
</div>
<!-- Main Content -->
<div class="w-full p-4">
<div class="container mx-auto">
<!-- Breadcrumbs -->
<div class="mb-4">
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
</div>
<!-- SEO-optimized heading -->
<div class="mb-6">
<h1 class="text-3xl md:text-4xl font-bold text-neutral-900 mb-2">Professional Business Brokers & Advisors</h1>
<p class="text-lg text-neutral-600">Connect with licensed business brokers, CPAs, attorneys, and other
professionals across the United States.</p>
</div>
<!-- Mobile Filter Button -->
<div class="md:hidden mb-4">
<button (click)="openFilterModal()"
class="w-full bg-primary-600 text-white py-3 px-4 rounded-lg flex items-center justify-center">
<i class="fas fa-filter mr-2"></i>
Filter Results
</button>
</div>
@if(users?.length>0){
<h2 class="text-2xl font-semibold text-neutral-800 mb-4">Professional Listings</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Professional Cards -->
@for (user of users; track user) {
<div
class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6 flex flex-col justify-between hover:shadow-2xl transition-all duration-300 hover:scale-[1.02] group relative">
<!-- Quick Actions Overlay -->
<div
class="absolute top-4 right-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-20">
@if(currentUser) {
<button type="button" class="bg-white rounded-full p-2 shadow-lg transition-colors"
[class.bg-red-50]="isFavorite(user)"
[title]="isFavorite(user) ? 'Remove from favorites' : 'Save to favorites'"
(click)="toggleFavorite($event, user)">
<i
[class]="isFavorite(user) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i>
</button>
}
<button type="button" class="bg-white rounded-full p-2 shadow-lg hover:bg-blue-50 transition-colors"
title="Share professional" (click)="shareProfessional($event, user)">
<i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i>
</button>
</div>
<div class="flex items-start space-x-4">
@if(user.hasProfile){
<img src="{{ env.imageBaseUrl }}/pictures/profile/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}"
[alt]="altText.generateBrokerProfileAlt(user)" class="rounded-md w-20 h-26 object-cover" width="80"
height="104" />
} @else {
<img src="/assets/images/person_placeholder.jpg" alt="Default business broker placeholder profile photo"
class="rounded-md w-20 h-26 object-cover" width="80" height="104" />
}
<div class="flex-1">
<p class="text-sm text-neutral-800 mb-2">{{ user.description }}</p>
<h3 class="text-lg font-semibold">
{{ user.firstname }} {{ user.lastname }}<span
class="bg-neutral-200 text-neutral-700 text-xs font-semibold px-2 py-1 rounded ml-4">{{
user.location?.name }} - {{ user.location?.state }}</span>
</h3>
<div class="flex items-center space-x-2 mt-2">
<app-customer-sub-type [customerSubType]="user.customerSubType"></app-customer-sub-type>
<p class="text-sm text-neutral-600">{{ user.companyName }}</p>
</div>
<div class="flex items-center justify-between my-2"></div>
</div>
</div>
<div class="mt-4 flex justify-between items-center">
@if(user.hasCompanyLogo){
<img src="{{ env.imageBaseUrl }}/pictures/logo/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}"
[alt]="altText.generateCompanyLogoAlt(user.companyName, user.firstname + ' ' + user.lastname)"
class="w-8 h-10 object-contain" width="32" height="40" />
} @else {
<img src="/assets/images/placeholder.png" alt="Default company logo placeholder"
class="w-8 h-10 object-contain" width="32" height="40" />
}
<button
class="bg-success-500 hover:bg-success-600 text-white font-medium py-2 px-4 rounded-full flex items-center"
[routerLink]="['/details-user', user.id]">
View Full profile
<i class="fas fa-arrow-right ml-2"></i>
</button>
</div>
</div>
}
</div>
} @else if (users?.length===0){
<!-- Empty State -->
<div class="w-full flex items-center flex-wrap justify-center gap-10 py-12">
<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">There're no professionals here
</h2>
<p class="text-center text-black text-base font-normal leading-relaxed pb-4">Try changing your filters to
<br />see professionals
</p>
<div class="flex gap-3">
<button (click)="clearAllFilters()"
class="w-full px-3 py-2 rounded-full border border-neutral-300 text-neutral-900 text-xs font-semibold leading-4">Clear
Filter</button>
</div>
</div>
</div>
</div>
}
<!-- Pagination -->
@if(pageCount>1){
<div class="mt-8">
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator>
</div>
}
</div>
</div>
<div class="flex flex-col md:flex-row">
<!-- Filter Panel for Desktop -->
<div class="hidden md:block w-full md:w-1/4 h-full bg-white shadow-lg p-6 overflow-y-auto z-10">
<app-search-modal-broker [isModal]="false"></app-search-modal-broker>
</div>
<!-- Main Content -->
<div class="w-full p-4">
<div class="container mx-auto">
<!-- Breadcrumbs -->
<div class="mb-4">
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
</div>
<!-- SEO-optimized heading -->
<div class="mb-6">
<h1 class="text-3xl md:text-4xl font-bold text-neutral-900 mb-2">Professional Business Brokers & Advisors</h1>
<p class="text-lg text-neutral-600">Connect with licensed business brokers, CPAs, attorneys, and other
professionals across the United States.</p>
</div>
<!-- Mobile Filter Button -->
<div class="md:hidden mb-4">
<button (click)="openFilterModal()"
class="w-full bg-primary-600 text-white py-3 px-4 rounded-lg flex items-center justify-center">
<i class="fas fa-filter mr-2"></i>
Filter Results
</button>
</div>
@if(users?.length>0){
<h2 class="text-2xl font-semibold text-neutral-800 mb-4">Professional Listings</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Professional Cards -->
@for (user of users; track user) {
<div
class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6 flex flex-col justify-between hover:shadow-2xl transition-all duration-300 hover:scale-[1.02] group relative">
<!-- Quick Actions Overlay -->
<div
class="absolute top-4 right-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-20">
@if(currentUser) {
<button type="button" class="bg-white rounded-full p-2 shadow-lg transition-colors"
[class.bg-red-50]="isFavorite(user)"
[title]="isFavorite(user) ? 'Remove from favorites' : 'Save to favorites'"
(click)="toggleFavorite($event, user)">
<i
[class]="isFavorite(user) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i>
</button>
}
<button type="button" class="bg-white rounded-full p-2 shadow-lg hover:bg-blue-50 transition-colors"
title="Share professional" (click)="shareProfessional($event, user)">
<i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i>
</button>
</div>
<div class="flex items-start space-x-4">
@if(user.hasProfile){
<img src="{{ env.imageBaseUrl }}/pictures/profile/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}"
[alt]="altText.generateBrokerProfileAlt(user)" class="rounded-md w-20 h-26 object-cover" width="80"
height="104" />
} @else {
<img src="/assets/images/person_placeholder.jpg" alt="Default business broker placeholder profile photo"
class="rounded-md w-20 h-26 object-cover" width="80" height="104" />
}
<div class="flex-1">
<p class="text-sm text-neutral-800 mb-2">{{ user.description }}</p>
<h3 class="text-lg font-semibold">
{{ user.firstname }} {{ user.lastname }}<span
class="bg-neutral-200 text-neutral-700 text-xs font-semibold px-2 py-1 rounded ml-4">{{
user.location?.name }} - {{ user.location?.state }}</span>
</h3>
<div class="flex items-center space-x-2 mt-2">
<app-customer-sub-type [customerSubType]="user.customerSubType"></app-customer-sub-type>
<p class="text-sm text-neutral-600">{{ user.companyName }}</p>
</div>
<div class="flex items-center justify-between my-2"></div>
</div>
</div>
<div class="mt-4 flex justify-between items-center">
@if(user.hasCompanyLogo){
<img src="{{ env.imageBaseUrl }}/pictures/logo/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}"
[alt]="altText.generateCompanyLogoAlt(user.companyName, user.firstname + ' ' + user.lastname)"
class="w-8 h-10 object-contain" width="32" height="40" />
} @else {
<img src="/assets/images/placeholder.png" alt="Default company logo placeholder"
class="w-8 h-10 object-contain" width="32" height="40" />
}
<button
class="bg-success-500 hover:bg-success-600 text-white font-medium py-2 px-4 rounded-full flex items-center"
[routerLink]="['/details-user', user.id]">
View Full profile
<i class="fas fa-arrow-right ml-2"></i>
</button>
</div>
</div>
}
</div>
} @else if (users?.length===0){
<!-- Empty State -->
<div class="w-full flex items-center flex-wrap justify-center gap-10 py-12">
<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">There're no professionals here
</h2>
<p class="text-center text-black text-base font-normal leading-relaxed pb-4">Try changing your filters to
<br />see professionals
</p>
<div class="flex gap-3">
<button (click)="clearAllFilters()"
class="w-full px-3 py-2 rounded-full border border-neutral-300 text-neutral-900 text-xs font-semibold leading-4">Clear
Filter</button>
</div>
</div>
</div>
</div>
}
<!-- Pagination -->
@if(pageCount>1){
<div class="mt-8">
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator>
</div>
}
</div>
</div>
</div>

View File

@@ -1,243 +1,243 @@
import { CommonModule, NgOptimizedImage } from '@angular/common';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { UntilDestroy } from '@ngneat/until-destroy';
import { Subject, takeUntil } from 'rxjs';
import { BusinessListing, SortByOptions, User } from '../../../../../../bizmatch-server/src/models/db.model';
import { LISTINGS_PER_PAGE, ListingType, UserListingCriteria, emailToDirName, KeycloakUser } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment';
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
import { CustomerSubTypeComponent } from '../../../components/customer-sub-type/customer-sub-type.component';
import { PaginatorComponent } from '../../../components/paginator/paginator.component';
import { SearchModalBrokerComponent } from '../../../components/search-modal/search-modal-broker.component';
import { ModalService } from '../../../components/search-modal/modal.service';
import { AltTextService } from '../../../services/alt-text.service';
import { CriteriaChangeService } from '../../../services/criteria-change.service';
import { FilterStateService } from '../../../services/filter-state.service';
import { ImageService } from '../../../services/image.service';
import { ListingsService } from '../../../services/listings.service';
import { SearchService } from '../../../services/search.service';
import { SelectOptionsService } from '../../../services/select-options.service';
import { UserService } from '../../../services/user.service';
import { AuthService } from '../../../services/auth.service';
import { assignProperties, resetUserListingCriteria, map2User } from '../../../utils/utils';
@UntilDestroy()
@Component({
selector: 'app-broker-listings',
standalone: true,
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, CustomerSubTypeComponent, BreadcrumbsComponent, SearchModalBrokerComponent],
templateUrl: './broker-listings.component.html',
styleUrls: ['./broker-listings.component.scss', '../../pages.scss'],
})
export class BrokerListingsComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
breadcrumbs: BreadcrumbItem[] = [
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
{ label: 'Professionals', url: '/brokerListings' }
];
environment = environment;
listings: Array<BusinessListing>;
users: Array<User>;
filteredListings: Array<ListingType>;
criteria: UserListingCriteria;
realEstateChecked: boolean;
maxPrice: string;
minPrice: string;
type: string;
statesSet = new Set();
state: string;
first: number = 0;
rows: number = 12;
totalRecords: number = 0;
ts = new Date().getTime();
env = environment;
public category: 'business' | 'commercialProperty' | 'professionals_brokers' | undefined;
emailToDirName = emailToDirName;
page = 1;
pageCount = 1;
sortBy: SortByOptions = null; // Neu: Separate Property
currentUser: KeycloakUser | null = null; // Current logged-in user
constructor(
public altText: AltTextService,
public selectOptions: SelectOptionsService,
private listingsService: ListingsService,
private userService: UserService,
private activatedRoute: ActivatedRoute,
private router: Router,
private cdRef: ChangeDetectorRef,
private imageService: ImageService,
private route: ActivatedRoute,
private searchService: SearchService,
private modalService: ModalService,
private criteriaChangeService: CriteriaChangeService,
private filterStateService: FilterStateService,
private authService: AuthService,
) {
this.loadSortBy();
}
private loadSortBy() {
const storedSortBy = sessionStorage.getItem('professionalsSortBy');
this.sortBy = storedSortBy && storedSortBy !== 'null' ? (storedSortBy as SortByOptions) : null;
}
async ngOnInit(): Promise<void> {
// Get current logged-in user
const token = await this.authService.getToken();
this.currentUser = map2User(token);
// Subscribe to FilterStateService for criteria changes
this.filterStateService
.getState$('brokerListings')
.pipe(takeUntil(this.destroy$))
.subscribe(state => {
this.criteria = state.criteria as UserListingCriteria;
this.sortBy = state.sortBy;
this.search();
});
// Subscribe to SearchService for search triggers
this.searchService.searchTrigger$
.pipe(takeUntil(this.destroy$))
.subscribe(type => {
if (type === 'brokerListings') {
this.search();
}
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
async search() {
const usersReponse = await this.userService.search(this.criteria);
this.users = usersReponse.results;
this.totalRecords = usersReponse.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();
}
reset() { }
// New methods for filter actions
clearAllFilters() {
// Reset criteria to default values
resetUserListingCriteria(this.criteria);
// Reset pagination
this.criteria.page = 1;
this.criteria.start = 0;
this.criteriaChangeService.notifyCriteriaChange();
// Search with cleared filters
this.searchService.search('brokerListings');
}
async openFilterModal() {
// Open the search modal with current criteria
const modalResult = await this.modalService.showModal(this.criteria);
if (modalResult.accepted) {
this.searchService.search('brokerListings');
} else {
this.criteria = assignProperties(this.criteria, modalResult.criteria);
}
}
/**
* Check if professional/user is already in current user's favorites
*/
isFavorite(professional: User): boolean {
if (!this.currentUser?.email || !professional.favoritesForUser) return false;
return professional.favoritesForUser.includes(this.currentUser.email);
}
/**
* Toggle favorite status for a professional
*/
async toggleFavorite(event: Event, professional: User): Promise<void> {
event.stopPropagation();
event.preventDefault();
if (!this.currentUser?.email) {
// User not logged in - redirect to login
this.router.navigate(['/login']);
return;
}
try {
console.log('Toggling favorite for:', professional.email, 'Current user:', this.currentUser.email);
console.log('Before update, favorites:', professional.favoritesForUser);
if (this.isFavorite(professional)) {
// Remove from favorites
await this.listingsService.removeFavorite(professional.id, 'user');
professional.favoritesForUser = professional.favoritesForUser.filter(
email => email !== this.currentUser!.email
);
} else {
// Add to favorites
await this.listingsService.addToFavorites(professional.id, 'user');
if (!professional.favoritesForUser) {
professional.favoritesForUser = [];
}
// Use spread to create new array reference
professional.favoritesForUser = [...professional.favoritesForUser, this.currentUser.email];
}
console.log('After update, favorites:', professional.favoritesForUser);
this.cdRef.markForCheck();
this.cdRef.detectChanges();
} catch (error) {
console.error('Error toggling favorite:', error);
}
}
/**
* Share professional profile
*/
async shareProfessional(event: Event, user: User): Promise<void> {
event.stopPropagation();
event.preventDefault();
const url = `${window.location.origin}/details-user/${user.id}`;
const title = `${user.firstname} ${user.lastname} - ${user.companyName}`;
// Try native share API first (works on mobile and some desktop browsers)
if (navigator.share) {
try {
await navigator.share({
title: title,
text: `Check out this professional: ${title}`,
url: url,
});
} catch (err) {
// User cancelled or share failed - fall back to clipboard
this.copyToClipboard(url);
}
} else {
// Fallback: open Facebook share dialog
const encodedUrl = encodeURIComponent(url);
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, '_blank', 'width=600,height=400');
}
}
/**
* Copy URL to clipboard and show feedback
*/
private copyToClipboard(url: string): void {
navigator.clipboard.writeText(url).then(() => {
console.log('Link copied to clipboard!');
}).catch(err => {
console.error('Failed to copy link:', err);
});
}
}
import { CommonModule, NgOptimizedImage } from '@angular/common';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { UntilDestroy } from '@ngneat/until-destroy';
import { Subject, takeUntil } from 'rxjs';
import { BusinessListing, SortByOptions, User } from '../../../../../../bizmatch-server/src/models/db.model';
import { LISTINGS_PER_PAGE, ListingType, UserListingCriteria, emailToDirName, KeycloakUser } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment';
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
import { CustomerSubTypeComponent } from '../../../components/customer-sub-type/customer-sub-type.component';
import { PaginatorComponent } from '../../../components/paginator/paginator.component';
import { SearchModalBrokerComponent } from '../../../components/search-modal/search-modal-broker.component';
import { ModalService } from '../../../components/search-modal/modal.service';
import { AltTextService } from '../../../services/alt-text.service';
import { CriteriaChangeService } from '../../../services/criteria-change.service';
import { FilterStateService } from '../../../services/filter-state.service';
import { ImageService } from '../../../services/image.service';
import { ListingsService } from '../../../services/listings.service';
import { SearchService } from '../../../services/search.service';
import { SelectOptionsService } from '../../../services/select-options.service';
import { UserService } from '../../../services/user.service';
import { AuthService } from '../../../services/auth.service';
import { assignProperties, resetUserListingCriteria, map2User } from '../../../utils/utils';
@UntilDestroy()
@Component({
selector: 'app-broker-listings',
standalone: true,
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, CustomerSubTypeComponent, BreadcrumbsComponent, SearchModalBrokerComponent],
templateUrl: './broker-listings.component.html',
styleUrls: ['./broker-listings.component.scss', '../../pages.scss'],
})
export class BrokerListingsComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
breadcrumbs: BreadcrumbItem[] = [
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
{ label: 'Professionals', url: '/brokerListings' }
];
environment = environment;
listings: Array<BusinessListing>;
users: Array<User>;
filteredListings: Array<ListingType>;
criteria: UserListingCriteria;
realEstateChecked: boolean;
maxPrice: string;
minPrice: string;
type: string;
statesSet = new Set();
state: string;
first: number = 0;
rows: number = 12;
totalRecords: number = 0;
ts = new Date().getTime();
env = environment;
public category: 'business' | 'commercialProperty' | 'professionals_brokers' | undefined;
emailToDirName = emailToDirName;
page = 1;
pageCount = 1;
sortBy: SortByOptions = null; // Neu: Separate Property
currentUser: KeycloakUser | null = null; // Current logged-in user
constructor(
public altText: AltTextService,
public selectOptions: SelectOptionsService,
private listingsService: ListingsService,
private userService: UserService,
private activatedRoute: ActivatedRoute,
private router: Router,
private cdRef: ChangeDetectorRef,
private imageService: ImageService,
private route: ActivatedRoute,
private searchService: SearchService,
private modalService: ModalService,
private criteriaChangeService: CriteriaChangeService,
private filterStateService: FilterStateService,
private authService: AuthService,
) {
this.loadSortBy();
}
private loadSortBy() {
const storedSortBy = sessionStorage.getItem('professionalsSortBy');
this.sortBy = storedSortBy && storedSortBy !== 'null' ? (storedSortBy as SortByOptions) : null;
}
async ngOnInit(): Promise<void> {
// Get current logged-in user
const token = await this.authService.getToken();
this.currentUser = map2User(token);
// Subscribe to FilterStateService for criteria changes
this.filterStateService
.getState$('brokerListings')
.pipe(takeUntil(this.destroy$))
.subscribe(state => {
this.criteria = state.criteria as UserListingCriteria;
this.sortBy = state.sortBy;
this.search();
});
// Subscribe to SearchService for search triggers
this.searchService.searchTrigger$
.pipe(takeUntil(this.destroy$))
.subscribe(type => {
if (type === 'brokerListings') {
this.search();
}
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
async search() {
const usersReponse = await this.userService.search(this.criteria);
this.users = usersReponse.results;
this.totalRecords = usersReponse.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();
}
reset() { }
// New methods for filter actions
clearAllFilters() {
// Reset criteria to default values
resetUserListingCriteria(this.criteria);
// Reset pagination
this.criteria.page = 1;
this.criteria.start = 0;
this.criteriaChangeService.notifyCriteriaChange();
// Search with cleared filters
this.searchService.search('brokerListings');
}
async openFilterModal() {
// Open the search modal with current criteria
const modalResult = await this.modalService.showModal(this.criteria);
if (modalResult.accepted) {
this.searchService.search('brokerListings');
} else {
this.criteria = assignProperties(this.criteria, modalResult.criteria);
}
}
/**
* Check if professional/user is already in current user's favorites
*/
isFavorite(professional: User): boolean {
if (!this.currentUser?.email || !professional.favoritesForUser) return false;
return professional.favoritesForUser.includes(this.currentUser.email);
}
/**
* Toggle favorite status for a professional
*/
async toggleFavorite(event: Event, professional: User): Promise<void> {
event.stopPropagation();
event.preventDefault();
if (!this.currentUser?.email) {
// User not logged in - redirect to login
this.router.navigate(['/login']);
return;
}
try {
console.log('Toggling favorite for:', professional.email, 'Current user:', this.currentUser.email);
console.log('Before update, favorites:', professional.favoritesForUser);
if (this.isFavorite(professional)) {
// Remove from favorites
await this.listingsService.removeFavorite(professional.id, 'user');
professional.favoritesForUser = professional.favoritesForUser.filter(
email => email !== this.currentUser!.email
);
} else {
// Add to favorites
await this.listingsService.addToFavorites(professional.id, 'user');
if (!professional.favoritesForUser) {
professional.favoritesForUser = [];
}
// Use spread to create new array reference
professional.favoritesForUser = [...professional.favoritesForUser, this.currentUser.email];
}
console.log('After update, favorites:', professional.favoritesForUser);
this.cdRef.markForCheck();
this.cdRef.detectChanges();
} catch (error) {
console.error('Error toggling favorite:', error);
}
}
/**
* Share professional profile
*/
async shareProfessional(event: Event, user: User): Promise<void> {
event.stopPropagation();
event.preventDefault();
const url = `${window.location.origin}/details-user/${user.id}`;
const title = `${user.firstname} ${user.lastname} - ${user.companyName}`;
// Try native share API first (works on mobile and some desktop browsers)
if (navigator.share) {
try {
await navigator.share({
title: title,
text: `Check out this professional: ${title}`,
url: url,
});
} catch (err) {
// User cancelled or share failed - fall back to clipboard
this.copyToClipboard(url);
}
} else {
// Fallback: open Facebook share dialog
const encodedUrl = encodeURIComponent(url);
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, '_blank', 'width=600,height=400');
}
}
/**
* Copy URL to clipboard and show feedback
*/
private copyToClipboard(url: string): void {
navigator.clipboard.writeText(url).then(() => {
console.log('Link copied to clipboard!');
}).catch(err => {
console.error('Failed to copy link:', err);
});
}
}

View File

@@ -1,259 +1,259 @@
<div class="flex flex-col md:flex-row">
<!-- Filter Panel for Desktop -->
<div class="hidden md:block w-full md:w-1/4 h-full bg-white shadow-lg p-6 overflow-y-auto z-10">
<app-search-modal [isModal]="false"></app-search-modal>
</div>
<!-- Main Content -->
<div class="w-full p-4">
<div class="container mx-auto">
<!-- Breadcrumbs -->
<div class="mb-4">
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
</div>
<!-- SEO-optimized heading -->
<div class="mb-6">
<h1 class="text-3xl md:text-4xl font-bold text-neutral-900 mb-2">Businesses for Sale</h1>
<p class="text-lg text-neutral-600">Discover profitable business opportunities across the United States. Browse
verified listings from business owners and brokers.</p>
</div>
<!-- Loading Skeleton -->
@if(isLoading) {
<h2 class="text-2xl font-semibold text-neutral-800 mb-4">Loading Business Listings...</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
@for (item of [1,2,3,4,5,6]; track item) {
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg overflow-hidden">
<div class="p-6 animate-pulse">
<!-- Category icon and text -->
<div class="flex items-center mb-4">
<div class="w-5 h-5 bg-neutral-200 rounded mr-2"></div>
<div class="h-5 bg-neutral-200 rounded w-32"></div>
</div>
<!-- Title -->
<div class="h-7 bg-neutral-200 rounded w-3/4 mb-4"></div>
<!-- Badges -->
<div class="flex justify-between mb-4">
<div class="h-6 bg-neutral-200 rounded-full w-20"></div>
<div class="h-6 bg-neutral-200 rounded-full w-16"></div>
</div>
<!-- Details -->
<div class="space-y-2 mb-4">
<div class="h-4 bg-neutral-200 rounded w-full"></div>
<div class="h-4 bg-neutral-200 rounded w-5/6"></div>
<div class="h-4 bg-neutral-200 rounded w-4/6"></div>
<div class="h-4 bg-neutral-200 rounded w-3/4"></div>
<div class="h-4 bg-neutral-200 rounded w-2/3"></div>
</div>
<!-- Button -->
<div class="h-12 bg-neutral-200 rounded-full w-full mt-4"></div>
</div>
</div>
}
</div>
} @else if(listings?.length > 0) {
<h2 class="text-2xl font-semibold text-neutral-800 mb-4">Available Business Listings</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
@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-2xl transition-all duration-300 hover:scale-[1.02] group">
<div class="p-6 flex flex-col h-full relative z-[0]">
<!-- Quick Actions Overlay -->
<div
class="absolute top-4 right-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-20">
@if(user) {
<button class="bg-white rounded-full p-2 shadow-lg transition-colors"
[class.bg-red-50]="isFavorite(listing)"
[title]="isFavorite(listing) ? 'Remove from favorites' : 'Save to favorites'"
(click)="toggleFavorite($event, listing)">
<i
[class]="isFavorite(listing) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i>
</button>
}
<button type="button" class="bg-white rounded-full p-2 shadow-lg hover:bg-blue-50 transition-colors"
title="Share listing" (click)="shareListing($event, listing)">
<i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i>
</button>
</div>
<div class="flex items-center mb-4">
<i [class]="selectOptions.getIconAndTextColorType(listing.type)" class="mr-2 text-xl"></i>
<span [class]="selectOptions.getTextColorType(listing.type)" class="font-bold text-lg">{{
selectOptions.getBusiness(listing.type) }}</span>
</div>
<h2 class="text-xl font-semibold mb-4">
{{ listing.title }}
@if(listing.draft) {
<span
class="bg-amber-100 text-amber-800 border border-amber-300 text-sm font-medium me-2 ml-2 px-2.5 py-0.5 rounded">Draft</span>
}
</h2>
<div class="flex justify-between">
<span
class="w-fit inline-flex items-center justify-center px-2 py-1 mb-4 text-xs font-bold leading-none bg-neutral-200 text-neutral-700 rounded-full">
{{ selectOptions.getState(listing.location.state) }}
</span>
@if (getListingBadge(listing); as badge) {
<span
class="mb-4 h-fit inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none rounded-full border"
[ngClass]="{
'bg-emerald-100 text-emerald-800 border-emerald-300': badge === 'NEW',
'bg-teal-100 text-teal-800 border-teal-300': badge === 'UPDATED'
}">
{{ badge }}
</span>
}
</div>
<p class="text-base font-bold text-neutral-800 mb-2">
<strong>Asking price:</strong>
<span class="text-success-600">
{{ listing?.price != null ? (listing.price | currency : 'USD' : 'symbol' : '1.0-0') : 'undisclosed' }}
</span>
</p>
<p class="text-sm text-neutral-600 mb-2">
<strong>Sales revenue:</strong>
{{ listing?.salesRevenue != null ? (listing.salesRevenue | currency : 'USD' : 'symbol' : '1.0-0') :
'undisclosed' }}
</p>
<p class="text-sm text-neutral-600 mb-2">
<strong>Net profit:</strong>
{{ listing?.cashFlow != null ? (listing.cashFlow | currency : 'USD' : 'symbol' : '1.0-0') : 'undisclosed'
}}
</p>
<p class="text-sm text-neutral-600 mb-2">
<strong>Location:</strong> {{ listing.location.name ? listing.location.name : listing.location.county ?
listing.location.county : this.selectOptions.getState(listing.location.state) }}
</p>
<p class="text-sm text-neutral-600 mb-4"><strong>Years established:</strong> {{ listing.established }}</p>
@if(listing.imageName) {
<img [appLazyLoad]="env.imageBaseUrl + '/pictures/logo/' + listing.imageName + '.avif?_ts=' + ts"
[alt]="altText.generateListingCardLogoAlt(listing)"
class="absolute bottom-[80px] right-[20px] h-[45px] w-auto" width="100" height="45" />
}
<div class="flex-grow"></div>
<button
class="bg-success-600 text-white px-5 py-3 rounded-full w-full flex items-center justify-center mt-4 transition-all duration-200 hover:bg-success-700 hover:shadow-lg group/btn"
[routerLink]="['/business', listing.slug || listing.id]">
<span class="font-semibold">View Opportunity</span>
<i class="fas fa-arrow-right ml-2 group-hover/btn:translate-x-1 transition-transform duration-200"></i>
</button>
</div>
</div>
}
</div>
} @else if (listings?.length === 0) {
<div class="w-full flex items-center flex-wrap justify-center gap-10 py-12">
<div class="grid gap-6 max-w-2xl w-full">
<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 86.0305 82.0027 83.3821 77.9987 83.3821C77.9987 83.3821 77.9987 86.0305 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 class="text-center">
<h2 class="text-black text-2xl font-semibold leading-loose pb-2">No listings found</h2>
<p class="text-neutral-600 text-base font-normal leading-relaxed pb-6">We couldn't find any businesses
matching your criteria.<br />Try adjusting your filters or explore popular categories below.</p>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-3 justify-center mb-8">
<button (click)="clearAllFilters()"
class="px-6 py-3 rounded-full bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700 transition-colors">
<i class="fas fa-redo mr-2"></i>Clear All Filters
</button>
<button [routerLink]="['/home']"
class="px-6 py-3 rounded-full border-2 border-neutral-300 text-neutral-700 text-sm font-semibold hover:border-primary-600 hover:text-primary-600 transition-colors">
<i class="fas fa-home mr-2"></i>Back to Home
</button>
</div>
<!-- Popular Categories Suggestions -->
<div class="mt-8 p-6 bg-neutral-50 rounded-lg">
<h3 class="text-lg font-semibold text-neutral-800 mb-4">
<i class="fas fa-fire text-orange-500 mr-2"></i>Popular Categories
</h3>
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
<button (click)="filterByCategory('foodAndRestaurant')"
class="px-4 py-2 bg-white rounded-lg border border-neutral-200 hover:border-primary-500 hover:bg-primary-50 text-sm text-neutral-700 hover:text-primary-600 transition-all">
<i class="fas fa-utensils mr-2"></i>Restaurants
</button>
<button (click)="filterByCategory('retail')"
class="px-4 py-2 bg-white rounded-lg border border-neutral-200 hover:border-primary-500 hover:bg-primary-50 text-sm text-neutral-700 hover:text-primary-600 transition-all">
<i class="fas fa-store mr-2"></i>Retail
</button>
<button (click)="filterByCategory('realEstate')"
class="px-4 py-2 bg-white rounded-lg border border-neutral-200 hover:border-primary-500 hover:bg-primary-50 text-sm text-neutral-700 hover:text-primary-600 transition-all">
<i class="fas fa-building mr-2"></i>Real Estate
</button>
<button (click)="filterByCategory('service')"
class="px-4 py-2 bg-white rounded-lg border border-neutral-200 hover:border-primary-500 hover:bg-primary-50 text-sm text-neutral-700 hover:text-primary-600 transition-all">
<i class="fas fa-cut mr-2"></i>Services
</button>
<button (click)="filterByCategory('franchise')"
class="px-4 py-2 bg-white rounded-lg border border-neutral-200 hover:border-primary-500 hover:bg-primary-50 text-sm text-neutral-700 hover:text-primary-600 transition-all">
<i class="fas fa-handshake mr-2"></i>Franchise
</button>
<button (click)="filterByCategory('professional')"
class="px-4 py-2 bg-white rounded-lg border border-neutral-200 hover:border-primary-500 hover:bg-primary-50 text-sm text-neutral-700 hover:text-primary-600 transition-all">
<i class="fas fa-briefcase mr-2"></i>Professional
</button>
</div>
</div>
<!-- Helpful Tips -->
<div class="mt-6 p-4 bg-primary-50 border border-primary-100 rounded-lg text-left">
<h4 class="font-semibold text-primary-900 mb-2 flex items-center">
<i class="fas fa-lightbulb mr-2"></i>Search Tips
</h4>
<ul class="text-sm text-primary-800 space-y-1">
<li>• Try expanding your search radius</li>
<li>• Consider adjusting your price range</li>
<li>• Browse all categories to discover opportunities</li>
</ul>
</div>
</div>
</div>
</div>
}
</div>
@if(pageCount > 1) {
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator>
}
</div>
<!-- Filter Button for Mobile -->
<button (click)="openFilterModal()"
class="md:hidden fixed bottom-4 right-4 bg-primary-500 text-white p-3 rounded-full shadow-lg z-20"><i
class="fas fa-filter"></i> Filter</button>
<div class="flex flex-col md:flex-row">
<!-- Filter Panel for Desktop -->
<div class="hidden md:block w-full md:w-1/4 h-full bg-white shadow-lg p-6 overflow-y-auto z-10">
<app-search-modal [isModal]="false"></app-search-modal>
</div>
<!-- Main Content -->
<div class="w-full p-4">
<div class="container mx-auto">
<!-- Breadcrumbs -->
<div class="mb-4">
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
</div>
<!-- SEO-optimized heading -->
<div class="mb-6">
<h1 class="text-3xl md:text-4xl font-bold text-neutral-900 mb-2">Businesses for Sale</h1>
<p class="text-lg text-neutral-600">Discover profitable business opportunities across the United States. Browse
verified listings from business owners and brokers.</p>
</div>
<!-- Loading Skeleton -->
@if(isLoading) {
<h2 class="text-2xl font-semibold text-neutral-800 mb-4">Loading Business Listings...</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
@for (item of [1,2,3,4,5,6]; track item) {
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg overflow-hidden">
<div class="p-6 animate-pulse">
<!-- Category icon and text -->
<div class="flex items-center mb-4">
<div class="w-5 h-5 bg-neutral-200 rounded mr-2"></div>
<div class="h-5 bg-neutral-200 rounded w-32"></div>
</div>
<!-- Title -->
<div class="h-7 bg-neutral-200 rounded w-3/4 mb-4"></div>
<!-- Badges -->
<div class="flex justify-between mb-4">
<div class="h-6 bg-neutral-200 rounded-full w-20"></div>
<div class="h-6 bg-neutral-200 rounded-full w-16"></div>
</div>
<!-- Details -->
<div class="space-y-2 mb-4">
<div class="h-4 bg-neutral-200 rounded w-full"></div>
<div class="h-4 bg-neutral-200 rounded w-5/6"></div>
<div class="h-4 bg-neutral-200 rounded w-4/6"></div>
<div class="h-4 bg-neutral-200 rounded w-3/4"></div>
<div class="h-4 bg-neutral-200 rounded w-2/3"></div>
</div>
<!-- Button -->
<div class="h-12 bg-neutral-200 rounded-full w-full mt-4"></div>
</div>
</div>
}
</div>
} @else if(listings?.length > 0) {
<h2 class="text-2xl font-semibold text-neutral-800 mb-4">Available Business Listings</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
@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-2xl transition-all duration-300 hover:scale-[1.02] group">
<div class="p-6 flex flex-col h-full relative z-[0]">
<!-- Quick Actions Overlay -->
<div
class="absolute top-4 right-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-20">
@if(user) {
<button class="bg-white rounded-full p-2 shadow-lg transition-colors"
[class.bg-red-50]="isFavorite(listing)"
[title]="isFavorite(listing) ? 'Remove from favorites' : 'Save to favorites'"
(click)="toggleFavorite($event, listing)">
<i
[class]="isFavorite(listing) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i>
</button>
}
<button type="button" class="bg-white rounded-full p-2 shadow-lg hover:bg-blue-50 transition-colors"
title="Share listing" (click)="shareListing($event, listing)">
<i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i>
</button>
</div>
<div class="flex items-center mb-4">
<i [class]="selectOptions.getIconAndTextColorType(listing.type)" class="mr-2 text-xl"></i>
<span [class]="selectOptions.getTextColorType(listing.type)" class="font-bold text-lg">{{
selectOptions.getBusiness(listing.type) }}</span>
</div>
<h2 class="text-xl font-semibold mb-4">
{{ listing.title }}
@if(listing.draft) {
<span
class="bg-amber-100 text-amber-800 border border-amber-300 text-sm font-medium me-2 ml-2 px-2.5 py-0.5 rounded">Draft</span>
}
</h2>
<div class="flex justify-between">
<span
class="w-fit inline-flex items-center justify-center px-2 py-1 mb-4 text-xs font-bold leading-none bg-neutral-200 text-neutral-700 rounded-full">
{{ selectOptions.getState(listing.location.state) }}
</span>
@if (getListingBadge(listing); as badge) {
<span
class="mb-4 h-fit inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none rounded-full border"
[ngClass]="{
'bg-emerald-100 text-emerald-800 border-emerald-300': badge === 'NEW',
'bg-teal-100 text-teal-800 border-teal-300': badge === 'UPDATED'
}">
{{ badge }}
</span>
}
</div>
<p class="text-base font-bold text-neutral-800 mb-2">
<strong>Asking price:</strong>
<span class="text-success-600">
{{ listing?.price != null ? (listing.price | currency : 'USD' : 'symbol' : '1.0-0') : 'undisclosed' }}
</span>
</p>
<p class="text-sm text-neutral-600 mb-2">
<strong>Sales revenue:</strong>
{{ listing?.salesRevenue != null ? (listing.salesRevenue | currency : 'USD' : 'symbol' : '1.0-0') :
'undisclosed' }}
</p>
<p class="text-sm text-neutral-600 mb-2">
<strong>Net profit:</strong>
{{ listing?.cashFlow != null ? (listing.cashFlow | currency : 'USD' : 'symbol' : '1.0-0') : 'undisclosed'
}}
</p>
<p class="text-sm text-neutral-600 mb-2">
<strong>Location:</strong> {{ listing.location.name ? listing.location.name : listing.location.county ?
listing.location.county : this.selectOptions.getState(listing.location.state) }}
</p>
<p class="text-sm text-neutral-600 mb-4"><strong>Years established:</strong> {{ listing.established }}</p>
@if(listing.imageName) {
<img [appLazyLoad]="env.imageBaseUrl + '/pictures/logo/' + listing.imageName + '.avif?_ts=' + ts"
[alt]="altText.generateListingCardLogoAlt(listing)"
class="absolute bottom-[80px] right-[20px] h-[45px] w-auto" width="100" height="45" />
}
<div class="flex-grow"></div>
<button
class="bg-success-600 text-white px-5 py-3 rounded-full w-full flex items-center justify-center mt-4 transition-all duration-200 hover:bg-success-700 hover:shadow-lg group/btn"
[routerLink]="['/business', listing.slug || listing.id]">
<span class="font-semibold">View Opportunity</span>
<i class="fas fa-arrow-right ml-2 group-hover/btn:translate-x-1 transition-transform duration-200"></i>
</button>
</div>
</div>
}
</div>
} @else if (listings?.length === 0) {
<div class="w-full flex items-center flex-wrap justify-center gap-10 py-12">
<div class="grid gap-6 max-w-2xl w-full">
<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 86.0305 82.0027 83.3821 77.9987 83.3821C77.9987 83.3821 77.9987 86.0305 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 class="text-center">
<h2 class="text-black text-2xl font-semibold leading-loose pb-2">No listings found</h2>
<p class="text-neutral-600 text-base font-normal leading-relaxed pb-6">We couldn't find any businesses
matching your criteria.<br />Try adjusting your filters or explore popular categories below.</p>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-3 justify-center mb-8">
<button (click)="clearAllFilters()"
class="px-6 py-3 rounded-full bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700 transition-colors">
<i class="fas fa-redo mr-2"></i>Clear All Filters
</button>
<button [routerLink]="['/home']"
class="px-6 py-3 rounded-full border-2 border-neutral-300 text-neutral-700 text-sm font-semibold hover:border-primary-600 hover:text-primary-600 transition-colors">
<i class="fas fa-home mr-2"></i>Back to Home
</button>
</div>
<!-- Popular Categories Suggestions -->
<div class="mt-8 p-6 bg-neutral-50 rounded-lg">
<h3 class="text-lg font-semibold text-neutral-800 mb-4">
<i class="fas fa-fire text-orange-500 mr-2"></i>Popular Categories
</h3>
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
<button (click)="filterByCategory('foodAndRestaurant')"
class="px-4 py-2 bg-white rounded-lg border border-neutral-200 hover:border-primary-500 hover:bg-primary-50 text-sm text-neutral-700 hover:text-primary-600 transition-all">
<i class="fas fa-utensils mr-2"></i>Restaurants
</button>
<button (click)="filterByCategory('retail')"
class="px-4 py-2 bg-white rounded-lg border border-neutral-200 hover:border-primary-500 hover:bg-primary-50 text-sm text-neutral-700 hover:text-primary-600 transition-all">
<i class="fas fa-store mr-2"></i>Retail
</button>
<button (click)="filterByCategory('realEstate')"
class="px-4 py-2 bg-white rounded-lg border border-neutral-200 hover:border-primary-500 hover:bg-primary-50 text-sm text-neutral-700 hover:text-primary-600 transition-all">
<i class="fas fa-building mr-2"></i>Real Estate
</button>
<button (click)="filterByCategory('service')"
class="px-4 py-2 bg-white rounded-lg border border-neutral-200 hover:border-primary-500 hover:bg-primary-50 text-sm text-neutral-700 hover:text-primary-600 transition-all">
<i class="fas fa-cut mr-2"></i>Services
</button>
<button (click)="filterByCategory('franchise')"
class="px-4 py-2 bg-white rounded-lg border border-neutral-200 hover:border-primary-500 hover:bg-primary-50 text-sm text-neutral-700 hover:text-primary-600 transition-all">
<i class="fas fa-handshake mr-2"></i>Franchise
</button>
<button (click)="filterByCategory('professional')"
class="px-4 py-2 bg-white rounded-lg border border-neutral-200 hover:border-primary-500 hover:bg-primary-50 text-sm text-neutral-700 hover:text-primary-600 transition-all">
<i class="fas fa-briefcase mr-2"></i>Professional
</button>
</div>
</div>
<!-- Helpful Tips -->
<div class="mt-6 p-4 bg-primary-50 border border-primary-100 rounded-lg text-left">
<h4 class="font-semibold text-primary-900 mb-2 flex items-center">
<i class="fas fa-lightbulb mr-2"></i>Search Tips
</h4>
<ul class="text-sm text-primary-800 space-y-1">
<li>• Try expanding your search radius</li>
<li>• Consider adjusting your price range</li>
<li>• Browse all categories to discover opportunities</li>
</ul>
</div>
</div>
</div>
</div>
}
</div>
@if(pageCount > 1) {
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator>
}
</div>
<!-- Filter Button for Mobile -->
<button (click)="openFilterModal()"
class="md:hidden fixed bottom-4 right-4 bg-primary-500 text-white p-3 rounded-full shadow-lg z-20"><i
class="fas fa-filter"></i> Filter</button>
</div>

View File

@@ -1,330 +1,330 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { UntilDestroy } from '@ngneat/until-destroy';
import { Subject, takeUntil } from 'rxjs';
import dayjs from 'dayjs';
import { BusinessListing, SortByOptions } from '../../../../../../bizmatch-server/src/models/db.model';
import { BusinessListingCriteria, KeycloakUser, LISTINGS_PER_PAGE, ListingType, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment';
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
import { PaginatorComponent } from '../../../components/paginator/paginator.component';
import { ModalService } from '../../../components/search-modal/modal.service';
import { SearchModalComponent } from '../../../components/search-modal/search-modal.component';
import { LazyLoadImageDirective } from '../../../directives/lazy-load-image.directive';
import { AltTextService } from '../../../services/alt-text.service';
import { AuthService } from '../../../services/auth.service';
import { FilterStateService } from '../../../services/filter-state.service';
import { ImageService } from '../../../services/image.service';
import { ListingsService } from '../../../services/listings.service';
import { SearchService } from '../../../services/search.service';
import { SelectOptionsService } from '../../../services/select-options.service';
import { SeoService } from '../../../services/seo.service';
import { map2User } from '../../../utils/utils';
@UntilDestroy()
@Component({
selector: 'app-business-listings',
standalone: true,
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, SearchModalComponent, LazyLoadImageDirective, BreadcrumbsComponent],
templateUrl: './business-listings.component.html',
styleUrls: ['./business-listings.component.scss', '../../pages.scss'],
})
export class BusinessListingsComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
// Component properties
environment = environment;
env = environment;
listings: Array<BusinessListing> = [];
filteredListings: Array<ListingType> = [];
criteria: BusinessListingCriteria;
sortBy: SortByOptions | null = null;
// Pagination
totalRecords = 0;
page = 1;
pageCount = 1;
first = 0;
rows = LISTINGS_PER_PAGE;
// UI state
ts = new Date().getTime();
emailToDirName = emailToDirName;
isLoading = false;
// Breadcrumbs
breadcrumbs: BreadcrumbItem[] = [
{ label: 'Home', url: '/', icon: 'fas fa-home' },
{ label: 'Business Listings' }
];
// User for favorites
user: KeycloakUser | null = null;
constructor(
public altText: AltTextService,
public selectOptions: SelectOptionsService,
private listingsService: ListingsService,
private router: Router,
private cdRef: ChangeDetectorRef,
private imageService: ImageService,
private searchService: SearchService,
private modalService: ModalService,
private filterStateService: FilterStateService,
private route: ActivatedRoute,
private seoService: SeoService,
private authService: AuthService,
) { }
async ngOnInit(): Promise<void> {
// Load user for favorites functionality
const token = await this.authService.getToken();
this.user = map2User(token);
// Set SEO meta tags for business listings page
this.seoService.updateMetaTags({
title: 'Businesses for Sale - Find Profitable Business Opportunities | BizMatch',
description: 'Browse thousands of businesses for sale across the United States. Find restaurants, franchises, retail stores, and more. Verified listings from business owners and brokers.',
keywords: 'businesses for sale, buy a business, business opportunities, franchise for sale, restaurant for sale, retail business for sale, business broker listings',
type: 'website'
});
// Subscribe to state changes
this.filterStateService
.getState$('businessListings')
.pipe(takeUntil(this.destroy$))
.subscribe(state => {
this.criteria = state.criteria;
this.sortBy = state.sortBy;
// Automatically search when state changes
this.search();
});
// Subscribe to search triggers (if triggered from other components)
this.searchService.searchTrigger$.pipe(takeUntil(this.destroy$)).subscribe(type => {
if (type === 'businessListings') {
this.search();
}
});
}
async search(): Promise<void> {
try {
// Show loading state
this.isLoading = true;
// Get current criteria from service
this.criteria = this.filterStateService.getCriteria('businessListings') as BusinessListingCriteria;
// Add sortBy if available
const searchCriteria = {
...this.criteria,
sortBy: this.sortBy,
};
// Perform search
const listingsResponse = await this.listingsService.getListings('business');
this.listings = listingsResponse.results;
this.totalRecords = listingsResponse.totalCount;
this.pageCount = Math.ceil(this.totalRecords / LISTINGS_PER_PAGE);
this.page = this.criteria.page || 1;
// Hide loading state
this.isLoading = false;
// Update pagination SEO links
this.updatePaginationSEO();
// Update view
this.cdRef.markForCheck();
this.cdRef.detectChanges();
} catch (error) {
console.error('Search error:', error);
// Handle error appropriately
this.listings = [];
this.totalRecords = 0;
this.isLoading = false;
this.cdRef.markForCheck();
}
}
onPageChange(page: number): void {
// Update only pagination properties
this.filterStateService.updateCriteria('businessListings', {
page: page,
start: (page - 1) * LISTINGS_PER_PAGE,
length: LISTINGS_PER_PAGE,
});
// Search will be triggered automatically through state subscription
}
clearAllFilters(): void {
// Reset criteria but keep sortBy
this.filterStateService.clearFilters('businessListings');
// Search will be triggered automatically through state subscription
}
async openFilterModal(): Promise<void> {
// Open modal with current criteria
const currentCriteria = this.filterStateService.getCriteria('businessListings');
const modalResult = await this.modalService.showModal(currentCriteria);
if (modalResult.accepted) {
// Modal accepted changes - state is updated by modal
// Search will be triggered automatically through state subscription
} else {
// Modal was cancelled - no action needed
}
}
getListingPrice(listing: BusinessListing): string {
if (!listing.price) return 'Price on Request';
return `$${listing.price.toLocaleString()}`;
}
getListingLocation(listing: BusinessListing): string {
if (!listing.location) return 'Location not specified';
return `${listing.location.name}, ${listing.location.state}`;
}
private isWithinDays(date: Date | string | undefined | null, days: number): boolean {
if (!date) return false;
return dayjs().diff(dayjs(date), 'day') < days;
}
getListingBadge(listing: BusinessListing): 'NEW' | 'UPDATED' | null {
if (this.isWithinDays(listing.created, 14)) return 'NEW'; // Priorität
if (this.isWithinDays(listing.updated, 14)) return 'UPDATED';
return null;
}
navigateToDetails(listingId: string): void {
this.router.navigate(['/details-business', listingId]);
}
getDaysListed(listing: BusinessListing) {
return dayjs().diff(listing.created, 'day');
}
/**
* Filter by popular category
*/
filterByCategory(category: string): void {
this.filterStateService.updateCriteria('businessListings', {
types: [category],
page: 1,
start: 0,
length: LISTINGS_PER_PAGE,
});
// Search will be triggered automatically through state subscription
}
/**
* Check if listing is already in user's favorites
*/
isFavorite(listing: BusinessListing): boolean {
if (!this.user?.email || !listing.favoritesForUser) return false;
return listing.favoritesForUser.includes(this.user.email);
}
/**
* Toggle favorite status for a listing
*/
async toggleFavorite(event: Event, listing: BusinessListing): Promise<void> {
event.stopPropagation();
event.preventDefault();
if (!this.user?.email) {
// User not logged in - redirect to login or show message
this.router.navigate(['/login']);
return;
}
try {
if (this.isFavorite(listing)) {
// Remove from favorites
await this.listingsService.removeFavorite(listing.id, 'business');
listing.favoritesForUser = listing.favoritesForUser.filter(email => email !== this.user!.email);
} else {
// Add to favorites
await this.listingsService.addToFavorites(listing.id, 'business');
if (!listing.favoritesForUser) {
listing.favoritesForUser = [];
}
listing.favoritesForUser.push(this.user.email);
}
this.cdRef.detectChanges();
} catch (error) {
console.error('Error toggling favorite:', error);
}
}
/**
* Share a listing - opens native share dialog or copies to clipboard
*/
async shareListing(event: Event, listing: BusinessListing): Promise<void> {
event.stopPropagation();
event.preventDefault();
const url = `${window.location.origin}/business/${listing.slug || listing.id}`;
const title = listing.title || 'Business Listing';
// Try native share API first (works on mobile and some desktop browsers)
if (navigator.share) {
try {
await navigator.share({
title: title,
text: `Check out this business: ${title}`,
url: url,
});
} catch (err) {
// User cancelled or share failed - fall back to clipboard
this.copyToClipboard(url);
}
} else {
// Fallback: open Facebook share dialog
const encodedUrl = encodeURIComponent(url);
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, '_blank', 'width=600,height=400');
}
}
/**
* Copy URL to clipboard and show feedback
*/
private copyToClipboard(url: string): void {
navigator.clipboard.writeText(url).then(() => {
// Could add a toast notification here
console.log('Link copied to clipboard!');
}).catch(err => {
console.error('Failed to copy link:', err);
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
// Clean up pagination links when leaving the page
this.seoService.clearPaginationLinks();
}
/**
* Update pagination SEO links (rel="next/prev") and CollectionPage schema
*/
private updatePaginationSEO(): void {
const baseUrl = `${this.seoService.getBaseUrl()}/businessListings`;
// Inject rel="next" and rel="prev" links
this.seoService.injectPaginationLinks(baseUrl, this.page, this.pageCount);
// Inject CollectionPage schema for paginated results
const collectionSchema = this.seoService.generateCollectionPageSchema({
name: 'Businesses for Sale',
description: 'Browse thousands of businesses for sale across the United States. Find restaurants, franchises, retail stores, and more.',
totalItems: this.totalRecords,
itemsPerPage: LISTINGS_PER_PAGE,
currentPage: this.page,
baseUrl: baseUrl
});
this.seoService.injectStructuredData(collectionSchema);
}
}
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { UntilDestroy } from '@ngneat/until-destroy';
import { Subject, takeUntil } from 'rxjs';
import dayjs from 'dayjs';
import { BusinessListing, SortByOptions } from '../../../../../../bizmatch-server/src/models/db.model';
import { BusinessListingCriteria, KeycloakUser, LISTINGS_PER_PAGE, ListingType, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment';
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
import { PaginatorComponent } from '../../../components/paginator/paginator.component';
import { ModalService } from '../../../components/search-modal/modal.service';
import { SearchModalComponent } from '../../../components/search-modal/search-modal.component';
import { LazyLoadImageDirective } from '../../../directives/lazy-load-image.directive';
import { AltTextService } from '../../../services/alt-text.service';
import { AuthService } from '../../../services/auth.service';
import { FilterStateService } from '../../../services/filter-state.service';
import { ImageService } from '../../../services/image.service';
import { ListingsService } from '../../../services/listings.service';
import { SearchService } from '../../../services/search.service';
import { SelectOptionsService } from '../../../services/select-options.service';
import { SeoService } from '../../../services/seo.service';
import { map2User } from '../../../utils/utils';
@UntilDestroy()
@Component({
selector: 'app-business-listings',
standalone: true,
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, SearchModalComponent, LazyLoadImageDirective, BreadcrumbsComponent],
templateUrl: './business-listings.component.html',
styleUrls: ['./business-listings.component.scss', '../../pages.scss'],
})
export class BusinessListingsComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
// Component properties
environment = environment;
env = environment;
listings: Array<BusinessListing> = [];
filteredListings: Array<ListingType> = [];
criteria: BusinessListingCriteria;
sortBy: SortByOptions | null = null;
// Pagination
totalRecords = 0;
page = 1;
pageCount = 1;
first = 0;
rows = LISTINGS_PER_PAGE;
// UI state
ts = new Date().getTime();
emailToDirName = emailToDirName;
isLoading = false;
// Breadcrumbs
breadcrumbs: BreadcrumbItem[] = [
{ label: 'Home', url: '/', icon: 'fas fa-home' },
{ label: 'Business Listings' }
];
// User for favorites
user: KeycloakUser | null = null;
constructor(
public altText: AltTextService,
public selectOptions: SelectOptionsService,
private listingsService: ListingsService,
private router: Router,
private cdRef: ChangeDetectorRef,
private imageService: ImageService,
private searchService: SearchService,
private modalService: ModalService,
private filterStateService: FilterStateService,
private route: ActivatedRoute,
private seoService: SeoService,
private authService: AuthService,
) { }
async ngOnInit(): Promise<void> {
// Load user for favorites functionality
const token = await this.authService.getToken();
this.user = map2User(token);
// Set SEO meta tags for business listings page
this.seoService.updateMetaTags({
title: 'Businesses for Sale - Profitable Opportunities | BizMatch',
description: 'Browse thousands of businesses for sale. Find restaurants, franchises, retail stores, and more. Verified listings from owners and brokers.',
keywords: 'businesses for sale, buy a business, business opportunities, franchise for sale, restaurant for sale, retail business for sale, business broker listings',
type: 'website'
});
// Subscribe to state changes
this.filterStateService
.getState$('businessListings')
.pipe(takeUntil(this.destroy$))
.subscribe(state => {
this.criteria = state.criteria;
this.sortBy = state.sortBy;
// Automatically search when state changes
this.search();
});
// Subscribe to search triggers (if triggered from other components)
this.searchService.searchTrigger$.pipe(takeUntil(this.destroy$)).subscribe(type => {
if (type === 'businessListings') {
this.search();
}
});
}
async search(): Promise<void> {
try {
// Show loading state
this.isLoading = true;
// Get current criteria from service
this.criteria = this.filterStateService.getCriteria('businessListings') as BusinessListingCriteria;
// Add sortBy if available
const searchCriteria = {
...this.criteria,
sortBy: this.sortBy,
};
// Perform search
const listingsResponse = await this.listingsService.getListings('business');
this.listings = listingsResponse.results;
this.totalRecords = listingsResponse.totalCount;
this.pageCount = Math.ceil(this.totalRecords / LISTINGS_PER_PAGE);
this.page = this.criteria.page || 1;
// Hide loading state
this.isLoading = false;
// Update pagination SEO links
this.updatePaginationSEO();
// Update view
this.cdRef.markForCheck();
this.cdRef.detectChanges();
} catch (error) {
console.error('Search error:', error);
// Handle error appropriately
this.listings = [];
this.totalRecords = 0;
this.isLoading = false;
this.cdRef.markForCheck();
}
}
onPageChange(page: number): void {
// Update only pagination properties
this.filterStateService.updateCriteria('businessListings', {
page: page,
start: (page - 1) * LISTINGS_PER_PAGE,
length: LISTINGS_PER_PAGE,
});
// Search will be triggered automatically through state subscription
}
clearAllFilters(): void {
// Reset criteria but keep sortBy
this.filterStateService.clearFilters('businessListings');
// Search will be triggered automatically through state subscription
}
async openFilterModal(): Promise<void> {
// Open modal with current criteria
const currentCriteria = this.filterStateService.getCriteria('businessListings');
const modalResult = await this.modalService.showModal(currentCriteria);
if (modalResult.accepted) {
// Modal accepted changes - state is updated by modal
// Search will be triggered automatically through state subscription
} else {
// Modal was cancelled - no action needed
}
}
getListingPrice(listing: BusinessListing): string {
if (!listing.price) return 'Price on Request';
return `$${listing.price.toLocaleString()}`;
}
getListingLocation(listing: BusinessListing): string {
if (!listing.location) return 'Location not specified';
return `${listing.location.name}, ${listing.location.state}`;
}
private isWithinDays(date: Date | string | undefined | null, days: number): boolean {
if (!date) return false;
return dayjs().diff(dayjs(date), 'day') < days;
}
getListingBadge(listing: BusinessListing): 'NEW' | 'UPDATED' | null {
if (this.isWithinDays(listing.created, 14)) return 'NEW'; // Priorität
if (this.isWithinDays(listing.updated, 14)) return 'UPDATED';
return null;
}
navigateToDetails(listingId: string): void {
this.router.navigate(['/details-business', listingId]);
}
getDaysListed(listing: BusinessListing) {
return dayjs().diff(listing.created, 'day');
}
/**
* Filter by popular category
*/
filterByCategory(category: string): void {
this.filterStateService.updateCriteria('businessListings', {
types: [category],
page: 1,
start: 0,
length: LISTINGS_PER_PAGE,
});
// Search will be triggered automatically through state subscription
}
/**
* Check if listing is already in user's favorites
*/
isFavorite(listing: BusinessListing): boolean {
if (!this.user?.email || !listing.favoritesForUser) return false;
return listing.favoritesForUser.includes(this.user.email);
}
/**
* Toggle favorite status for a listing
*/
async toggleFavorite(event: Event, listing: BusinessListing): Promise<void> {
event.stopPropagation();
event.preventDefault();
if (!this.user?.email) {
// User not logged in - redirect to login or show message
this.router.navigate(['/login']);
return;
}
try {
if (this.isFavorite(listing)) {
// Remove from favorites
await this.listingsService.removeFavorite(listing.id, 'business');
listing.favoritesForUser = listing.favoritesForUser.filter(email => email !== this.user!.email);
} else {
// Add to favorites
await this.listingsService.addToFavorites(listing.id, 'business');
if (!listing.favoritesForUser) {
listing.favoritesForUser = [];
}
listing.favoritesForUser.push(this.user.email);
}
this.cdRef.detectChanges();
} catch (error) {
console.error('Error toggling favorite:', error);
}
}
/**
* Share a listing - opens native share dialog or copies to clipboard
*/
async shareListing(event: Event, listing: BusinessListing): Promise<void> {
event.stopPropagation();
event.preventDefault();
const url = `${window.location.origin}/business/${listing.slug || listing.id}`;
const title = listing.title || 'Business Listing';
// Try native share API first (works on mobile and some desktop browsers)
if (navigator.share) {
try {
await navigator.share({
title: title,
text: `Check out this business: ${title}`,
url: url,
});
} catch (err) {
// User cancelled or share failed - fall back to clipboard
this.copyToClipboard(url);
}
} else {
// Fallback: open Facebook share dialog
const encodedUrl = encodeURIComponent(url);
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, '_blank', 'width=600,height=400');
}
}
/**
* Copy URL to clipboard and show feedback
*/
private copyToClipboard(url: string): void {
navigator.clipboard.writeText(url).then(() => {
// Could add a toast notification here
console.log('Link copied to clipboard!');
}).catch(err => {
console.error('Failed to copy link:', err);
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
// Clean up pagination links when leaving the page
this.seoService.clearPaginationLinks();
}
/**
* Update pagination SEO links (rel="next/prev") and CollectionPage schema
*/
private updatePaginationSEO(): void {
const baseUrl = `${this.seoService.getBaseUrl()}/businessListings`;
// Inject rel="next" and rel="prev" links
this.seoService.injectPaginationLinks(baseUrl, this.page, this.pageCount);
// Inject CollectionPage schema for paginated results
const collectionSchema = this.seoService.generateCollectionPageSchema({
name: 'Businesses for Sale',
description: 'Browse thousands of businesses for sale across the United States. Find restaurants, franchises, retail stores, and more.',
totalItems: this.totalRecords,
itemsPerPage: LISTINGS_PER_PAGE,
currentPage: this.page,
baseUrl: baseUrl
});
this.seoService.injectStructuredData(collectionSchema);
}
}

View File

@@ -1,143 +1,143 @@
<div class="flex flex-col md:flex-row">
<!-- Filter Panel for Desktop -->
<div class="hidden md:block w-full md:w-1/4 h-full bg-white shadow-lg p-6 overflow-y-auto z-10">
<app-search-modal-commercial [isModal]="false"></app-search-modal-commercial>
</div>
<!-- Main Content -->
<div class="w-full p-4">
<div class="container mx-auto">
<!-- Breadcrumbs -->
<div class="mb-4">
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
</div>
<!-- SEO-optimized heading -->
<div class="mb-6">
<h1 class="text-3xl md:text-4xl font-bold text-neutral-900 mb-2">Commercial Properties for Sale</h1>
<p class="text-lg text-neutral-600">Find office buildings, retail spaces, warehouses, and industrial properties across the United States. Investment opportunities from verified sellers and commercial real estate brokers.</p>
</div>
@if(listings?.length > 0) {
<h2 class="text-2xl font-semibold text-neutral-800 mb-4">Available Commercial Property Listings</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@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 flex flex-col h-full group relative">
<!-- Quick Actions Overlay -->
<div class="absolute top-4 right-4 z-10 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
@if(user) {
<button
class="bg-white rounded-full p-2 shadow-lg transition-colors"
[class.bg-red-50]="isFavorite(listing)"
[class.opacity-100]="isFavorite(listing)"
[title]="isFavorite(listing) ? 'Remove from favorites' : 'Save to favorites'"
(click)="toggleFavorite($event, listing)">
<i [class]="isFavorite(listing) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i>
</button>
}
<button type="button" class="bg-white rounded-full p-2 shadow-lg hover:bg-blue-50 transition-colors"
title="Share property" (click)="shareProperty($event, listing)">
<i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i>
</button>
</div>
@if (listing.imageOrder?.length>0){
<img [appLazyLoad]="env.imageBaseUrl + '/pictures/property/' + listing.imagePath + '/' + listing.serialId + '/' + listing.imageOrder[0]"
[alt]="altText.generatePropertyListingAlt(listing)"
class="w-full h-48 object-cover"
width="400"
height="192" />
} @else {
<img [appLazyLoad]="'assets/images/placeholder_properties.jpg'"
[alt]="'Commercial property placeholder - ' + listing.title"
class="w-full h-48 object-cover"
width="400"
height="192" />
}
<div class="p-4 flex flex-col flex-grow">
<span [class]="selectOptions.getTextColorTypeOfCommercial(listing.type)" class="text-sm font-semibold"
><i [class]="selectOptions.getIconAndTextColorTypeOfCommercials(listing.type)" class="mr-1"></i> {{ selectOptions.getCommercialProperty(listing.type) }}</span
>
<div class="flex items-center justify-between my-2">
<span class="bg-neutral-200 text-neutral-700 text-xs font-semibold px-2 py-1 rounded">{{ selectOptions.getState(listing.location.state) }}</span>
<p class="text-sm text-neutral-600 mb-4">
<strong>{{ getDaysListed(listing) }} days listed</strong>
</p>
</div>
<h3 class="text-lg font-semibold mb-2">
{{ 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>
}
</h3>
<p class="text-neutral-600 mb-2">{{ listing.location.name ? listing.location.name : listing.location.county }}</p>
<p class="text-xl font-bold mb-4">{{ listing.price | currency : 'USD' : 'symbol' : '1.0-0' }}</p>
<div class="flex-grow"></div>
<button [routerLink]="['/commercial-property', listing.slug || listing.id]" class="bg-success-500 text-white px-4 py-2 rounded-full w-full hover:bg-success-600 transition duration-300 mt-auto">
View Full Listing <i class="fas fa-arrow-right ml-1"></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-neutral-300 text-neutral-900 text-xs font-semibold leading-4">Clear Filter</button>
</div>
</div>
</div>
</div>
}
</div>
@if(pageCount > 1) {
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator>
}
</div>
<!-- Filter Button for Mobile -->
<button (click)="openFilterModal()" class="md:hidden fixed bottom-4 right-4 bg-primary-500 text-white p-3 rounded-full shadow-lg z-20"><i class="fas fa-filter"></i> Filter</button>
</div>
<div class="flex flex-col md:flex-row">
<!-- Filter Panel for Desktop -->
<div class="hidden md:block w-full md:w-1/4 h-full bg-white shadow-lg p-6 overflow-y-auto z-10">
<app-search-modal-commercial [isModal]="false"></app-search-modal-commercial>
</div>
<!-- Main Content -->
<div class="w-full p-4">
<div class="container mx-auto">
<!-- Breadcrumbs -->
<div class="mb-4">
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
</div>
<!-- SEO-optimized heading -->
<div class="mb-6">
<h1 class="text-3xl md:text-4xl font-bold text-neutral-900 mb-2">Commercial Properties for Sale</h1>
<p class="text-lg text-neutral-600">Find office buildings, retail spaces, warehouses, and industrial properties across the United States. Investment opportunities from verified sellers and commercial real estate brokers.</p>
</div>
@if(listings?.length > 0) {
<h2 class="text-2xl font-semibold text-neutral-800 mb-4">Available Commercial Property Listings</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@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 flex flex-col h-full group relative">
<!-- Quick Actions Overlay -->
<div class="absolute top-4 right-4 z-10 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
@if(user) {
<button
class="bg-white rounded-full p-2 shadow-lg transition-colors"
[class.bg-red-50]="isFavorite(listing)"
[class.opacity-100]="isFavorite(listing)"
[title]="isFavorite(listing) ? 'Remove from favorites' : 'Save to favorites'"
(click)="toggleFavorite($event, listing)">
<i [class]="isFavorite(listing) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i>
</button>
}
<button type="button" class="bg-white rounded-full p-2 shadow-lg hover:bg-blue-50 transition-colors"
title="Share property" (click)="shareProperty($event, listing)">
<i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i>
</button>
</div>
@if (listing.imageOrder?.length>0){
<img [appLazyLoad]="env.imageBaseUrl + '/pictures/property/' + listing.imagePath + '/' + listing.serialId + '/' + listing.imageOrder[0]"
[alt]="altText.generatePropertyListingAlt(listing)"
class="w-full h-48 object-cover"
width="400"
height="192" />
} @else {
<img [appLazyLoad]="'assets/images/placeholder_properties.jpg'"
[alt]="'Commercial property placeholder - ' + listing.title"
class="w-full h-48 object-cover"
width="400"
height="192" />
}
<div class="p-4 flex flex-col flex-grow">
<span [class]="selectOptions.getTextColorTypeOfCommercial(listing.type)" class="text-sm font-semibold"
><i [class]="selectOptions.getIconAndTextColorTypeOfCommercials(listing.type)" class="mr-1"></i> {{ selectOptions.getCommercialProperty(listing.type) }}</span
>
<div class="flex items-center justify-between my-2">
<span class="bg-neutral-200 text-neutral-700 text-xs font-semibold px-2 py-1 rounded">{{ selectOptions.getState(listing.location.state) }}</span>
<p class="text-sm text-neutral-600 mb-4">
<strong>{{ getDaysListed(listing) }} days listed</strong>
</p>
</div>
<h3 class="text-lg font-semibold mb-2">
{{ 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>
}
</h3>
<p class="text-neutral-600 mb-2">{{ listing.location.name ? listing.location.name : listing.location.county }}</p>
<p class="text-xl font-bold mb-4">{{ listing.price | currency : 'USD' : 'symbol' : '1.0-0' }}</p>
<div class="flex-grow"></div>
<button [routerLink]="['/commercial-property', listing.slug || listing.id]" class="bg-success-500 text-white px-4 py-2 rounded-full w-full hover:bg-success-600 transition duration-300 mt-auto">
View Full Listing <i class="fas fa-arrow-right ml-1"></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-neutral-300 text-neutral-900 text-xs font-semibold leading-4">Clear Filter</button>
</div>
</div>
</div>
</div>
}
</div>
@if(pageCount > 1) {
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator>
}
</div>
<!-- Filter Button for Mobile -->
<button (click)="openFilterModal()" class="md:hidden fixed bottom-4 right-4 bg-primary-500 text-white p-3 rounded-full shadow-lg z-20"><i class="fas fa-filter"></i> Filter</button>
</div>

View File

@@ -1,301 +1,301 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { UntilDestroy } from '@ngneat/until-destroy';
import dayjs from 'dayjs';
import { Subject, takeUntil } from 'rxjs';
import { CommercialPropertyListing, SortByOptions } from '../../../../../../bizmatch-server/src/models/db.model';
import { CommercialPropertyListingCriteria, KeycloakUser, LISTINGS_PER_PAGE, ResponseCommercialPropertyListingArray } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment';
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
import { PaginatorComponent } from '../../../components/paginator/paginator.component';
import { ModalService } from '../../../components/search-modal/modal.service';
import { SearchModalCommercialComponent } from '../../../components/search-modal/search-modal-commercial.component';
import { LazyLoadImageDirective } from '../../../directives/lazy-load-image.directive';
import { AltTextService } from '../../../services/alt-text.service';
import { FilterStateService } from '../../../services/filter-state.service';
import { ImageService } from '../../../services/image.service';
import { ListingsService } from '../../../services/listings.service';
import { SearchService } from '../../../services/search.service';
import { SelectOptionsService } from '../../../services/select-options.service';
import { SeoService } from '../../../services/seo.service';
import { AuthService } from '../../../services/auth.service';
import { map2User } from '../../../utils/utils';
@UntilDestroy()
@Component({
selector: 'app-commercial-property-listings',
standalone: true,
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, SearchModalCommercialComponent, LazyLoadImageDirective, BreadcrumbsComponent],
templateUrl: './commercial-property-listings.component.html',
styleUrls: ['./commercial-property-listings.component.scss', '../../pages.scss'],
})
export class CommercialPropertyListingsComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
// Component properties
environment = environment;
env = environment;
listings: Array<CommercialPropertyListing> = [];
filteredListings: Array<CommercialPropertyListing> = [];
criteria: CommercialPropertyListingCriteria;
sortBy: SortByOptions | null = null;
// Pagination
totalRecords = 0;
page = 1;
pageCount = 1;
first = 0;
rows = LISTINGS_PER_PAGE;
// UI state
ts = new Date().getTime();
// Breadcrumbs
breadcrumbs: BreadcrumbItem[] = [
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
{ label: 'Commercial Properties' }
];
// User for favorites
user: KeycloakUser | null = null;
constructor(
public altText: AltTextService,
public selectOptions: SelectOptionsService,
private listingsService: ListingsService,
private router: Router,
private cdRef: ChangeDetectorRef,
private imageService: ImageService,
private searchService: SearchService,
private modalService: ModalService,
private filterStateService: FilterStateService,
private route: ActivatedRoute,
private seoService: SeoService,
private authService: AuthService,
) {}
async ngOnInit(): Promise<void> {
// Load user for favorites functionality
const token = await this.authService.getToken();
this.user = map2User(token);
// Set SEO meta tags for commercial property listings page
this.seoService.updateMetaTags({
title: 'Commercial Properties for Sale - Office, Retail, Industrial Real Estate | BizMatch',
description: 'Browse commercial real estate listings including office buildings, retail spaces, warehouses, and industrial properties. Investment opportunities from verified sellers and brokers across the United States.',
keywords: 'commercial property for sale, commercial real estate, office building for sale, retail space for sale, warehouse for sale, industrial property, investment property, commercial property listings',
type: 'website'
});
// Subscribe to state changes
this.filterStateService
.getState$('commercialPropertyListings')
.pipe(takeUntil(this.destroy$))
.subscribe(state => {
this.criteria = state.criteria;
this.sortBy = state.sortBy;
// Automatically search when state changes
this.search();
});
// Subscribe to search triggers (if triggered from other components)
this.searchService.searchTrigger$.pipe(takeUntil(this.destroy$)).subscribe(type => {
if (type === 'commercialPropertyListings') {
this.search();
}
});
}
async search(): Promise<void> {
try {
// Perform search
const listingResponse = await this.listingsService.getListings('commercialProperty');
this.listings = (listingResponse as ResponseCommercialPropertyListingArray).results;
this.totalRecords = (listingResponse as ResponseCommercialPropertyListingArray).totalCount;
this.pageCount = Math.ceil(this.totalRecords / LISTINGS_PER_PAGE);
this.page = this.criteria.page || 1;
// Update pagination SEO links
this.updatePaginationSEO();
// Update view
this.cdRef.markForCheck();
this.cdRef.detectChanges();
} catch (error) {
console.error('Search error:', error);
// Handle error appropriately
this.listings = [];
this.totalRecords = 0;
this.cdRef.markForCheck();
}
}
onPageChange(page: number): void {
// Update only pagination properties
this.filterStateService.updateCriteria('commercialPropertyListings', {
page: page,
start: (page - 1) * LISTINGS_PER_PAGE,
length: LISTINGS_PER_PAGE,
});
// Search will be triggered automatically through state subscription
}
clearAllFilters(): void {
// Reset criteria but keep sortBy
this.filterStateService.clearFilters('commercialPropertyListings');
// Search will be triggered automatically through state subscription
}
async openFilterModal(): Promise<void> {
// Open modal with current criteria
const currentCriteria = this.filterStateService.getCriteria('commercialPropertyListings');
const modalResult = await this.modalService.showModal(currentCriteria);
if (modalResult.accepted) {
// Modal accepted changes - state is updated by modal
// Search will be triggered automatically through state subscription
} else {
// Modal was cancelled - no action needed
}
}
// Helper methods for template
getTS(): number {
return new Date().getTime();
}
getDaysListed(listing: CommercialPropertyListing): number {
return dayjs().diff(listing.created, 'day');
}
getListingImage(listing: CommercialPropertyListing): string {
if (listing.imageOrder?.length > 0) {
return `${this.env.imageBaseUrl}/pictures/property/${listing.imagePath}/${listing.serialId}/${listing.imageOrder[0]}`;
}
return 'assets/images/placeholder_properties.jpg';
}
getListingPrice(listing: CommercialPropertyListing): string {
if (!listing.price) return 'Price on Request';
return `$${listing.price.toLocaleString()}`;
}
getListingLocation(listing: CommercialPropertyListing): string {
if (!listing.location) return 'Location not specified';
return listing.location.name || listing.location.county || 'Location not specified';
}
navigateToDetails(listingId: string): void {
this.router.navigate(['/details-commercial-property-listing', listingId]);
}
/**
* Check if listing is already in user's favorites
*/
isFavorite(listing: CommercialPropertyListing): boolean {
if (!this.user?.email || !listing.favoritesForUser) return false;
return listing.favoritesForUser.includes(this.user.email);
}
/**
* Toggle favorite status for a listing
*/
async toggleFavorite(event: Event, listing: CommercialPropertyListing): Promise<void> {
event.stopPropagation();
event.preventDefault();
if (!this.user?.email) {
// User not logged in - redirect to login
this.router.navigate(['/login']);
return;
}
try {
if (this.isFavorite(listing)) {
// Remove from favorites
await this.listingsService.removeFavorite(listing.id, 'commercialProperty');
listing.favoritesForUser = listing.favoritesForUser.filter(email => email !== this.user!.email);
} else {
// Add to favorites
await this.listingsService.addToFavorites(listing.id, 'commercialProperty');
if (!listing.favoritesForUser) {
listing.favoritesForUser = [];
}
listing.favoritesForUser.push(this.user.email);
}
this.cdRef.detectChanges();
} catch (error) {
console.error('Error toggling favorite:', error);
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
// Clean up pagination links when leaving the page
this.seoService.clearPaginationLinks();
}
/**
* Update pagination SEO links (rel="next/prev") and CollectionPage schema
*/
private updatePaginationSEO(): void {
const baseUrl = `${this.seoService.getBaseUrl()}/commercialPropertyListings`;
// Inject rel="next" and rel="prev" links
this.seoService.injectPaginationLinks(baseUrl, this.page, this.pageCount);
// Inject CollectionPage schema for paginated results
const collectionSchema = this.seoService.generateCollectionPageSchema({
name: 'Commercial Properties for Sale',
description: 'Browse commercial real estate listings including office buildings, retail spaces, warehouses, and industrial properties across the United States.',
totalItems: this.totalRecords,
itemsPerPage: LISTINGS_PER_PAGE,
currentPage: this.page,
baseUrl: baseUrl
});
this.seoService.injectStructuredData(collectionSchema);
}
/**
* Share property listing
*/
async shareProperty(event: Event, listing: CommercialPropertyListing): Promise<void> {
event.stopPropagation();
event.preventDefault();
const url = `${window.location.origin}/commercial-property/${listing.slug || listing.id}`;
const title = listing.title || 'Commercial Property Listing';
// Try native share API first (works on mobile and some desktop browsers)
if (navigator.share) {
try {
await navigator.share({
title: title,
text: `Check out this property: ${title}`,
url: url,
});
} catch (err) {
// User cancelled or share failed - fall back to clipboard
this.copyToClipboard(url);
}
} else {
// Fallback: open Facebook share dialog
const encodedUrl = encodeURIComponent(url);
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, '_blank', 'width=600,height=400');
}
}
/**
* Copy URL to clipboard and show feedback
*/
private copyToClipboard(url: string): void {
navigator.clipboard.writeText(url).then(() => {
console.log('Link copied to clipboard!');
}).catch(err => {
console.error('Failed to copy link:', err);
});
}
}
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { UntilDestroy } from '@ngneat/until-destroy';
import dayjs from 'dayjs';
import { Subject, takeUntil } from 'rxjs';
import { CommercialPropertyListing, SortByOptions } from '../../../../../../bizmatch-server/src/models/db.model';
import { CommercialPropertyListingCriteria, KeycloakUser, LISTINGS_PER_PAGE, ResponseCommercialPropertyListingArray } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment';
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
import { PaginatorComponent } from '../../../components/paginator/paginator.component';
import { ModalService } from '../../../components/search-modal/modal.service';
import { SearchModalCommercialComponent } from '../../../components/search-modal/search-modal-commercial.component';
import { LazyLoadImageDirective } from '../../../directives/lazy-load-image.directive';
import { AltTextService } from '../../../services/alt-text.service';
import { FilterStateService } from '../../../services/filter-state.service';
import { ImageService } from '../../../services/image.service';
import { ListingsService } from '../../../services/listings.service';
import { SearchService } from '../../../services/search.service';
import { SelectOptionsService } from '../../../services/select-options.service';
import { SeoService } from '../../../services/seo.service';
import { AuthService } from '../../../services/auth.service';
import { map2User } from '../../../utils/utils';
@UntilDestroy()
@Component({
selector: 'app-commercial-property-listings',
standalone: true,
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, SearchModalCommercialComponent, LazyLoadImageDirective, BreadcrumbsComponent],
templateUrl: './commercial-property-listings.component.html',
styleUrls: ['./commercial-property-listings.component.scss', '../../pages.scss'],
})
export class CommercialPropertyListingsComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
// Component properties
environment = environment;
env = environment;
listings: Array<CommercialPropertyListing> = [];
filteredListings: Array<CommercialPropertyListing> = [];
criteria: CommercialPropertyListingCriteria;
sortBy: SortByOptions | null = null;
// Pagination
totalRecords = 0;
page = 1;
pageCount = 1;
first = 0;
rows = LISTINGS_PER_PAGE;
// UI state
ts = new Date().getTime();
// Breadcrumbs
breadcrumbs: BreadcrumbItem[] = [
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
{ label: 'Commercial Properties' }
];
// User for favorites
user: KeycloakUser | null = null;
constructor(
public altText: AltTextService,
public selectOptions: SelectOptionsService,
private listingsService: ListingsService,
private router: Router,
private cdRef: ChangeDetectorRef,
private imageService: ImageService,
private searchService: SearchService,
private modalService: ModalService,
private filterStateService: FilterStateService,
private route: ActivatedRoute,
private seoService: SeoService,
private authService: AuthService,
) {}
async ngOnInit(): Promise<void> {
// Load user for favorites functionality
const token = await this.authService.getToken();
this.user = map2User(token);
// Set SEO meta tags for commercial property listings page
this.seoService.updateMetaTags({
title: 'Commercial Properties for Sale - Office, Retail | BizMatch',
description: 'Browse commercial real estate: office buildings, retail spaces, warehouses, and industrial properties. Verified investment opportunities.',
keywords: 'commercial property for sale, commercial real estate, office building for sale, retail space for sale, warehouse for sale, industrial property, investment property, commercial property listings',
type: 'website'
});
// Subscribe to state changes
this.filterStateService
.getState$('commercialPropertyListings')
.pipe(takeUntil(this.destroy$))
.subscribe(state => {
this.criteria = state.criteria;
this.sortBy = state.sortBy;
// Automatically search when state changes
this.search();
});
// Subscribe to search triggers (if triggered from other components)
this.searchService.searchTrigger$.pipe(takeUntil(this.destroy$)).subscribe(type => {
if (type === 'commercialPropertyListings') {
this.search();
}
});
}
async search(): Promise<void> {
try {
// Perform search
const listingResponse = await this.listingsService.getListings('commercialProperty');
this.listings = (listingResponse as ResponseCommercialPropertyListingArray).results;
this.totalRecords = (listingResponse as ResponseCommercialPropertyListingArray).totalCount;
this.pageCount = Math.ceil(this.totalRecords / LISTINGS_PER_PAGE);
this.page = this.criteria.page || 1;
// Update pagination SEO links
this.updatePaginationSEO();
// Update view
this.cdRef.markForCheck();
this.cdRef.detectChanges();
} catch (error) {
console.error('Search error:', error);
// Handle error appropriately
this.listings = [];
this.totalRecords = 0;
this.cdRef.markForCheck();
}
}
onPageChange(page: number): void {
// Update only pagination properties
this.filterStateService.updateCriteria('commercialPropertyListings', {
page: page,
start: (page - 1) * LISTINGS_PER_PAGE,
length: LISTINGS_PER_PAGE,
});
// Search will be triggered automatically through state subscription
}
clearAllFilters(): void {
// Reset criteria but keep sortBy
this.filterStateService.clearFilters('commercialPropertyListings');
// Search will be triggered automatically through state subscription
}
async openFilterModal(): Promise<void> {
// Open modal with current criteria
const currentCriteria = this.filterStateService.getCriteria('commercialPropertyListings');
const modalResult = await this.modalService.showModal(currentCriteria);
if (modalResult.accepted) {
// Modal accepted changes - state is updated by modal
// Search will be triggered automatically through state subscription
} else {
// Modal was cancelled - no action needed
}
}
// Helper methods for template
getTS(): number {
return new Date().getTime();
}
getDaysListed(listing: CommercialPropertyListing): number {
return dayjs().diff(listing.created, 'day');
}
getListingImage(listing: CommercialPropertyListing): string {
if (listing.imageOrder?.length > 0) {
return `${this.env.imageBaseUrl}/pictures/property/${listing.imagePath}/${listing.serialId}/${listing.imageOrder[0]}`;
}
return 'assets/images/placeholder_properties.jpg';
}
getListingPrice(listing: CommercialPropertyListing): string {
if (!listing.price) return 'Price on Request';
return `$${listing.price.toLocaleString()}`;
}
getListingLocation(listing: CommercialPropertyListing): string {
if (!listing.location) return 'Location not specified';
return listing.location.name || listing.location.county || 'Location not specified';
}
navigateToDetails(listingId: string): void {
this.router.navigate(['/details-commercial-property-listing', listingId]);
}
/**
* Check if listing is already in user's favorites
*/
isFavorite(listing: CommercialPropertyListing): boolean {
if (!this.user?.email || !listing.favoritesForUser) return false;
return listing.favoritesForUser.includes(this.user.email);
}
/**
* Toggle favorite status for a listing
*/
async toggleFavorite(event: Event, listing: CommercialPropertyListing): Promise<void> {
event.stopPropagation();
event.preventDefault();
if (!this.user?.email) {
// User not logged in - redirect to login
this.router.navigate(['/login']);
return;
}
try {
if (this.isFavorite(listing)) {
// Remove from favorites
await this.listingsService.removeFavorite(listing.id, 'commercialProperty');
listing.favoritesForUser = listing.favoritesForUser.filter(email => email !== this.user!.email);
} else {
// Add to favorites
await this.listingsService.addToFavorites(listing.id, 'commercialProperty');
if (!listing.favoritesForUser) {
listing.favoritesForUser = [];
}
listing.favoritesForUser.push(this.user.email);
}
this.cdRef.detectChanges();
} catch (error) {
console.error('Error toggling favorite:', error);
}
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
// Clean up pagination links when leaving the page
this.seoService.clearPaginationLinks();
}
/**
* Update pagination SEO links (rel="next/prev") and CollectionPage schema
*/
private updatePaginationSEO(): void {
const baseUrl = `${this.seoService.getBaseUrl()}/commercialPropertyListings`;
// Inject rel="next" and rel="prev" links
this.seoService.injectPaginationLinks(baseUrl, this.page, this.pageCount);
// Inject CollectionPage schema for paginated results
const collectionSchema = this.seoService.generateCollectionPageSchema({
name: 'Commercial Properties for Sale',
description: 'Browse commercial real estate listings including office buildings, retail spaces, warehouses, and industrial properties across the United States.',
totalItems: this.totalRecords,
itemsPerPage: LISTINGS_PER_PAGE,
currentPage: this.page,
baseUrl: baseUrl
});
this.seoService.injectStructuredData(collectionSchema);
}
/**
* Share property listing
*/
async shareProperty(event: Event, listing: CommercialPropertyListing): Promise<void> {
event.stopPropagation();
event.preventDefault();
const url = `${window.location.origin}/commercial-property/${listing.slug || listing.id}`;
const title = listing.title || 'Commercial Property Listing';
// Try native share API first (works on mobile and some desktop browsers)
if (navigator.share) {
try {
await navigator.share({
title: title,
text: `Check out this property: ${title}`,
url: url,
});
} catch (err) {
// User cancelled or share failed - fall back to clipboard
this.copyToClipboard(url);
}
} else {
// Fallback: open Facebook share dialog
const encodedUrl = encodeURIComponent(url);
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, '_blank', 'width=600,height=400');
}
}
/**
* Copy URL to clipboard and show feedback
*/
private copyToClipboard(url: string): void {
navigator.clipboard.writeText(url).then(() => {
console.log('Link copied to clipboard!');
}).catch(err => {
console.error('Failed to copy link:', err);
});
}
}

View File

@@ -1,42 +1,42 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { AuthService } from '../../services/auth.service';
import { UserService } from '../../services/user.service';
import { map2User } from '../../utils/utils';
@Component({
selector: 'app-login',
standalone: true,
imports: [CommonModule, RouterModule],
template: ``,
})
export class LoginComponent {
page: string | undefined = this.activatedRoute.snapshot.params['page'] as string | undefined;
constructor(
public userService: UserService,
private activatedRoute: ActivatedRoute,
private router: Router,
private authService: AuthService,
) {}
async ngOnInit() {
const token = await this.authService.getToken();
const keycloakUser = map2User(token);
const email = keycloakUser.email;
const user = await this.userService.getByMail(email);
// if (!user.subscriptionPlan) {
// const subscriptions = await lastValueFrom(this.subscriptionService.getAllSubscriptions(user.email));
// const activeSubscription = subscriptions.filter(s => s.status === 'active');
// if (activeSubscription.length > 0) {
// user.subscriptionPlan = activeSubscription[0].metadata['plan'] === 'Broker Plan' ? 'broker' : 'professional';
// this.userService.saveGuaranteed(user);
// } else {
// this.router.navigate([`/home`]);
// return;
// }
// }
this.router.navigate([`/${this.page}`]);
}
}
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { AuthService } from '../../services/auth.service';
import { UserService } from '../../services/user.service';
import { map2User } from '../../utils/utils';
@Component({
selector: 'app-login',
standalone: true,
imports: [CommonModule, RouterModule],
template: ``,
})
export class LoginComponent {
page: string | undefined = this.activatedRoute.snapshot.params['page'] as string | undefined;
constructor(
public userService: UserService,
private activatedRoute: ActivatedRoute,
private router: Router,
private authService: AuthService,
) {}
async ngOnInit() {
const token = await this.authService.getToken();
const keycloakUser = map2User(token);
const email = keycloakUser.email;
const user = await this.userService.getByMail(email);
// if (!user.subscriptionPlan) {
// const subscriptions = await lastValueFrom(this.subscriptionService.getAllSubscriptions(user.email));
// const activeSubscription = subscriptions.filter(s => s.status === 'active');
// if (activeSubscription.length > 0) {
// user.subscriptionPlan = activeSubscription[0].metadata['plan'] === 'Broker Plan' ? 'broker' : 'professional';
// this.userService.saveGuaranteed(user);
// } else {
// this.router.navigate([`/home`]);
// return;
// }
// }
this.router.navigate([`/${this.page}`]);
}
}

View File

@@ -1,335 +1,335 @@
<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>
}@else{
<app-validated-select 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>
<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>
}@else{
<app-validated-select 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

@@ -1,274 +1,274 @@
import { DatePipe, TitleCasePipe } from '@angular/common';
import { ChangeDetectorRef, Component } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { faTrash } from '@fortawesome/free-solid-svg-icons';
import { NgSelectModule } from '@ng-select/ng-select';
import { QuillModule } from 'ngx-quill';
import { lastValueFrom } from 'rxjs';
import { User } from '../../../../../../bizmatch-server/src/models/db.model';
import { AutoCompleteCompleteEvent, Invoice, UploadParams, ValidationMessage, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment';
import { ConfirmationComponent } from '../../../components/confirmation/confirmation.component';
import { ConfirmationService } from '../../../components/confirmation/confirmation.service';
import { ImageCropAndUploadComponent, UploadReponse } from '../../../components/image-crop-and-upload/image-crop-and-upload.component';
import { MessageService } from '../../../components/message/message.service';
import { TooltipComponent } from '../../../components/tooltip/tooltip.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 { ValidationMessagesService } from '../../../components/validation-messages.service';
import { AuthService } from '../../../services/auth.service';
import { GeoService } from '../../../services/geo.service';
import { ImageService } from '../../../services/image.service';
import { LoadingService } from '../../../services/loading.service';
import { SelectOptionsService } from '../../../services/select-options.service';
import { SharedService } from '../../../services/shared.service';
import { UserService } from '../../../services/user.service';
import { SharedModule } from '../../../shared/shared/shared.module';
import { map2User } from '../../../utils/utils';
import { TOOLBAR_OPTIONS } from '../../utils/defaults';
@Component({
selector: 'app-account',
standalone: true,
imports: [
SharedModule,
QuillModule,
NgSelectModule,
ConfirmationComponent,
ImageCropAndUploadComponent,
ValidatedInputComponent,
ValidatedSelectComponent,
ValidatedQuillComponent,
TooltipComponent,
ValidatedCountyComponent,
ValidatedLocationComponent,
],
providers: [TitleCasePipe, DatePipe],
templateUrl: './account.component.html',
styleUrl: './account.component.scss',
})
export class AccountComponent {
id: string | undefined = this.activatedRoute.snapshot.params['id'] as 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';
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 datePipe: DatePipe,
private router: Router,
public authService: AuthService,
) { }
async ngOnInit() {
// Flowbite is now initialized once in AppComponent
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),
}));
}
// async synchronizeSubscriptions(subscriptions: StripeSubscription[]) {
// let changed = false;
// if (this.isAdmin()) {
// return;
// }
// if (this.subscriptions.length === 0) {
// if (!this.user.subscriptionPlan) {
// this.router.navigate(['pricing']);
// } else {
// this.subscriptions = [{ ended_at: null, start_date: Math.floor(new Date(this.user.created).getTime() / 1000), status: null, metadata: { plan: 'Free Plan' } }];
// changed = checkAndUpdate(changed, this.user.customerType !== 'buyer' && this.user.customerType !== 'seller', () => (this.user.customerType = 'buyer'));
// changed = checkAndUpdate(changed, !!this.user.customerSubType, () => (this.user.customerSubType = null));
// changed = checkAndUpdate(changed, this.user.subscriptionPlan !== 'free', () => (this.user.subscriptionPlan = 'free'));
// changed = checkAndUpdate(changed, !!this.user.subscriptionId, () => (this.user.subscriptionId = null));
// }
// } else {
// const subscription = subscriptions[0];
// changed = checkAndUpdate(changed, subscription.metadata['plan'] === 'Broker Plan' && this.user.customerType !== 'professional', () => (this.user.customerType = 'professional'));
// changed = checkAndUpdate(changed, subscription.metadata['plan'] === 'Broker Plan' && this.user.customerSubType !== 'broker', () => (this.user.customerSubType = 'broker'));
// changed = checkAndUpdate(changed, subscription.metadata['plan'] === 'Broker Plan' && this.user.subscriptionPlan !== 'broker', () => (this.user.subscriptionPlan = 'broker'));
// changed = checkAndUpdate(changed, subscription.metadata['plan'] === 'Broker Plan' && !this.user.subscriptionId, () => (this.user.subscriptionId = subscription.id));
// changed = checkAndUpdate(changed, subscription.metadata['plan'] === 'Professional Plan' && this.user.customerType !== 'professional', () => (this.user.customerType = 'professional'));
// changed = checkAndUpdate(changed, subscription.metadata['plan'] === 'Professional Plan' && this.user.subscriptionPlan !== 'professional', () => (this.user.subscriptionPlan = 'professional'));
// changed = checkAndUpdate(changed, subscription.metadata['plan'] === 'Professional Plan' && this.user.subscriptionId !== 'professional', () => (this.user.subscriptionId = subscription.id));
// }
// if (changed) {
// await this.userService.saveGuaranteed(this.user);
// this.cdref.detectChanges();
// this.cdref.markForCheck();
// }
// }
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 : '';
// }
}
import { DatePipe, TitleCasePipe } from '@angular/common';
import { ChangeDetectorRef, Component } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { faTrash } from '@fortawesome/free-solid-svg-icons';
import { NgSelectModule } from '@ng-select/ng-select';
import { QuillModule } from 'ngx-quill';
import { lastValueFrom } from 'rxjs';
import { User } from '../../../../../../bizmatch-server/src/models/db.model';
import { AutoCompleteCompleteEvent, Invoice, UploadParams, ValidationMessage, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment';
import { ConfirmationComponent } from '../../../components/confirmation/confirmation.component';
import { ConfirmationService } from '../../../components/confirmation/confirmation.service';
import { ImageCropAndUploadComponent, UploadReponse } from '../../../components/image-crop-and-upload/image-crop-and-upload.component';
import { MessageService } from '../../../components/message/message.service';
import { TooltipComponent } from '../../../components/tooltip/tooltip.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 { ValidationMessagesService } from '../../../components/validation-messages.service';
import { AuthService } from '../../../services/auth.service';
import { GeoService } from '../../../services/geo.service';
import { ImageService } from '../../../services/image.service';
import { LoadingService } from '../../../services/loading.service';
import { SelectOptionsService } from '../../../services/select-options.service';
import { SharedService } from '../../../services/shared.service';
import { UserService } from '../../../services/user.service';
import { SharedModule } from '../../../shared/shared/shared.module';
import { map2User } from '../../../utils/utils';
import { TOOLBAR_OPTIONS } from '../../utils/defaults';
@Component({
selector: 'app-account',
standalone: true,
imports: [
SharedModule,
QuillModule,
NgSelectModule,
ConfirmationComponent,
ImageCropAndUploadComponent,
ValidatedInputComponent,
ValidatedSelectComponent,
ValidatedQuillComponent,
TooltipComponent,
ValidatedCountyComponent,
ValidatedLocationComponent,
],
providers: [TitleCasePipe, DatePipe],
templateUrl: './account.component.html',
styleUrl: './account.component.scss',
})
export class AccountComponent {
id: string | undefined = this.activatedRoute.snapshot.params['id'] as 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';
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 datePipe: DatePipe,
private router: Router,
public authService: AuthService,
) { }
async ngOnInit() {
// Flowbite is now initialized once in AppComponent
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),
}));
}
// async synchronizeSubscriptions(subscriptions: StripeSubscription[]) {
// let changed = false;
// if (this.isAdmin()) {
// return;
// }
// if (this.subscriptions.length === 0) {
// if (!this.user.subscriptionPlan) {
// this.router.navigate(['pricing']);
// } else {
// this.subscriptions = [{ ended_at: null, start_date: Math.floor(new Date(this.user.created).getTime() / 1000), status: null, metadata: { plan: 'Free Plan' } }];
// changed = checkAndUpdate(changed, this.user.customerType !== 'buyer' && this.user.customerType !== 'seller', () => (this.user.customerType = 'buyer'));
// changed = checkAndUpdate(changed, !!this.user.customerSubType, () => (this.user.customerSubType = null));
// changed = checkAndUpdate(changed, this.user.subscriptionPlan !== 'free', () => (this.user.subscriptionPlan = 'free'));
// changed = checkAndUpdate(changed, !!this.user.subscriptionId, () => (this.user.subscriptionId = null));
// }
// } else {
// const subscription = subscriptions[0];
// changed = checkAndUpdate(changed, subscription.metadata['plan'] === 'Broker Plan' && this.user.customerType !== 'professional', () => (this.user.customerType = 'professional'));
// changed = checkAndUpdate(changed, subscription.metadata['plan'] === 'Broker Plan' && this.user.customerSubType !== 'broker', () => (this.user.customerSubType = 'broker'));
// changed = checkAndUpdate(changed, subscription.metadata['plan'] === 'Broker Plan' && this.user.subscriptionPlan !== 'broker', () => (this.user.subscriptionPlan = 'broker'));
// changed = checkAndUpdate(changed, subscription.metadata['plan'] === 'Broker Plan' && !this.user.subscriptionId, () => (this.user.subscriptionId = subscription.id));
// changed = checkAndUpdate(changed, subscription.metadata['plan'] === 'Professional Plan' && this.user.customerType !== 'professional', () => (this.user.customerType = 'professional'));
// changed = checkAndUpdate(changed, subscription.metadata['plan'] === 'Professional Plan' && this.user.subscriptionPlan !== 'professional', () => (this.user.subscriptionPlan = 'professional'));
// changed = checkAndUpdate(changed, subscription.metadata['plan'] === 'Professional Plan' && this.user.subscriptionId !== 'professional', () => (this.user.subscriptionId = subscription.id));
// }
// if (changed) {
// await this.userService.saveGuaranteed(this.user);
// this.cdref.detectChanges();
// this.cdref.markForCheck();
// }
// }
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

@@ -1,178 +1,178 @@
import { Component } from '@angular/core';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { lastValueFrom } from 'rxjs';
import { ListingsService } from '../../../services/listings.service';
import { SelectOptionsService } from '../../../services/select-options.service';
import { map2User, routeListingWithState } from '../../../utils/utils';
import { DragDropModule } from '@angular/cdk/drag-drop';
import { faTrash } from '@fortawesome/free-solid-svg-icons';
import { QuillModule } from 'ngx-quill';
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, ImageProperty, createDefaultBusinessListing, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment';
import { MessageService } from '../../../components/message/message.service';
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 { LoadingService } from '../../../services/loading.service';
import { UserService } from '../../../services/user.service';
import { SharedModule } from '../../../shared/shared/shared.module';
import { TOOLBAR_OPTIONS } from '../../utils/defaults';
@Component({
selector: 'business-listing',
standalone: true,
imports: [
SharedModule,
DragDropModule,
QuillModule,
NgSelectModule,
ValidatedInputComponent,
ValidatedQuillComponent,
ValidatedNgSelectComponent,
ValidatedPriceComponent,
ValidatedTextareaComponent,
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 = this.activatedRoute.snapshot.params['id'] as 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 };
});
}
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, 'business'));
} 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.listing.listingsCategory);
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) {
console.error('Error saving listing:', error);
let errorText = 'An error occurred while saving the listing - Please check your inputs';
if (error.error && Array.isArray(error.error?.message)) {
this.validationMessagesService.updateMessages(error.error.message);
errorText = 'Please fix the validation errors highlighted in the form';
} else if (error.error?.message) {
errorText = `Error: ${error.error.message}`;
}
this.messageService.addMessage({
severity: 'danger',
text: errorText,
duration: 5000,
});
}
}
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;
}
}
import { Component } from '@angular/core';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { lastValueFrom } from 'rxjs';
import { ListingsService } from '../../../services/listings.service';
import { SelectOptionsService } from '../../../services/select-options.service';
import { map2User, routeListingWithState } from '../../../utils/utils';
import { DragDropModule } from '@angular/cdk/drag-drop';
import { faTrash } from '@fortawesome/free-solid-svg-icons';
import { QuillModule } from 'ngx-quill';
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, ImageProperty, createDefaultBusinessListing, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment';
import { MessageService } from '../../../components/message/message.service';
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 { LoadingService } from '../../../services/loading.service';
import { UserService } from '../../../services/user.service';
import { SharedModule } from '../../../shared/shared/shared.module';
import { TOOLBAR_OPTIONS } from '../../utils/defaults';
@Component({
selector: 'business-listing',
standalone: true,
imports: [
SharedModule,
DragDropModule,
QuillModule,
NgSelectModule,
ValidatedInputComponent,
ValidatedQuillComponent,
ValidatedNgSelectComponent,
ValidatedPriceComponent,
ValidatedTextareaComponent,
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 = this.activatedRoute.snapshot.params['id'] as 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 };
});
}
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, 'business'));
} 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.listing.listingsCategory);
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) {
console.error('Error saving listing:', error);
let errorText = 'An error occurred while saving the listing - Please check your inputs';
if (error.error && Array.isArray(error.error?.message)) {
this.validationMessagesService.updateMessages(error.error.message);
errorText = 'Please fix the validation errors highlighted in the form';
} else if (error.error?.message) {
errorText = `Error: ${error.error.message}`;
}
this.messageService.addMessage({
severity: 'danger',
text: errorText,
duration: 5000,
});
}
}
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

@@ -1,228 +1,228 @@
import { ChangeDetectorRef, Component, ElementRef, ViewChild } from '@angular/core';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { lastValueFrom } from 'rxjs';
import { ListingsService } from '../../../services/listings.service';
import { SelectOptionsService } from '../../../services/select-options.service';
import { map2User, routeListingWithState } from '../../../utils/utils';
import { DragDropModule } from '@angular/cdk/drag-drop';
import { ViewportRuler } from '@angular/cdk/scrolling';
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 { BusinessListing, CommercialPropertyListing, User } from '../../../../../../bizmatch-server/src/models/db.model';
import { AutoCompleteCompleteEvent, ImageProperty, UploadParams, createDefaultCommercialPropertyListing, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment';
import { ConfirmationComponent } from '../../../components/confirmation/confirmation.component';
import { ConfirmationService } from '../../../components/confirmation/confirmation.service';
import { DragDropMixedComponent } from '../../../components/drag-drop-mixed/drag-drop-mixed.component';
import { ImageCropAndUploadComponent, UploadReponse } from '../../../components/image-crop-and-upload/image-crop-and-upload.component';
import { MessageService } from '../../../components/message/message.service';
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 { 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 { LoadingService } from '../../../services/loading.service';
import { UserService } from '../../../services/user.service';
import { SharedModule } from '../../../shared/shared/shared.module';
import { TOOLBAR_OPTIONS } from '../../utils/defaults';
@Component({
selector: 'commercial-property-listing',
standalone: true,
imports: [
SharedModule,
DragDropModule,
QuillModule,
NgSelectModule,
ConfirmationComponent,
DragDropMixedComponent,
ValidatedInputComponent,
ValidatedQuillComponent,
ValidatedNgSelectComponent,
ValidatedPriceComponent,
ValidatedLocationComponent,
ImageCropAndUploadComponent,
],
providers: [],
templateUrl: './edit-commercial-property-listing.component.html',
styleUrl: './edit-commercial-property-listing.component.scss',
})
export class EditCommercialPropertyListingComponent {
@ViewChild('fileInput') fileInput!: ElementRef<HTMLInputElement>;
listingsCategory = 'commercialProperty';
category: string;
location: string;
mode: 'edit' | 'create';
separator: '\n\n';
listing: CommercialPropertyListing;
private id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined;
user: User;
maxFileSize = 3000000;
environment = environment;
responsiveOptions = [
{
breakpoint: '1199px',
numVisible: 1,
numScroll: 1,
},
{
breakpoint: '991px',
numVisible: 2,
numScroll: 1,
},
{
breakpoint: '767px',
numVisible: 1,
numScroll: 1,
},
];
config = { aspectRatio: 16 / 9 };
editorModules = TOOLBAR_OPTIONS;
draggedImage: ImageProperty;
faTrash = faTrash;
suggestions: string[] | undefined;
data: BusinessListing;
userId: string;
typesOfCommercialProperty = [];
listingCategories = [];
env = environment;
ts = new Date().getTime();
quillModules = {
toolbar: [['bold', 'italic', 'underline', 'strike'], [{ list: 'ordered' }, { list: 'bullet' }], [{ header: [1, 2, 3, 4, 5, 6, false] }], [{ color: [] }, { background: [] }], ['clean']],
};
//showModal = false;
imageChangedEvent: any = '';
croppedImage: Blob | null = null;
uploadParams: UploadParams;
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 route: ActivatedRoute,
private cdr: ChangeDetectorRef,
private confirmationService: ConfirmationService,
private messageService: MessageService,
private viewportRuler: ViewportRuler,
private validationMessagesService: ValidationMessagesService,
private authService: AuthService,
) {
// Abonniere Router-Events, um den aktiven Link zu ermitteln
this.router.events.subscribe(event => {
if (event instanceof NavigationEnd) {
this.mode = event.url.startsWith('/createCommercialPropertyListing') ? 'create' : 'edit';
}
});
this.route.data.subscribe(async () => {
if (this.router.getCurrentNavigation().extras.state) {
this.data = this.router.getCurrentNavigation().extras.state['data'];
}
});
this.typesOfCommercialProperty = selectOptions.typesOfCommercialProperty.map(e => {
return { name: e.name, value: e.value };
});
}
async ngOnInit() {
const token = await this.authService.getToken();
const keycloakUser = map2User(token);
const email = keycloakUser.email;
this.user = await this.userService.getByMail(email);
this.listingCategories = this.selectOptions.listingCategories
.filter(lc => lc.value === 'commercialProperty' || this.user.customerType === 'professional')
.map(e => {
return { name: e.name, value: e.value };
});
if (this.mode === 'edit') {
this.listing = (await lastValueFrom(this.listingsService.getListingById(this.id, 'commercialProperty'))) as CommercialPropertyListing;
} else {
this.listing = createDefaultCommercialPropertyListing();
const listingUser = await this.userService.getByMail(keycloakUser.email);
this.listing.email = listingUser.email;
this.listing.imagePath = `${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.listing.listingsCategory)) as CommercialPropertyListing;
this.router.navigate(['editCommercialPropertyListing', 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) {
console.error('Error saving listing:', error);
let errorText = 'An error occurred while saving the listing - Please check your inputs';
if (error.error && Array.isArray(error.error?.message)) {
this.validationMessagesService.updateMessages(error.error.message);
errorText = 'Please fix the validation errors highlighted in the form';
} else if (error.error?.message) {
errorText = `Error: ${error.error.message}`;
}
this.messageService.addMessage({
severity: 'danger',
text: errorText,
duration: 5000,
});
}
}
async search(event: AutoCompleteCompleteEvent) {
const result = await lastValueFrom(this.geoService.findCitiesStartingWith(event.query));
this.suggestions = result.map(r => r.name).slice(0, 5);
}
uploadPropertyPicture() {
this.uploadParams = { type: 'uploadPropertyPicture', imagePath: this.listing.imagePath, serialId: this.listing.serialId };
}
async uploadFinished(response: UploadReponse) {
if (response.success) {
this.listing = (await lastValueFrom(this.listingsService.getListingById(this.id, 'commercialProperty'))) as CommercialPropertyListing;
}
}
async deleteConfirm(imageName: string) {
const confirmed = await this.confirmationService.showConfirmation({ message: 'Are you sure you want to delete this image?' });
if (confirmed) {
this.listing.imageOrder = this.listing.imageOrder.filter(item => item !== imageName);
await this.imageService.deleteListingImage(this.listing.imagePath, this.listing.serialId, imageName);
await this.listingsService.save(this.listing, 'commercialProperty');
this.listing = (await lastValueFrom(this.listingsService.getListingById(this.id, 'commercialProperty'))) as CommercialPropertyListing;
this.messageService.addMessage({ severity: 'success', text: 'Image has been deleted', duration: 3000 });
this.ts = new Date().getTime();
} else {
console.log('deny');
}
}
changeListingCategory(value: 'business' | 'commercialProperty') {
routeListingWithState(this.router, value, this.listing);
}
imageOrderChanged(imageOrder: string[]) {
this.listing.imageOrder = imageOrder;
}
}
import { ChangeDetectorRef, Component, ElementRef, ViewChild } from '@angular/core';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { lastValueFrom } from 'rxjs';
import { ListingsService } from '../../../services/listings.service';
import { SelectOptionsService } from '../../../services/select-options.service';
import { map2User, routeListingWithState } from '../../../utils/utils';
import { DragDropModule } from '@angular/cdk/drag-drop';
import { ViewportRuler } from '@angular/cdk/scrolling';
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 { BusinessListing, CommercialPropertyListing, User } from '../../../../../../bizmatch-server/src/models/db.model';
import { AutoCompleteCompleteEvent, ImageProperty, UploadParams, createDefaultCommercialPropertyListing, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment';
import { ConfirmationComponent } from '../../../components/confirmation/confirmation.component';
import { ConfirmationService } from '../../../components/confirmation/confirmation.service';
import { DragDropMixedComponent } from '../../../components/drag-drop-mixed/drag-drop-mixed.component';
import { ImageCropAndUploadComponent, UploadReponse } from '../../../components/image-crop-and-upload/image-crop-and-upload.component';
import { MessageService } from '../../../components/message/message.service';
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 { 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 { LoadingService } from '../../../services/loading.service';
import { UserService } from '../../../services/user.service';
import { SharedModule } from '../../../shared/shared/shared.module';
import { TOOLBAR_OPTIONS } from '../../utils/defaults';
@Component({
selector: 'commercial-property-listing',
standalone: true,
imports: [
SharedModule,
DragDropModule,
QuillModule,
NgSelectModule,
ConfirmationComponent,
DragDropMixedComponent,
ValidatedInputComponent,
ValidatedQuillComponent,
ValidatedNgSelectComponent,
ValidatedPriceComponent,
ValidatedLocationComponent,
ImageCropAndUploadComponent,
],
providers: [],
templateUrl: './edit-commercial-property-listing.component.html',
styleUrl: './edit-commercial-property-listing.component.scss',
})
export class EditCommercialPropertyListingComponent {
@ViewChild('fileInput') fileInput!: ElementRef<HTMLInputElement>;
listingsCategory = 'commercialProperty';
category: string;
location: string;
mode: 'edit' | 'create';
separator: '\n\n';
listing: CommercialPropertyListing;
private id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined;
user: User;
maxFileSize = 3000000;
environment = environment;
responsiveOptions = [
{
breakpoint: '1199px',
numVisible: 1,
numScroll: 1,
},
{
breakpoint: '991px',
numVisible: 2,
numScroll: 1,
},
{
breakpoint: '767px',
numVisible: 1,
numScroll: 1,
},
];
config = { aspectRatio: 16 / 9 };
editorModules = TOOLBAR_OPTIONS;
draggedImage: ImageProperty;
faTrash = faTrash;
suggestions: string[] | undefined;
data: BusinessListing;
userId: string;
typesOfCommercialProperty = [];
listingCategories = [];
env = environment;
ts = new Date().getTime();
quillModules = {
toolbar: [['bold', 'italic', 'underline', 'strike'], [{ list: 'ordered' }, { list: 'bullet' }], [{ header: [1, 2, 3, 4, 5, 6, false] }], [{ color: [] }, { background: [] }], ['clean']],
};
//showModal = false;
imageChangedEvent: any = '';
croppedImage: Blob | null = null;
uploadParams: UploadParams;
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 route: ActivatedRoute,
private cdr: ChangeDetectorRef,
private confirmationService: ConfirmationService,
private messageService: MessageService,
private viewportRuler: ViewportRuler,
private validationMessagesService: ValidationMessagesService,
private authService: AuthService,
) {
// Abonniere Router-Events, um den aktiven Link zu ermitteln
this.router.events.subscribe(event => {
if (event instanceof NavigationEnd) {
this.mode = event.url.startsWith('/createCommercialPropertyListing') ? 'create' : 'edit';
}
});
this.route.data.subscribe(async () => {
if (this.router.getCurrentNavigation().extras.state) {
this.data = this.router.getCurrentNavigation().extras.state['data'];
}
});
this.typesOfCommercialProperty = selectOptions.typesOfCommercialProperty.map(e => {
return { name: e.name, value: e.value };
});
}
async ngOnInit() {
const token = await this.authService.getToken();
const keycloakUser = map2User(token);
const email = keycloakUser.email;
this.user = await this.userService.getByMail(email);
this.listingCategories = this.selectOptions.listingCategories
.filter(lc => lc.value === 'commercialProperty' || this.user.customerType === 'professional')
.map(e => {
return { name: e.name, value: e.value };
});
if (this.mode === 'edit') {
this.listing = (await lastValueFrom(this.listingsService.getListingById(this.id, 'commercialProperty'))) as CommercialPropertyListing;
} else {
this.listing = createDefaultCommercialPropertyListing();
const listingUser = await this.userService.getByMail(keycloakUser.email);
this.listing.email = listingUser.email;
this.listing.imagePath = `${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.listing.listingsCategory)) as CommercialPropertyListing;
this.router.navigate(['editCommercialPropertyListing', 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) {
console.error('Error saving listing:', error);
let errorText = 'An error occurred while saving the listing - Please check your inputs';
if (error.error && Array.isArray(error.error?.message)) {
this.validationMessagesService.updateMessages(error.error.message);
errorText = 'Please fix the validation errors highlighted in the form';
} else if (error.error?.message) {
errorText = `Error: ${error.error.message}`;
}
this.messageService.addMessage({
severity: 'danger',
text: errorText,
duration: 5000,
});
}
}
async search(event: AutoCompleteCompleteEvent) {
const result = await lastValueFrom(this.geoService.findCitiesStartingWith(event.query));
this.suggestions = result.map(r => r.name).slice(0, 5);
}
uploadPropertyPicture() {
this.uploadParams = { type: 'uploadPropertyPicture', imagePath: this.listing.imagePath, serialId: this.listing.serialId };
}
async uploadFinished(response: UploadReponse) {
if (response.success) {
this.listing = (await lastValueFrom(this.listingsService.getListingById(this.id, 'commercialProperty'))) as CommercialPropertyListing;
}
}
async deleteConfirm(imageName: string) {
const confirmed = await this.confirmationService.showConfirmation({ message: 'Are you sure you want to delete this image?' });
if (confirmed) {
this.listing.imageOrder = this.listing.imageOrder.filter(item => item !== imageName);
await this.imageService.deleteListingImage(this.listing.imagePath, this.listing.serialId, imageName);
await this.listingsService.save(this.listing, 'commercialProperty');
this.listing = (await lastValueFrom(this.listingsService.getListingById(this.id, 'commercialProperty'))) as CommercialPropertyListing;
this.messageService.addMessage({ severity: 'success', text: 'Image has been deleted', duration: 3000 });
this.ts = new Date().getTime();
} else {
console.log('deny');
}
}
changeListingCategory(value: 'business' | 'commercialProperty') {
routeListingWithState(this.router, value, this.listing);
}
imageOrderChanged(imageOrder: string[]) {
this.listing.imageOrder = imageOrder;
}
}

View File

@@ -1,132 +1,132 @@
<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){
@if(isBusinessOrCommercial(listing)){
<tr class="border-b">
<td class="py-2 px-4">{{ $any(listing).title }}</td>
<td class="py-2 px-4">{{ $any(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">${{ $any(listing).price.toLocaleString() }}</td>
<td class="py-2 px-4 flex">
@if($any(listing).listingsCategory==='business'){
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
[routerLink]="['/business', $any(listing).slug || listing.id]">
<i class="fa-regular fa-eye"></i>
</button>
} @if($any(listing).listingsCategory==='commercialProperty'){
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
[routerLink]="['/commercial-property', $any(listing).slug || 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>
} @else {
<tr class="border-b">
<td class="py-2 px-4">{{ $any(listing).firstname }} {{ $any(listing).lastname }}</td>
<td class="py-2 px-4">Professional</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">-</td>
<td class="py-2 px-4 flex">
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
[routerLink]="['/details-user', 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">
@if(isBusinessOrCommercial(listing)){
<h2 class="text-xl font-semibold mb-2">{{ $any(listing).title }}</h2>
<p class="text-gray-600 mb-2">Category: {{ $any(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: ${{ $any(listing).price.toLocaleString() }}</p>
<div class="flex justify-start">
@if($any(listing).listingsCategory==='business'){
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
[routerLink]="['/business', $any(listing).slug || listing.id]">
<i class="fa-regular fa-eye"></i>
</button>
} @if($any(listing).listingsCategory==='commercialProperty'){
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
[routerLink]="['/commercial-property', $any(listing).slug || 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>
} @else {
<h2 class="text-xl font-semibold mb-2">{{ $any(listing).firstname }} {{ $any(listing).lastname }}</h2>
<p class="text-gray-600 mb-2">Category: Professional</p>
<p class="text-gray-600 mb-2">Located in: {{ listing.location?.name ? listing.location.name :
listing.location?.county }}, {{ listing.location?.state }}</p>
<div class="flex justify-start">
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
[routerLink]="['/details-user', 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>
<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){
@if(isBusinessOrCommercial(listing)){
<tr class="border-b">
<td class="py-2 px-4">{{ $any(listing).title }}</td>
<td class="py-2 px-4">{{ $any(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">${{ $any(listing).price.toLocaleString() }}</td>
<td class="py-2 px-4 flex">
@if($any(listing).listingsCategory==='business'){
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
[routerLink]="['/business', $any(listing).slug || listing.id]">
<i class="fa-regular fa-eye"></i>
</button>
} @if($any(listing).listingsCategory==='commercialProperty'){
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
[routerLink]="['/commercial-property', $any(listing).slug || 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>
} @else {
<tr class="border-b">
<td class="py-2 px-4">{{ $any(listing).firstname }} {{ $any(listing).lastname }}</td>
<td class="py-2 px-4">Professional</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">-</td>
<td class="py-2 px-4 flex">
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
[routerLink]="['/details-user', 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">
@if(isBusinessOrCommercial(listing)){
<h2 class="text-xl font-semibold mb-2">{{ $any(listing).title }}</h2>
<p class="text-gray-600 mb-2">Category: {{ $any(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: ${{ $any(listing).price.toLocaleString() }}</p>
<div class="flex justify-start">
@if($any(listing).listingsCategory==='business'){
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
[routerLink]="['/business', $any(listing).slug || listing.id]">
<i class="fa-regular fa-eye"></i>
</button>
} @if($any(listing).listingsCategory==='commercialProperty'){
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
[routerLink]="['/commercial-property', $any(listing).slug || 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>
} @else {
<h2 class="text-xl font-semibold mb-2">{{ $any(listing).firstname }} {{ $any(listing).lastname }}</h2>
<p class="text-gray-600 mb-2">Category: Professional</p>
<p class="text-gray-600 mb-2">Located in: {{ listing.location?.name ? listing.location.name :
listing.location?.county }}, {{ listing.location?.state }}</p>
<div class="flex justify-start">
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
[routerLink]="['/details-user', 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

@@ -1,54 +1,54 @@
import { Component } from '@angular/core';
import { BusinessListing, CommercialPropertyListing, User } from '../../../../../../bizmatch-server/src/models/db.model';
import { KeycloakUser } from '../../../../../../bizmatch-server/src/models/main.model';
import { ConfirmationComponent } from '../../../components/confirmation/confirmation.component';
import { ConfirmationService } from '../../../components/confirmation/confirmation.service';
import { AuthService } from '../../../services/auth.service';
import { ListingsService } from '../../../services/listings.service';
import { SelectOptionsService } from '../../../services/select-options.service';
import { SharedModule } from '../../../shared/shared/shared.module';
import { map2User } from '../../../utils/utils';
@Component({
selector: 'app-favorites',
standalone: true,
imports: [SharedModule, 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 | CommercialPropertyListing | User>;
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);
const result = await Promise.all([await this.listingsService.getFavoriteListings('business'), await this.listingsService.getFavoriteListings('commercialProperty'), await this.listingsService.getFavoriteListings('user')]);
this.favorites = [...result[0], ...result[1], ...result[2]] as Array<BusinessListing | CommercialPropertyListing | User>;
}
async confirmDelete(listing: BusinessListing | CommercialPropertyListing | User) {
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 | CommercialPropertyListing | User) {
if ('listingsCategory' in listing) {
if (listing.listingsCategory === 'business') {
await this.listingsService.removeFavorite(listing.id, 'business');
} else {
await this.listingsService.removeFavorite(listing.id, 'commercialProperty');
}
} else {
await this.listingsService.removeFavorite(listing.id, 'user');
}
const result = await Promise.all([await this.listingsService.getFavoriteListings('business'), await this.listingsService.getFavoriteListings('commercialProperty'), await this.listingsService.getFavoriteListings('user')]);
this.favorites = [...result[0], ...result[1], ...result[2]] as Array<BusinessListing | CommercialPropertyListing | User>;
}
isBusinessOrCommercial(listing: any): boolean {
return !!listing.listingsCategory;
}
}
import { Component } from '@angular/core';
import { BusinessListing, CommercialPropertyListing, User } from '../../../../../../bizmatch-server/src/models/db.model';
import { KeycloakUser } from '../../../../../../bizmatch-server/src/models/main.model';
import { ConfirmationComponent } from '../../../components/confirmation/confirmation.component';
import { ConfirmationService } from '../../../components/confirmation/confirmation.service';
import { AuthService } from '../../../services/auth.service';
import { ListingsService } from '../../../services/listings.service';
import { SelectOptionsService } from '../../../services/select-options.service';
import { SharedModule } from '../../../shared/shared/shared.module';
import { map2User } from '../../../utils/utils';
@Component({
selector: 'app-favorites',
standalone: true,
imports: [SharedModule, 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 | CommercialPropertyListing | User>;
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);
const result = await Promise.all([await this.listingsService.getFavoriteListings('business'), await this.listingsService.getFavoriteListings('commercialProperty'), await this.listingsService.getFavoriteListings('user')]);
this.favorites = [...result[0], ...result[1], ...result[2]] as Array<BusinessListing | CommercialPropertyListing | User>;
}
async confirmDelete(listing: BusinessListing | CommercialPropertyListing | User) {
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 | CommercialPropertyListing | User) {
if ('listingsCategory' in listing) {
if (listing.listingsCategory === 'business') {
await this.listingsService.removeFavorite(listing.id, 'business');
} else {
await this.listingsService.removeFavorite(listing.id, 'commercialProperty');
}
} else {
await this.listingsService.removeFavorite(listing.id, 'user');
}
const result = await Promise.all([await this.listingsService.getFavoriteListings('business'), await this.listingsService.getFavoriteListings('commercialProperty'), await this.listingsService.getFavoriteListings('user')]);
this.favorites = [...result[0], ...result[1], ...result[2]] as Array<BusinessListing | CommercialPropertyListing | User>;
}
isBusinessOrCommercial(listing: any): boolean {
return !!listing.listingsCategory;
}
}

View File

@@ -1,116 +1,116 @@
import { ChangeDetectorRef, Component } from '@angular/core';
import { CommercialPropertyListing, User } from '../../../../../../bizmatch-server/src/models/db.model';
import { ListingType } from '../../../../../../bizmatch-server/src/models/main.model';
import { ConfirmationComponent } from '../../../components/confirmation/confirmation.component';
import { ConfirmationService } from '../../../components/confirmation/confirmation.service';
import { MessageComponent } from '../../../components/message/message.component';
import { MessageService } from '../../../components/message/message.service';
import { AuthService } from '../../../services/auth.service';
import { ListingsService } from '../../../services/listings.service';
import { SelectOptionsService } from '../../../services/select-options.service';
import { UserService } from '../../../services/user.service';
import { SharedModule } from '../../../shared/shared/shared.module';
import { map2User } from '../../../utils/utils';
@Component({
selector: 'app-my-listing',
standalone: true,
imports: [SharedModule, ConfirmationComponent],
providers: [],
templateUrl: './my-listing.component.html',
styleUrl: './my-listing.component.scss',
})
export class MyListingComponent {
// Vollständige, ungefilterte Daten
listings: Array<ListingType> = [];
// Aktuell angezeigte (gefilterte) Daten
myListings: Array<ListingType> = [];
user: User;
// VERY small filter state
filters = {
title: '',
internalListingNumber: '',
location: '',
status: '' as '' | 'published' | 'draft',
category: '' as '' | 'business' | 'commercialProperty', // <── NEU
};
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);
const result = await Promise.all([this.listingsService.getListingsByEmail(this.user.email, 'business'), this.listingsService.getListingsByEmail(this.user.email, 'commercialProperty')]);
this.listings = [...result[0], ...result[1]];
this.myListings = this.listings;
}
private normalize(s: string | undefined | null): string {
return (s ?? '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ') // Kommas, Bindestriche etc. neutralisieren
.trim()
.replace(/\s+/g, ' '); // Mehrfach-Spaces zu einem Space
}
applyFilters() {
const titleQ = this.normalize(this.filters.title);
const locQ = this.normalize(this.filters.location);
const intQ = this.normalize(this.filters.internalListingNumber);
const catQ = this.filters.category; // <── NEU
const status = this.filters.status;
this.myListings = this.listings.filter(l => {
const okTitle = !titleQ || this.normalize(l.title).includes(titleQ);
const locStr = this.normalize(`${l.location?.name ? l.location.name : l.location?.county} ${l.location?.state}`);
const okLoc = !locQ || locStr.includes(locQ);
const ilnStr = this.normalize((l as any).internalListingNumber?.toString());
const okInt = !intQ || ilnStr.includes(intQ);
const okCat = !catQ || l.listingsCategory === catQ; // <── NEU
const isDraft = !!(l as any).draft;
const okStatus = !status || (status === 'published' && !isDraft) || (status === 'draft' && isDraft);
return okTitle && okLoc && okInt && okCat && okStatus; // <── NEU
});
}
clearFilters() {
this.filters = { title: '', internalListingNumber: '', location: '', status: '', category: '' };
this.myListings = this.listings;
}
async deleteListing(listing: ListingType) {
if (listing.listingsCategory === 'business') {
await this.listingsService.deleteBusinessListing(listing.id);
} else {
await this.listingsService.deleteCommercialPropertyListing(listing.id, (listing as CommercialPropertyListing).imagePath);
}
const result = await Promise.all([this.listingsService.getListingsByEmail(this.user.email, 'business'), this.listingsService.getListingsByEmail(this.user.email, 'commercialProperty')]);
this.listings = [...result[0], ...result[1]];
this.applyFilters(); // Filter beibehalten nach Löschen
}
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);
}
}
}
import { ChangeDetectorRef, Component } from '@angular/core';
import { CommercialPropertyListing, User } from '../../../../../../bizmatch-server/src/models/db.model';
import { ListingType } from '../../../../../../bizmatch-server/src/models/main.model';
import { ConfirmationComponent } from '../../../components/confirmation/confirmation.component';
import { ConfirmationService } from '../../../components/confirmation/confirmation.service';
import { MessageComponent } from '../../../components/message/message.component';
import { MessageService } from '../../../components/message/message.service';
import { AuthService } from '../../../services/auth.service';
import { ListingsService } from '../../../services/listings.service';
import { SelectOptionsService } from '../../../services/select-options.service';
import { UserService } from '../../../services/user.service';
import { SharedModule } from '../../../shared/shared/shared.module';
import { map2User } from '../../../utils/utils';
@Component({
selector: 'app-my-listing',
standalone: true,
imports: [SharedModule, ConfirmationComponent],
providers: [],
templateUrl: './my-listing.component.html',
styleUrl: './my-listing.component.scss',
})
export class MyListingComponent {
// Vollständige, ungefilterte Daten
listings: Array<ListingType> = [];
// Aktuell angezeigte (gefilterte) Daten
myListings: Array<ListingType> = [];
user: User;
// VERY small filter state
filters = {
title: '',
internalListingNumber: '',
location: '',
status: '' as '' | 'published' | 'draft',
category: '' as '' | 'business' | 'commercialProperty', // <── NEU
};
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);
const result = await Promise.all([this.listingsService.getListingsByEmail(this.user.email, 'business'), this.listingsService.getListingsByEmail(this.user.email, 'commercialProperty')]);
this.listings = [...result[0], ...result[1]];
this.myListings = this.listings;
}
private normalize(s: string | undefined | null): string {
return (s ?? '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ') // Kommas, Bindestriche etc. neutralisieren
.trim()
.replace(/\s+/g, ' '); // Mehrfach-Spaces zu einem Space
}
applyFilters() {
const titleQ = this.normalize(this.filters.title);
const locQ = this.normalize(this.filters.location);
const intQ = this.normalize(this.filters.internalListingNumber);
const catQ = this.filters.category; // <── NEU
const status = this.filters.status;
this.myListings = this.listings.filter(l => {
const okTitle = !titleQ || this.normalize(l.title).includes(titleQ);
const locStr = this.normalize(`${l.location?.name ? l.location.name : l.location?.county} ${l.location?.state}`);
const okLoc = !locQ || locStr.includes(locQ);
const ilnStr = this.normalize((l as any).internalListingNumber?.toString());
const okInt = !intQ || ilnStr.includes(intQ);
const okCat = !catQ || l.listingsCategory === catQ; // <── NEU
const isDraft = !!(l as any).draft;
const okStatus = !status || (status === 'published' && !isDraft) || (status === 'draft' && isDraft);
return okTitle && okLoc && okInt && okCat && okStatus; // <── NEU
});
}
clearFilters() {
this.filters = { title: '', internalListingNumber: '', location: '', status: '', category: '' };
this.myListings = this.listings;
}
async deleteListing(listing: ListingType) {
if (listing.listingsCategory === 'business') {
await this.listingsService.deleteBusinessListing(listing.id);
} else {
await this.listingsService.deleteCommercialPropertyListing(listing.id, (listing as CommercialPropertyListing).imagePath);
}
const result = await Promise.all([this.listingsService.getListingsByEmail(this.user.email, 'business'), this.listingsService.getListingsByEmail(this.user.email, 'commercialProperty')]);
this.listings = [...result[0], ...result[1]];
this.applyFilters(); // Filter beibehalten nach Löschen
}
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);
}
}
}

View File

@@ -1,18 +1,18 @@
import { isPlatformBrowser } from '@angular/common';
import { inject, PLATFORM_ID } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { KeycloakService } from '../services/keycloak.service';
export const authResolver: ResolveFn<boolean> = async (route, state) => {
const keycloakService: KeycloakService = inject(KeycloakService);
const platformId = inject(PLATFORM_ID);
const isBrowser = isPlatformBrowser(platformId);
if (!keycloakService.isLoggedIn() && isBrowser) {
await keycloakService.login({
redirectUri: window.location.href,
});
}
return true;
};
import { isPlatformBrowser } from '@angular/common';
import { inject, PLATFORM_ID } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { KeycloakService } from '../services/keycloak.service';
export const authResolver: ResolveFn<boolean> = async (route, state) => {
const keycloakService: KeycloakService = inject(KeycloakService);
const platformId = inject(PLATFORM_ID);
const isBrowser = isPlatformBrowser(platformId);
if (!keycloakService.isLoggedIn() && isBrowser) {
await keycloakService.login({
redirectUri: window.location.href,
});
}
return true;
};

View File

@@ -1,120 +1,120 @@
import { Injectable, inject } from '@angular/core';
import { SelectOptionsService } from './select-options.service';
/**
* Service for generating SEO-optimized alt text for images
* Ensures consistent, keyword-rich alt text across the application
*/
@Injectable({
providedIn: 'root'
})
export class AltTextService {
private selectOptions = inject(SelectOptionsService);
/**
* Generate alt text for business listing images
* Format: "Business Name - Business Type for sale in City, State"
* Example: "Italian Restaurant - Restaurant for sale in Austin, TX"
*/
generateBusinessListingAlt(listing: any): string {
const location = this.getLocationString(listing.location);
const businessType = listing.type ? this.selectOptions.getBusiness(listing.type) : 'Business';
return `${listing.title} - ${businessType} for sale in ${location}`;
}
/**
* Generate alt text for commercial property listing images
* Format: "Property Type for sale - Title in City, State"
* Example: "Retail Space for sale - Downtown storefront in Miami, FL"
*/
generatePropertyListingAlt(listing: any): string {
const location = this.getLocationString(listing.location);
const propertyType = listing.type ? this.selectOptions.getCommercialProperty(listing.type) : 'Commercial Property';
return `${propertyType} for sale - ${listing.title} in ${location}`;
}
/**
* Generate alt text for broker/user profile photos
* Format: "First Last - Customer Type broker profile photo"
* Example: "John Smith - Business broker profile photo"
*/
generateBrokerProfileAlt(user: any): string {
const name = `${user.firstname || ''} ${user.lastname || ''}`.trim() || 'Broker';
const customerType = user.customerSubType ? this.selectOptions.getCustomerSubType(user.customerSubType) : 'Business';
return `${name} - ${customerType} broker profile photo`;
}
/**
* Generate alt text for company logos
* Format: "Company Name company logo" or "Owner Name company logo"
* Example: "ABC Realty company logo"
*/
generateCompanyLogoAlt(companyName?: string, ownerName?: string): string {
const name = companyName || ownerName || 'Company';
return `${name} company logo`;
}
/**
* Generate alt text for listing card logo images
* Includes business name and location for context
*/
generateListingCardLogoAlt(listing: any): string {
const location = this.getLocationString(listing.location);
return `${listing.title} - Business for sale in ${location}`;
}
/**
* Generate alt text for property images in detail view
* Format: "Title - Property Type image (index)"
* Example: "Downtown Office Space - Office building image 1 of 5"
*/
generatePropertyImageAlt(title: string, propertyType: string, imageIndex?: number, totalImages?: number): string {
let alt = `${title} - ${propertyType}`;
if (imageIndex !== undefined && totalImages !== undefined && totalImages > 1) {
alt += ` image ${imageIndex + 1} of ${totalImages}`;
} else {
alt += ' image';
}
return alt;
}
/**
* Generate alt text for business images in detail view
* Format: "Title - Business Type image (index)"
*/
generateBusinessImageAlt(title: string, businessType: string, imageIndex?: number, totalImages?: number): string {
let alt = `${title} - ${businessType}`;
if (imageIndex !== undefined && totalImages !== undefined && totalImages > 1) {
alt += ` image ${imageIndex + 1} of ${totalImages}`;
} else {
alt += ' image';
}
return alt;
}
/**
* Helper: Get location string from location object
* Returns: "City, STATE" or "County, STATE" or "STATE"
*/
private getLocationString(location: any): string {
if (!location) return 'United States';
const city = location.name || location.county;
const state = location.state || '';
if (city && state) {
return `${city}, ${state}`;
} else if (state) {
return this.selectOptions.getState(state) || state;
}
return 'United States';
}
}
import { Injectable, inject } from '@angular/core';
import { SelectOptionsService } from './select-options.service';
/**
* Service for generating SEO-optimized alt text for images
* Ensures consistent, keyword-rich alt text across the application
*/
@Injectable({
providedIn: 'root'
})
export class AltTextService {
private selectOptions = inject(SelectOptionsService);
/**
* Generate alt text for business listing images
* Format: "Business Name - Business Type for sale in City, State"
* Example: "Italian Restaurant - Restaurant for sale in Austin, TX"
*/
generateBusinessListingAlt(listing: any): string {
const location = this.getLocationString(listing.location);
const businessType = listing.type ? this.selectOptions.getBusiness(listing.type) : 'Business';
return `${listing.title} - ${businessType} for sale in ${location}`;
}
/**
* Generate alt text for commercial property listing images
* Format: "Property Type for sale - Title in City, State"
* Example: "Retail Space for sale - Downtown storefront in Miami, FL"
*/
generatePropertyListingAlt(listing: any): string {
const location = this.getLocationString(listing.location);
const propertyType = listing.type ? this.selectOptions.getCommercialProperty(listing.type) : 'Commercial Property';
return `${propertyType} for sale - ${listing.title} in ${location}`;
}
/**
* Generate alt text for broker/user profile photos
* Format: "First Last - Customer Type broker profile photo"
* Example: "John Smith - Business broker profile photo"
*/
generateBrokerProfileAlt(user: any): string {
const name = `${user.firstname || ''} ${user.lastname || ''}`.trim() || 'Broker';
const customerType = user.customerSubType ? this.selectOptions.getCustomerSubType(user.customerSubType) : 'Business';
return `${name} - ${customerType} broker profile photo`;
}
/**
* Generate alt text for company logos
* Format: "Company Name company logo" or "Owner Name company logo"
* Example: "ABC Realty company logo"
*/
generateCompanyLogoAlt(companyName?: string, ownerName?: string): string {
const name = companyName || ownerName || 'Company';
return `${name} company logo`;
}
/**
* Generate alt text for listing card logo images
* Includes business name and location for context
*/
generateListingCardLogoAlt(listing: any): string {
const location = this.getLocationString(listing.location);
return `${listing.title} - Business for sale in ${location}`;
}
/**
* Generate alt text for property images in detail view
* Format: "Title - Property Type image (index)"
* Example: "Downtown Office Space - Office building image 1 of 5"
*/
generatePropertyImageAlt(title: string, propertyType: string, imageIndex?: number, totalImages?: number): string {
let alt = `${title} - ${propertyType}`;
if (imageIndex !== undefined && totalImages !== undefined && totalImages > 1) {
alt += ` image ${imageIndex + 1} of ${totalImages}`;
} else {
alt += ' image';
}
return alt;
}
/**
* Generate alt text for business images in detail view
* Format: "Title - Business Type image (index)"
*/
generateBusinessImageAlt(title: string, businessType: string, imageIndex?: number, totalImages?: number): string {
let alt = `${title} - ${businessType}`;
if (imageIndex !== undefined && totalImages !== undefined && totalImages > 1) {
alt += ` image ${imageIndex + 1} of ${totalImages}`;
} else {
alt += ' image';
}
return alt;
}
/**
* Helper: Get location string from location object
* Returns: "City, STATE" or "County, STATE" or "STATE"
*/
private getLocationString(location: any): string {
if (!location) return 'United States';
const city = location.name || location.county;
const state = location.state || '';
if (city && state) {
return `${city}, ${state}`;
} else if (state) {
return this.selectOptions.getState(state) || state;
}
return 'United States';
}
}

View File

@@ -1,42 +1,42 @@
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { lastValueFrom } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { EventTypeEnum, ListingEvent } from '../../../../bizmatch-server/src/models/db.model';
import { LogMessage } from '../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../environments/environment';
import { GeoService } from './geo.service';
@Injectable({
providedIn: 'root',
})
export class AuditService {
private apiBaseUrl = environment.apiBaseUrl;
private apiKey = environment.ipinfo_token;
constructor(private http: HttpClient, private geoService: GeoService) {}
async log(message: LogMessage): Promise<void> {
lastValueFrom(this.http.post(`${this.apiBaseUrl}/bizmatch/log`, message));
}
async createEvent(id: string, eventType: EventTypeEnum, userId: string, additionalData?): Promise<void> {
const ipInfo = await this.geoService.getIpInfo();
const [latitude, longitude] = ipInfo.loc ? ipInfo.loc.split(',') : [null, null]; //.map(Number);
const listingEvent: ListingEvent = {
id: uuidv4(),
listingId: id,
eventType,
eventTimestamp: new Date(),
userAgent: navigator.userAgent,
email: userId,
userIp: ipInfo.ip,
locationCountry: ipInfo.country,
locationCity: ipInfo.city,
locationLat: latitude,
locationLng: longitude,
additionalData,
};
let headers = new HttpHeaders().set('X-Hide-Loading', 'true');
lastValueFrom(this.http.post(`${this.apiBaseUrl}/bizmatch/event`, listingEvent, { headers }));
}
}
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { lastValueFrom } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
import { EventTypeEnum, ListingEvent } from '../../../../bizmatch-server/src/models/db.model';
import { LogMessage } from '../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../environments/environment';
import { GeoService } from './geo.service';
@Injectable({
providedIn: 'root',
})
export class AuditService {
private apiBaseUrl = environment.apiBaseUrl;
private apiKey = environment.ipinfo_token;
constructor(private http: HttpClient, private geoService: GeoService) {}
async log(message: LogMessage): Promise<void> {
lastValueFrom(this.http.post(`${this.apiBaseUrl}/bizmatch/log`, message));
}
async createEvent(id: string, eventType: EventTypeEnum, userId: string, additionalData?): Promise<void> {
const ipInfo = await this.geoService.getIpInfo();
const [latitude, longitude] = ipInfo.loc ? ipInfo.loc.split(',') : [null, null]; //.map(Number);
const listingEvent: ListingEvent = {
id: uuidv4(),
listingId: id,
eventType,
eventTimestamp: new Date(),
userAgent: navigator.userAgent,
email: userId,
userIp: ipInfo.ip,
locationCountry: ipInfo.country,
locationCity: ipInfo.city,
locationLat: latitude,
locationLng: longitude,
additionalData,
};
let headers = new HttpHeaders().set('X-Hide-Loading', 'true');
lastValueFrom(this.http.post(`${this.apiBaseUrl}/bizmatch/event`, listingEvent, { headers }));
}
}

View File

@@ -1,356 +1,356 @@
// auth.service.ts
import { isPlatformBrowser } from '@angular/common';
import { HttpClient, HttpBackend, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable, inject, PLATFORM_ID } from '@angular/core';
import { FirebaseApp } from '@angular/fire/app';
import { GoogleAuthProvider, UserCredential, createUserWithEmailAndPassword, getAuth, signInWithCustomToken, signInWithEmailAndPassword, signInWithPopup } from 'firebase/auth';
import { BehaviorSubject, Observable, catchError, firstValueFrom, map, of, shareReplay, take, tap } from 'rxjs';
import { environment } from '../../environments/environment';
import { MailService } from './mail.service';
export type UserRole = 'admin' | 'pro' | 'guest';
@Injectable({
providedIn: 'root',
})
export class AuthService {
private app = inject(FirebaseApp);
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
private auth = this.isBrowser ? getAuth(this.app) : null;
private http = new HttpClient(inject(HttpBackend));
private mailService = inject(MailService);
// Add a BehaviorSubject to track the current user role
private userRoleSubject = new BehaviorSubject<UserRole | null>(null);
public userRole$ = this.userRoleSubject.asObservable();
// Referenz für den gecachten API-Aufruf
private cachedUserRole$: Observable<UserRole | null> | null = null;
// Zeitraum in ms, nach dem der Cache zurückgesetzt werden soll (z.B. 5 Minuten)
private cacheDuration = 5 * 60 * 1000;
private lastCacheTime = 0;
constructor() {
// Load role from token when service is initialized
this.loadRoleFromToken();
}
// Helper methods for localStorage access (only in browser)
private setLocalStorageItem(key: string, value: string): void {
if (this.isBrowser) {
localStorage.setItem(key, value);
}
}
private getLocalStorageItem(key: string): string | null {
if (this.isBrowser) {
return localStorage.getItem(key);
}
return null;
}
private removeLocalStorageItem(key: string): void {
if (this.isBrowser) {
localStorage.removeItem(key);
}
}
private loadRoleFromToken(): void {
this.getToken().then(token => {
if (token) {
const role = this.extractRoleFromToken(token);
this.userRoleSubject.next(role);
} else {
this.userRoleSubject.next(null);
}
});
}
private extractRoleFromToken(token: string): UserRole | null {
try {
const payloadBase64 = token.split('.')[1];
const payloadJson = atob(payloadBase64.replace(/-/g, '+').replace(/_/g, '/'));
const payload = JSON.parse(payloadJson);
return (payload.role as UserRole) || null;
} catch (e) {
return null;
}
}
// Registrierung mit Email und Passwort
async registerWithEmail(email: string, password: string): Promise<UserCredential> {
if (!this.isBrowser || !this.auth) {
throw new Error('Auth is only available in browser context');
}
// Bestimmen der aktuellen Umgebung/Domain für die Verifizierungs-URL
let verificationUrl = 'https://www.bizmatch.net/email-authorized';
// Prüfen der aktuellen Umgebung basierend auf dem Host (nur im Browser)
if (this.isBrowser) {
const currentHost = window.location.hostname;
if (currentHost.includes('localhost')) {
verificationUrl = 'http://localhost:4200/email-authorized';
} else if (currentHost.includes('dev.bizmatch.net')) {
verificationUrl = 'https://dev.bizmatch.net/email-authorized';
} else {
verificationUrl = 'https://www.bizmatch.net/email-authorized';
}
}
// ActionCode-Einstellungen mit der dynamischen URL
const actionCodeSettings = {
url: `${verificationUrl}?email=${email}`,
handleCodeInApp: true,
};
// Benutzer erstellen
const userCredential = await createUserWithEmailAndPassword(this.auth, email, password);
// E-Mail-Verifizierung mit den angepassten ActionCode-Einstellungen senden
if (userCredential.user) {
//await sendEmailVerification(userCredential.user, actionCodeSettings);
this.mailService.sendVerificationEmail(userCredential.user.email).subscribe({
next: () => {
console.log('Verification email sent successfully');
// Erfolgsmeldung anzeigen
},
error: error => {
console.error('Error sending verification email', error);
// Fehlermeldung anzeigen
},
});
}
// const token = await userCredential.user.getIdToken();
// this.setLocalStorageItem('authToken', token);
// this.setLocalStorageItem('refreshToken', userCredential.user.refreshToken);
// if (userCredential.user.photoURL) {
// this.setLocalStorageItem('photoURL', userCredential.user.photoURL);
// }
return userCredential;
}
// Login mit Email und Passwort
loginWithEmail(email: string, password: string): Promise<UserCredential> {
if (!this.isBrowser || !this.auth) {
throw new Error('Auth is only available in browser context');
}
return signInWithEmailAndPassword(this.auth, email, password).then(async userCredential => {
if (userCredential.user) {
const token = await userCredential.user.getIdToken();
this.setLocalStorageItem('authToken', token);
this.setLocalStorageItem('refreshToken', userCredential.user.refreshToken);
if (userCredential.user.photoURL) {
this.setLocalStorageItem('photoURL', userCredential.user.photoURL);
}
this.loadRoleFromToken();
}
return userCredential;
});
}
// Login mit Google
loginWithGoogle(): Promise<UserCredential> {
if (!this.isBrowser || !this.auth) {
throw new Error('Auth is only available in browser context');
}
const provider = new GoogleAuthProvider();
return signInWithPopup(this.auth, provider).then(async userCredential => {
if (userCredential.user) {
const token = await userCredential.user.getIdToken();
this.setLocalStorageItem('authToken', token);
this.setLocalStorageItem('refreshToken', userCredential.user.refreshToken);
if (userCredential.user.photoURL) {
this.setLocalStorageItem('photoURL', userCredential.user.photoURL);
}
this.loadRoleFromToken();
}
return userCredential;
});
}
// Logout: Token, RefreshToken und photoURL entfernen
logout(): Promise<void> {
this.removeLocalStorageItem('authToken');
this.removeLocalStorageItem('refreshToken');
this.removeLocalStorageItem('photoURL');
this.clearRoleCache();
this.userRoleSubject.next(null);
if (this.auth) {
return this.auth.signOut();
}
return Promise.resolve();
}
isAdmin(): Observable<boolean> {
return this.userRole$.pipe(
map(role => role === 'admin'),
);
}
// Get current user's role from the server with caching
getUserRole(): Observable<UserRole | null> {
const now = Date.now();
// Cache zurücksetzen, wenn die Caching-Zeit abgelaufen ist oder kein Cache existiert
if (!this.cachedUserRole$ || now - this.lastCacheTime > this.cacheDuration) {
if (!this.getLocalStorageItem('authToken')) {
return of(null);
}
this.lastCacheTime = now;
let headers = new HttpHeaders().set('X-Hide-Loading', 'true').set('Accept-Language', 'en-US');
this.cachedUserRole$ = this.http.get<{ role: UserRole | null }>(`${environment.apiBaseUrl}/bizmatch/auth/me/role`, { headers }).pipe(
map(response => response.role),
tap(role => this.userRoleSubject.next(role)),
catchError(error => {
console.error('Error fetching user role', error);
return of(null);
}),
// Cache für mehrere Subscriber und behalte den letzten Wert
// Der Parameter 1 gibt an, dass der letzte Wert gecacht werden soll
// refCount: false bedeutet, dass der Cache nicht zurückgesetzt wird, wenn keine Subscriber mehr da sind
shareReplay({ bufferSize: 1, refCount: false }),
);
}
return this.cachedUserRole$;
}
clearRoleCache(): void {
this.cachedUserRole$ = null;
this.lastCacheTime = 0;
}
// Check if user has a specific role
hasRole(role: UserRole): Observable<boolean> {
return this.userRole$.pipe(
map(userRole => {
if (role === 'guest') {
// Any authenticated user can access guest features
return userRole !== null;
} else if (role === 'pro') {
// Both pro and admin can access pro features
return userRole === 'pro' || userRole === 'admin';
} else if (role === 'admin') {
// Only admin can access admin features
return userRole === 'admin';
}
return false;
}),
);
}
// Force refresh the token to get updated custom claims
async refreshUserClaims(): Promise<void> {
this.clearRoleCache();
if (this.auth && this.auth.currentUser) {
await this.auth.currentUser.getIdToken(true);
const token = await this.auth.currentUser.getIdToken();
this.setLocalStorageItem('authToken', token);
this.loadRoleFromToken();
}
}
// Prüft, ob ein Token noch gültig ist (über die "exp"-Eigenschaft)
private isTokenValid(token: string): boolean {
try {
const payloadBase64 = token.split('.')[1];
const payloadJson = atob(payloadBase64.replace(/-/g, '+').replace(/_/g, '/'));
const payload = JSON.parse(payloadJson);
const exp = payload.exp;
const now = Math.floor(Date.now() / 1000);
return exp > now;
} catch (e) {
return false;
}
}
private isEMailVerified(token: string): boolean {
try {
const payloadBase64 = token.split('.')[1];
const payloadJson = atob(payloadBase64.replace(/-/g, '+').replace(/_/g, '/'));
const payload = JSON.parse(payloadJson);
return payload.email_verified;
} catch (e) {
return false;
}
}
// Versucht, mit dem RefreshToken einen neuen Access Token zu erhalten
async refreshToken(): Promise<string | null> {
const storedRefreshToken = this.getLocalStorageItem('refreshToken');
// SSR protection: refreshToken should only run in browser
if (!this.isBrowser) {
return null;
}
if (!storedRefreshToken) {
return null;
}
const apiKey = environment.firebaseConfig.apiKey; // Stelle sicher, dass dieser Wert in Deiner environment.ts gesetzt ist
const url = `https://securetoken.googleapis.com/v1/token?key=${apiKey}`;
const body = new HttpParams().set('grant_type', 'refresh_token').set('refresh_token', storedRefreshToken);
const headers = new HttpHeaders({ 'Content-Type': 'application/x-www-form-urlencoded' });
try {
const response: any = await firstValueFrom(this.http.post(url, body.toString(), { headers }));
// response enthält z.B. id_token, refresh_token, expires_in etc.
const newToken = response.id_token;
const newRefreshToken = response.refresh_token;
this.setLocalStorageItem('authToken', newToken);
this.setLocalStorageItem('refreshToken', newRefreshToken);
return newToken;
} catch (error) {
console.error('Error refreshing token:', error);
return null;
}
}
/**
* Gibt einen gültigen Token zurück.
* Falls der gespeicherte Token noch gültig ist, wird er zurückgegeben.
* Ansonsten wird versucht, einen neuen Token mit dem RefreshToken zu holen.
* Ist auch das nicht möglich, wird null zurückgegeben.
*/
async getToken(): Promise<string | null> {
const token = this.getLocalStorageItem('authToken');
// SSR protection: return null on server
if (!this.isBrowser) {
return null;
}
if (token && !this.isEMailVerified(token)) {
return null;
} else if (token && this.isTokenValid(token) && this.isEMailVerified(token)) {
return token;
} else {
return await this.refreshToken();
}
}
// Add this new method to sign in with a custom token
async signInWithCustomToken(token: string): Promise<void> {
if (!this.isBrowser || !this.auth) {
throw new Error('Auth is only available in browser context');
}
try {
// Sign in to Firebase with the custom token
const userCredential = await signInWithCustomToken(this.auth, token);
// Store the authentication token
if (userCredential.user) {
const idToken = await userCredential.user.getIdToken();
this.setLocalStorageItem('authToken', idToken);
this.setLocalStorageItem('refreshToken', userCredential.user.refreshToken);
if (userCredential.user.photoURL) {
this.setLocalStorageItem('photoURL', userCredential.user.photoURL);
}
// Load user role from the token
this.loadRoleFromToken();
}
return;
} catch (error) {
console.error('Error signing in with custom token:', error);
throw error;
}
}
}
// auth.service.ts
import { isPlatformBrowser } from '@angular/common';
import { HttpClient, HttpBackend, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable, inject, PLATFORM_ID } from '@angular/core';
import { FirebaseApp } from '@angular/fire/app';
import { GoogleAuthProvider, UserCredential, createUserWithEmailAndPassword, getAuth, signInWithCustomToken, signInWithEmailAndPassword, signInWithPopup } from 'firebase/auth';
import { BehaviorSubject, Observable, catchError, firstValueFrom, map, of, shareReplay, take, tap } from 'rxjs';
import { environment } from '../../environments/environment';
import { MailService } from './mail.service';
export type UserRole = 'admin' | 'pro' | 'guest';
@Injectable({
providedIn: 'root',
})
export class AuthService {
private app = inject(FirebaseApp);
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
private auth = this.isBrowser ? getAuth(this.app) : null;
private http = new HttpClient(inject(HttpBackend));
private mailService = inject(MailService);
// Add a BehaviorSubject to track the current user role
private userRoleSubject = new BehaviorSubject<UserRole | null>(null);
public userRole$ = this.userRoleSubject.asObservable();
// Referenz für den gecachten API-Aufruf
private cachedUserRole$: Observable<UserRole | null> | null = null;
// Zeitraum in ms, nach dem der Cache zurückgesetzt werden soll (z.B. 5 Minuten)
private cacheDuration = 5 * 60 * 1000;
private lastCacheTime = 0;
constructor() {
// Load role from token when service is initialized
this.loadRoleFromToken();
}
// Helper methods for localStorage access (only in browser)
private setLocalStorageItem(key: string, value: string): void {
if (this.isBrowser) {
localStorage.setItem(key, value);
}
}
private getLocalStorageItem(key: string): string | null {
if (this.isBrowser) {
return localStorage.getItem(key);
}
return null;
}
private removeLocalStorageItem(key: string): void {
if (this.isBrowser) {
localStorage.removeItem(key);
}
}
private loadRoleFromToken(): void {
this.getToken().then(token => {
if (token) {
const role = this.extractRoleFromToken(token);
this.userRoleSubject.next(role);
} else {
this.userRoleSubject.next(null);
}
});
}
private extractRoleFromToken(token: string): UserRole | null {
try {
const payloadBase64 = token.split('.')[1];
const payloadJson = atob(payloadBase64.replace(/-/g, '+').replace(/_/g, '/'));
const payload = JSON.parse(payloadJson);
return (payload.role as UserRole) || null;
} catch (e) {
return null;
}
}
// Registrierung mit Email und Passwort
async registerWithEmail(email: string, password: string): Promise<UserCredential> {
if (!this.isBrowser || !this.auth) {
throw new Error('Auth is only available in browser context');
}
// Bestimmen der aktuellen Umgebung/Domain für die Verifizierungs-URL
let verificationUrl = 'https://www.bizmatch.net/email-authorized';
// Prüfen der aktuellen Umgebung basierend auf dem Host (nur im Browser)
if (this.isBrowser) {
const currentHost = window.location.hostname;
if (currentHost.includes('localhost')) {
verificationUrl = 'http://localhost:4200/email-authorized';
} else if (currentHost.includes('dev.bizmatch.net')) {
verificationUrl = 'https://dev.bizmatch.net/email-authorized';
} else {
verificationUrl = 'https://www.bizmatch.net/email-authorized';
}
}
// ActionCode-Einstellungen mit der dynamischen URL
const actionCodeSettings = {
url: `${verificationUrl}?email=${email}`,
handleCodeInApp: true,
};
// Benutzer erstellen
const userCredential = await createUserWithEmailAndPassword(this.auth, email, password);
// E-Mail-Verifizierung mit den angepassten ActionCode-Einstellungen senden
if (userCredential.user) {
//await sendEmailVerification(userCredential.user, actionCodeSettings);
this.mailService.sendVerificationEmail(userCredential.user.email).subscribe({
next: () => {
console.log('Verification email sent successfully');
// Erfolgsmeldung anzeigen
},
error: error => {
console.error('Error sending verification email', error);
// Fehlermeldung anzeigen
},
});
}
// const token = await userCredential.user.getIdToken();
// this.setLocalStorageItem('authToken', token);
// this.setLocalStorageItem('refreshToken', userCredential.user.refreshToken);
// if (userCredential.user.photoURL) {
// this.setLocalStorageItem('photoURL', userCredential.user.photoURL);
// }
return userCredential;
}
// Login mit Email und Passwort
loginWithEmail(email: string, password: string): Promise<UserCredential> {
if (!this.isBrowser || !this.auth) {
throw new Error('Auth is only available in browser context');
}
return signInWithEmailAndPassword(this.auth, email, password).then(async userCredential => {
if (userCredential.user) {
const token = await userCredential.user.getIdToken();
this.setLocalStorageItem('authToken', token);
this.setLocalStorageItem('refreshToken', userCredential.user.refreshToken);
if (userCredential.user.photoURL) {
this.setLocalStorageItem('photoURL', userCredential.user.photoURL);
}
this.loadRoleFromToken();
}
return userCredential;
});
}
// Login mit Google
loginWithGoogle(): Promise<UserCredential> {
if (!this.isBrowser || !this.auth) {
throw new Error('Auth is only available in browser context');
}
const provider = new GoogleAuthProvider();
return signInWithPopup(this.auth, provider).then(async userCredential => {
if (userCredential.user) {
const token = await userCredential.user.getIdToken();
this.setLocalStorageItem('authToken', token);
this.setLocalStorageItem('refreshToken', userCredential.user.refreshToken);
if (userCredential.user.photoURL) {
this.setLocalStorageItem('photoURL', userCredential.user.photoURL);
}
this.loadRoleFromToken();
}
return userCredential;
});
}
// Logout: Token, RefreshToken und photoURL entfernen
logout(): Promise<void> {
this.removeLocalStorageItem('authToken');
this.removeLocalStorageItem('refreshToken');
this.removeLocalStorageItem('photoURL');
this.clearRoleCache();
this.userRoleSubject.next(null);
if (this.auth) {
return this.auth.signOut();
}
return Promise.resolve();
}
isAdmin(): Observable<boolean> {
return this.userRole$.pipe(
map(role => role === 'admin'),
);
}
// Get current user's role from the server with caching
getUserRole(): Observable<UserRole | null> {
const now = Date.now();
// Cache zurücksetzen, wenn die Caching-Zeit abgelaufen ist oder kein Cache existiert
if (!this.cachedUserRole$ || now - this.lastCacheTime > this.cacheDuration) {
if (!this.getLocalStorageItem('authToken')) {
return of(null);
}
this.lastCacheTime = now;
let headers = new HttpHeaders().set('X-Hide-Loading', 'true').set('Accept-Language', 'en-US');
this.cachedUserRole$ = this.http.get<{ role: UserRole | null }>(`${environment.apiBaseUrl}/bizmatch/auth/me/role`, { headers }).pipe(
map(response => response.role),
tap(role => this.userRoleSubject.next(role)),
catchError(error => {
console.error('Error fetching user role', error);
return of(null);
}),
// Cache für mehrere Subscriber und behalte den letzten Wert
// Der Parameter 1 gibt an, dass der letzte Wert gecacht werden soll
// refCount: false bedeutet, dass der Cache nicht zurückgesetzt wird, wenn keine Subscriber mehr da sind
shareReplay({ bufferSize: 1, refCount: false }),
);
}
return this.cachedUserRole$;
}
clearRoleCache(): void {
this.cachedUserRole$ = null;
this.lastCacheTime = 0;
}
// Check if user has a specific role
hasRole(role: UserRole): Observable<boolean> {
return this.userRole$.pipe(
map(userRole => {
if (role === 'guest') {
// Any authenticated user can access guest features
return userRole !== null;
} else if (role === 'pro') {
// Both pro and admin can access pro features
return userRole === 'pro' || userRole === 'admin';
} else if (role === 'admin') {
// Only admin can access admin features
return userRole === 'admin';
}
return false;
}),
);
}
// Force refresh the token to get updated custom claims
async refreshUserClaims(): Promise<void> {
this.clearRoleCache();
if (this.auth && this.auth.currentUser) {
await this.auth.currentUser.getIdToken(true);
const token = await this.auth.currentUser.getIdToken();
this.setLocalStorageItem('authToken', token);
this.loadRoleFromToken();
}
}
// Prüft, ob ein Token noch gültig ist (über die "exp"-Eigenschaft)
private isTokenValid(token: string): boolean {
try {
const payloadBase64 = token.split('.')[1];
const payloadJson = atob(payloadBase64.replace(/-/g, '+').replace(/_/g, '/'));
const payload = JSON.parse(payloadJson);
const exp = payload.exp;
const now = Math.floor(Date.now() / 1000);
return exp > now;
} catch (e) {
return false;
}
}
private isEMailVerified(token: string): boolean {
try {
const payloadBase64 = token.split('.')[1];
const payloadJson = atob(payloadBase64.replace(/-/g, '+').replace(/_/g, '/'));
const payload = JSON.parse(payloadJson);
return payload.email_verified;
} catch (e) {
return false;
}
}
// Versucht, mit dem RefreshToken einen neuen Access Token zu erhalten
async refreshToken(): Promise<string | null> {
const storedRefreshToken = this.getLocalStorageItem('refreshToken');
// SSR protection: refreshToken should only run in browser
if (!this.isBrowser) {
return null;
}
if (!storedRefreshToken) {
return null;
}
const apiKey = environment.firebaseConfig.apiKey; // Stelle sicher, dass dieser Wert in Deiner environment.ts gesetzt ist
const url = `https://securetoken.googleapis.com/v1/token?key=${apiKey}`;
const body = new HttpParams().set('grant_type', 'refresh_token').set('refresh_token', storedRefreshToken);
const headers = new HttpHeaders({ 'Content-Type': 'application/x-www-form-urlencoded' });
try {
const response: any = await firstValueFrom(this.http.post(url, body.toString(), { headers }));
// response enthält z.B. id_token, refresh_token, expires_in etc.
const newToken = response.id_token;
const newRefreshToken = response.refresh_token;
this.setLocalStorageItem('authToken', newToken);
this.setLocalStorageItem('refreshToken', newRefreshToken);
return newToken;
} catch (error) {
console.error('Error refreshing token:', error);
return null;
}
}
/**
* Gibt einen gültigen Token zurück.
* Falls der gespeicherte Token noch gültig ist, wird er zurückgegeben.
* Ansonsten wird versucht, einen neuen Token mit dem RefreshToken zu holen.
* Ist auch das nicht möglich, wird null zurückgegeben.
*/
async getToken(): Promise<string | null> {
const token = this.getLocalStorageItem('authToken');
// SSR protection: return null on server
if (!this.isBrowser) {
return null;
}
if (token && !this.isEMailVerified(token)) {
return null;
} else if (token && this.isTokenValid(token) && this.isEMailVerified(token)) {
return token;
} else {
return await this.refreshToken();
}
}
// Add this new method to sign in with a custom token
async signInWithCustomToken(token: string): Promise<void> {
if (!this.isBrowser || !this.auth) {
throw new Error('Auth is only available in browser context');
}
try {
// Sign in to Firebase with the custom token
const userCredential = await signInWithCustomToken(this.auth, token);
// Store the authentication token
if (userCredential.user) {
const idToken = await userCredential.user.getIdToken();
this.setLocalStorageItem('authToken', idToken);
this.setLocalStorageItem('refreshToken', userCredential.user.refreshToken);
if (userCredential.user.photoURL) {
this.setLocalStorageItem('photoURL', userCredential.user.photoURL);
}
// Load user role from the token
this.loadRoleFromToken();
}
return;
} catch (error) {
console.error('Error signing in with custom token:', error);
throw error;
}
}
}

View File

@@ -1,254 +1,254 @@
import { isPlatformBrowser } from '@angular/common';
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { SortByOptions } from '../../../../bizmatch-server/src/models/db.model';
import { BusinessListingCriteria, CommercialPropertyListingCriteria, UserListingCriteria } from '../../../../bizmatch-server/src/models/main.model';
type CriteriaType = BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
type ListingType = 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
interface FilterState {
businessListings: {
criteria: BusinessListingCriteria;
sortBy: SortByOptions | null;
};
commercialPropertyListings: {
criteria: CommercialPropertyListingCriteria;
sortBy: SortByOptions | null;
};
brokerListings: {
criteria: UserListingCriteria;
sortBy: SortByOptions | null;
};
}
@Injectable({
providedIn: 'root',
})
export class FilterStateService {
private state: FilterState;
private stateSubjects: Map<ListingType, BehaviorSubject<any>> = new Map();
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
constructor() {
// Initialize state from sessionStorage or with defaults
this.state = this.loadStateFromStorage();
// Create BehaviorSubjects for each listing type
this.stateSubjects.set('businessListings', new BehaviorSubject(this.state.businessListings));
this.stateSubjects.set('commercialPropertyListings', new BehaviorSubject(this.state.commercialPropertyListings));
this.stateSubjects.set('brokerListings', new BehaviorSubject(this.state.brokerListings));
}
// Get observable for specific listing type
getState$(type: ListingType): Observable<any> {
return this.stateSubjects.get(type)!.asObservable();
}
// Get current criteria
getCriteria(type: ListingType): CriteriaType {
return { ...this.state[type].criteria };
}
// Update criteria
updateCriteria(type: ListingType, criteria: Partial<CriteriaType>): void {
// Type-safe update basierend auf dem Listing-Typ
if (type === 'businessListings') {
this.state.businessListings.criteria = {
...this.state.businessListings.criteria,
...criteria,
} as BusinessListingCriteria;
} else if (type === 'commercialPropertyListings') {
this.state.commercialPropertyListings.criteria = {
...this.state.commercialPropertyListings.criteria,
...criteria,
} as CommercialPropertyListingCriteria;
} else if (type === 'brokerListings') {
this.state.brokerListings.criteria = {
...this.state.brokerListings.criteria,
...criteria,
} as UserListingCriteria;
}
this.saveToStorage(type);
this.emitState(type);
}
// Set complete criteria (for reset operations)
setCriteria(type: ListingType, criteria: CriteriaType): void {
if (type === 'businessListings') {
this.state.businessListings.criteria = criteria as BusinessListingCriteria;
} else if (type === 'commercialPropertyListings') {
this.state.commercialPropertyListings.criteria = criteria as CommercialPropertyListingCriteria;
} else if (type === 'brokerListings') {
this.state.brokerListings.criteria = criteria as UserListingCriteria;
}
this.saveToStorage(type);
this.emitState(type);
}
// Get current sortBy
getSortBy(type: ListingType): SortByOptions | null {
return this.state[type].sortBy;
}
// Update sortBy
updateSortBy(type: ListingType, sortBy: SortByOptions | null): void {
this.state[type].sortBy = sortBy;
this.saveSortByToStorage(type, sortBy);
this.emitState(type);
}
// Reset criteria to defaults
resetCriteria(type: ListingType): void {
if (type === 'businessListings') {
this.state.businessListings.criteria = this.createEmptyBusinessListingCriteria();
} else if (type === 'commercialPropertyListings') {
this.state.commercialPropertyListings.criteria = this.createEmptyCommercialPropertyListingCriteria();
} else if (type === 'brokerListings') {
this.state.brokerListings.criteria = this.createEmptyUserListingCriteria();
}
this.saveToStorage(type);
this.emitState(type);
}
// Clear all filters but keep sortBy
clearFilters(type: ListingType): void {
const sortBy = this.state[type].sortBy;
this.resetCriteria(type);
this.state[type].sortBy = sortBy;
this.emitState(type);
}
private emitState(type: ListingType): void {
this.stateSubjects.get(type)?.next({ ...this.state[type] });
}
private saveToStorage(type: ListingType): void {
if (!this.isBrowser) return;
sessionStorage.setItem(type, JSON.stringify(this.state[type].criteria));
}
private saveSortByToStorage(type: ListingType, sortBy: SortByOptions | null): void {
if (!this.isBrowser) return;
const sortByKey = type === 'businessListings' ? 'businessSortBy' : type === 'commercialPropertyListings' ? 'commercialSortBy' : 'professionalsSortBy';
if (sortBy) {
sessionStorage.setItem(sortByKey, sortBy);
} else {
sessionStorage.removeItem(sortByKey);
}
}
private loadStateFromStorage(): FilterState {
return {
businessListings: {
criteria: this.loadCriteriaFromStorage('businessListings') as BusinessListingCriteria,
sortBy: this.loadSortByFromStorage('businessSortBy'),
},
commercialPropertyListings: {
criteria: this.loadCriteriaFromStorage('commercialPropertyListings') as CommercialPropertyListingCriteria,
sortBy: this.loadSortByFromStorage('commercialSortBy'),
},
brokerListings: {
criteria: this.loadCriteriaFromStorage('brokerListings') as UserListingCriteria,
sortBy: this.loadSortByFromStorage('professionalsSortBy'),
},
};
}
private loadCriteriaFromStorage(key: ListingType): CriteriaType {
if (this.isBrowser) {
const stored = sessionStorage.getItem(key);
if (stored) {
return JSON.parse(stored);
}
}
switch (key) {
case 'businessListings':
return this.createEmptyBusinessListingCriteria();
case 'commercialPropertyListings':
return this.createEmptyCommercialPropertyListingCriteria();
case 'brokerListings':
return this.createEmptyUserListingCriteria();
}
}
private loadSortByFromStorage(key: string): SortByOptions | null {
if (!this.isBrowser) return null;
const stored = sessionStorage.getItem(key);
return stored && stored !== 'null' ? (stored as SortByOptions) : null;
}
// Helper methods to create empty criteria
private createEmptyBusinessListingCriteria(): BusinessListingCriteria {
return {
criteriaType: 'businessListings',
types: [],
state: null,
city: null,
radius: null,
searchType: 'exact' as const,
minPrice: null,
maxPrice: null,
minRevenue: null,
maxRevenue: null,
minCashFlow: null,
maxCashFlow: null,
minNumberEmployees: null,
maxNumberEmployees: null,
establishedMin: null,
brokerName: null,
title: null,
realEstateChecked: false,
leasedLocation: false,
franchiseResale: false,
email: null,
prompt: null,
page: 1,
start: 0,
length: 12,
};
}
private createEmptyCommercialPropertyListingCriteria(): CommercialPropertyListingCriteria {
return {
criteriaType: 'commercialPropertyListings',
types: [],
state: null,
city: null,
radius: null,
searchType: 'exact' as const,
minPrice: null,
maxPrice: null,
title: null,
brokerName: null,
prompt: null,
page: 1,
start: 0,
length: 12,
};
}
private createEmptyUserListingCriteria(): UserListingCriteria {
return {
criteriaType: 'brokerListings',
types: [],
state: null,
city: null,
radius: null,
searchType: 'exact' as const,
brokerName: null,
companyName: null,
counties: [],
prompt: null,
page: 1,
start: 0,
length: 12,
};
}
}
import { isPlatformBrowser } from '@angular/common';
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { SortByOptions } from '../../../../bizmatch-server/src/models/db.model';
import { BusinessListingCriteria, CommercialPropertyListingCriteria, UserListingCriteria } from '../../../../bizmatch-server/src/models/main.model';
type CriteriaType = BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
type ListingType = 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
interface FilterState {
businessListings: {
criteria: BusinessListingCriteria;
sortBy: SortByOptions | null;
};
commercialPropertyListings: {
criteria: CommercialPropertyListingCriteria;
sortBy: SortByOptions | null;
};
brokerListings: {
criteria: UserListingCriteria;
sortBy: SortByOptions | null;
};
}
@Injectable({
providedIn: 'root',
})
export class FilterStateService {
private state: FilterState;
private stateSubjects: Map<ListingType, BehaviorSubject<any>> = new Map();
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
constructor() {
// Initialize state from sessionStorage or with defaults
this.state = this.loadStateFromStorage();
// Create BehaviorSubjects for each listing type
this.stateSubjects.set('businessListings', new BehaviorSubject(this.state.businessListings));
this.stateSubjects.set('commercialPropertyListings', new BehaviorSubject(this.state.commercialPropertyListings));
this.stateSubjects.set('brokerListings', new BehaviorSubject(this.state.brokerListings));
}
// Get observable for specific listing type
getState$(type: ListingType): Observable<any> {
return this.stateSubjects.get(type)!.asObservable();
}
// Get current criteria
getCriteria(type: ListingType): CriteriaType {
return { ...this.state[type].criteria };
}
// Update criteria
updateCriteria(type: ListingType, criteria: Partial<CriteriaType>): void {
// Type-safe update basierend auf dem Listing-Typ
if (type === 'businessListings') {
this.state.businessListings.criteria = {
...this.state.businessListings.criteria,
...criteria,
} as BusinessListingCriteria;
} else if (type === 'commercialPropertyListings') {
this.state.commercialPropertyListings.criteria = {
...this.state.commercialPropertyListings.criteria,
...criteria,
} as CommercialPropertyListingCriteria;
} else if (type === 'brokerListings') {
this.state.brokerListings.criteria = {
...this.state.brokerListings.criteria,
...criteria,
} as UserListingCriteria;
}
this.saveToStorage(type);
this.emitState(type);
}
// Set complete criteria (for reset operations)
setCriteria(type: ListingType, criteria: CriteriaType): void {
if (type === 'businessListings') {
this.state.businessListings.criteria = criteria as BusinessListingCriteria;
} else if (type === 'commercialPropertyListings') {
this.state.commercialPropertyListings.criteria = criteria as CommercialPropertyListingCriteria;
} else if (type === 'brokerListings') {
this.state.brokerListings.criteria = criteria as UserListingCriteria;
}
this.saveToStorage(type);
this.emitState(type);
}
// Get current sortBy
getSortBy(type: ListingType): SortByOptions | null {
return this.state[type].sortBy;
}
// Update sortBy
updateSortBy(type: ListingType, sortBy: SortByOptions | null): void {
this.state[type].sortBy = sortBy;
this.saveSortByToStorage(type, sortBy);
this.emitState(type);
}
// Reset criteria to defaults
resetCriteria(type: ListingType): void {
if (type === 'businessListings') {
this.state.businessListings.criteria = this.createEmptyBusinessListingCriteria();
} else if (type === 'commercialPropertyListings') {
this.state.commercialPropertyListings.criteria = this.createEmptyCommercialPropertyListingCriteria();
} else if (type === 'brokerListings') {
this.state.brokerListings.criteria = this.createEmptyUserListingCriteria();
}
this.saveToStorage(type);
this.emitState(type);
}
// Clear all filters but keep sortBy
clearFilters(type: ListingType): void {
const sortBy = this.state[type].sortBy;
this.resetCriteria(type);
this.state[type].sortBy = sortBy;
this.emitState(type);
}
private emitState(type: ListingType): void {
this.stateSubjects.get(type)?.next({ ...this.state[type] });
}
private saveToStorage(type: ListingType): void {
if (!this.isBrowser) return;
sessionStorage.setItem(type, JSON.stringify(this.state[type].criteria));
}
private saveSortByToStorage(type: ListingType, sortBy: SortByOptions | null): void {
if (!this.isBrowser) return;
const sortByKey = type === 'businessListings' ? 'businessSortBy' : type === 'commercialPropertyListings' ? 'commercialSortBy' : 'professionalsSortBy';
if (sortBy) {
sessionStorage.setItem(sortByKey, sortBy);
} else {
sessionStorage.removeItem(sortByKey);
}
}
private loadStateFromStorage(): FilterState {
return {
businessListings: {
criteria: this.loadCriteriaFromStorage('businessListings') as BusinessListingCriteria,
sortBy: this.loadSortByFromStorage('businessSortBy'),
},
commercialPropertyListings: {
criteria: this.loadCriteriaFromStorage('commercialPropertyListings') as CommercialPropertyListingCriteria,
sortBy: this.loadSortByFromStorage('commercialSortBy'),
},
brokerListings: {
criteria: this.loadCriteriaFromStorage('brokerListings') as UserListingCriteria,
sortBy: this.loadSortByFromStorage('professionalsSortBy'),
},
};
}
private loadCriteriaFromStorage(key: ListingType): CriteriaType {
if (this.isBrowser) {
const stored = sessionStorage.getItem(key);
if (stored) {
return JSON.parse(stored);
}
}
switch (key) {
case 'businessListings':
return this.createEmptyBusinessListingCriteria();
case 'commercialPropertyListings':
return this.createEmptyCommercialPropertyListingCriteria();
case 'brokerListings':
return this.createEmptyUserListingCriteria();
}
}
private loadSortByFromStorage(key: string): SortByOptions | null {
if (!this.isBrowser) return null;
const stored = sessionStorage.getItem(key);
return stored && stored !== 'null' ? (stored as SortByOptions) : null;
}
// Helper methods to create empty criteria
private createEmptyBusinessListingCriteria(): BusinessListingCriteria {
return {
criteriaType: 'businessListings',
types: [],
state: null,
city: null,
radius: null,
searchType: 'exact' as const,
minPrice: null,
maxPrice: null,
minRevenue: null,
maxRevenue: null,
minCashFlow: null,
maxCashFlow: null,
minNumberEmployees: null,
maxNumberEmployees: null,
establishedMin: null,
brokerName: null,
title: null,
realEstateChecked: false,
leasedLocation: false,
franchiseResale: false,
email: null,
prompt: null,
page: 1,
start: 0,
length: 12,
};
}
private createEmptyCommercialPropertyListingCriteria(): CommercialPropertyListingCriteria {
return {
criteriaType: 'commercialPropertyListings',
types: [],
state: null,
city: null,
radius: null,
searchType: 'exact' as const,
minPrice: null,
maxPrice: null,
title: null,
brokerName: null,
prompt: null,
page: 1,
start: 0,
length: 12,
};
}
private createEmptyUserListingCriteria(): UserListingCriteria {
return {
criteriaType: 'brokerListings',
types: [],
state: null,
city: null,
radius: null,
searchType: 'exact' as const,
brokerName: null,
companyName: null,
counties: [],
prompt: null,
page: 1,
start: 0,
length: 12,
};
}
}

View File

@@ -1,170 +1,170 @@
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable, PLATFORM_ID, inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { lastValueFrom, Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { CityAndStateResult, CountyResult, GeoResult, IpInfo } from '../../../../bizmatch-server/src/models/main.model';
import { Place } from '../../../../bizmatch-server/src/models/server.model';
import { environment } from '../../environments/environment';
interface CachedBoundary {
data: any;
timestamp: number;
}
@Injectable({
providedIn: 'root',
})
export class GeoService {
private apiBaseUrl = environment.apiBaseUrl;
private baseUrl: string = 'https://nominatim.openstreetmap.org/search';
private fetchingData: Observable<IpInfo> | null = null;
private readonly storageKey = 'ipInfo';
private readonly boundaryStoragePrefix = 'nominatim_boundary_';
private readonly cacheExpiration = 30 * 24 * 60 * 60 * 1000; // 30 days in milliseconds
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
constructor(private http: HttpClient) {}
/**
* Get cached boundary data from localStorage
*/
private getCachedBoundary(cacheKey: string): any | null {
if (!this.isBrowser) return null;
try {
const cached = localStorage.getItem(this.boundaryStoragePrefix + cacheKey);
if (!cached) {
return null;
}
const cachedData: CachedBoundary = JSON.parse(cached);
const now = Date.now();
// Check if cache has expired
if (now - cachedData.timestamp > this.cacheExpiration) {
localStorage.removeItem(this.boundaryStoragePrefix + cacheKey);
return null;
}
return cachedData.data;
} catch (error) {
console.error('Error reading boundary cache:', error);
return null;
}
}
/**
* Save boundary data to localStorage
*/
private setCachedBoundary(cacheKey: string, data: any): void {
if (!this.isBrowser) return;
try {
const cachedData: CachedBoundary = {
data: data,
timestamp: Date.now()
};
localStorage.setItem(this.boundaryStoragePrefix + cacheKey, JSON.stringify(cachedData));
} catch (error) {
console.error('Error saving boundary cache:', error);
}
}
/**
* Clear all cached boundary data
*/
clearBoundaryCache(): void {
if (!this.isBrowser) return;
try {
const keys = Object.keys(localStorage);
keys.forEach(key => {
if (key.startsWith(this.boundaryStoragePrefix)) {
localStorage.removeItem(key);
}
});
} catch (error) {
console.error('Error clearing boundary cache:', error);
}
}
findCitiesStartingWith(prefix: string, state?: string): Observable<GeoResult[]> {
const stateString = state ? `/${state}` : '';
return this.http.get<GeoResult[]>(`${this.apiBaseUrl}/bizmatch/geo/${prefix}${stateString}`);
}
findCitiesAndStatesStartingWith(prefix: string): Observable<CityAndStateResult[]> {
return this.http.get<CityAndStateResult[]>(`${this.apiBaseUrl}/bizmatch/geo/citiesandstates/${prefix}`);
}
findCountiesStartingWith(prefix: string, states?: string[]): Observable<CountyResult[]> {
return this.http.post<CountyResult[]>(`${this.apiBaseUrl}/bizmatch/geo/counties`, { prefix, states });
}
findLocationStartingWith(prefix: string): Observable<Place[]> {
let headers = new HttpHeaders().set('X-Hide-Loading', 'true').set('Accept-Language', 'en-US');
return this.http.get(`${this.baseUrl}?q=${prefix},US&format=json&addressdetails=1&limit=5`, { headers }) as Observable<Place[]>;
}
getCityBoundary(cityName: string, state: string): Observable<any> {
const cacheKey = `city_${cityName}_${state}`.toLowerCase().replace(/\s+/g, '_');
// Check cache first
const cached = this.getCachedBoundary(cacheKey);
if (cached) {
return of(cached);
}
// If not in cache, fetch from API
const query = `${cityName}, ${state}, USA`;
let headers = new HttpHeaders().set('X-Hide-Loading', 'true').set('Accept-Language', 'en-US');
return this.http.get(`${this.baseUrl}?q=${encodeURIComponent(query)}&format=json&polygon_geojson=1&limit=1`, { headers }).pipe(
tap(data => this.setCachedBoundary(cacheKey, data))
);
}
getStateBoundary(state: string): Observable<any> {
const cacheKey = `state_${state}`.toLowerCase().replace(/\s+/g, '_');
// Check cache first
const cached = this.getCachedBoundary(cacheKey);
if (cached) {
return of(cached);
}
// If not in cache, fetch from API
const query = `${state}, USA`;
let headers = new HttpHeaders().set('X-Hide-Loading', 'true').set('Accept-Language', 'en-US');
return this.http.get(`${this.baseUrl}?q=${encodeURIComponent(query)}&format=json&polygon_geojson=1&limit=1&featuretype=state`, { headers }).pipe(
tap(data => this.setCachedBoundary(cacheKey, data))
);
}
private fetchIpAndGeoLocation(): Observable<IpInfo> {
return this.http.get<IpInfo>(`${this.apiBaseUrl}/bizmatch/geo/ipinfo/georesult/wysiwyg`);
}
async getIpInfo(): Promise<IpInfo | null> {
// Versuche zuerst, die Daten aus dem sessionStorage zu holen
if (this.isBrowser) {
const storedData = sessionStorage.getItem(this.storageKey);
if (storedData) {
return JSON.parse(storedData);
}
}
try {
// Wenn keine Daten im Storage, hole sie vom Server
const data = await lastValueFrom(this.http.get<IpInfo>(`${this.apiBaseUrl}/bizmatch/geo/ipinfo/georesult/wysiwyg`));
// Speichere die Daten im sessionStorage
if (this.isBrowser) {
sessionStorage.setItem(this.storageKey, JSON.stringify(data));
}
return data;
} catch (error) {
console.error('Error fetching IP info:', error);
return null;
}
}
}
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable, PLATFORM_ID, inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { lastValueFrom, Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { CityAndStateResult, CountyResult, GeoResult, IpInfo } from '../../../../bizmatch-server/src/models/main.model';
import { Place } from '../../../../bizmatch-server/src/models/server.model';
import { environment } from '../../environments/environment';
interface CachedBoundary {
data: any;
timestamp: number;
}
@Injectable({
providedIn: 'root',
})
export class GeoService {
private apiBaseUrl = environment.apiBaseUrl;
private baseUrl: string = 'https://nominatim.openstreetmap.org/search';
private fetchingData: Observable<IpInfo> | null = null;
private readonly storageKey = 'ipInfo';
private readonly boundaryStoragePrefix = 'nominatim_boundary_';
private readonly cacheExpiration = 30 * 24 * 60 * 60 * 1000; // 30 days in milliseconds
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
constructor(private http: HttpClient) {}
/**
* Get cached boundary data from localStorage
*/
private getCachedBoundary(cacheKey: string): any | null {
if (!this.isBrowser) return null;
try {
const cached = localStorage.getItem(this.boundaryStoragePrefix + cacheKey);
if (!cached) {
return null;
}
const cachedData: CachedBoundary = JSON.parse(cached);
const now = Date.now();
// Check if cache has expired
if (now - cachedData.timestamp > this.cacheExpiration) {
localStorage.removeItem(this.boundaryStoragePrefix + cacheKey);
return null;
}
return cachedData.data;
} catch (error) {
console.error('Error reading boundary cache:', error);
return null;
}
}
/**
* Save boundary data to localStorage
*/
private setCachedBoundary(cacheKey: string, data: any): void {
if (!this.isBrowser) return;
try {
const cachedData: CachedBoundary = {
data: data,
timestamp: Date.now()
};
localStorage.setItem(this.boundaryStoragePrefix + cacheKey, JSON.stringify(cachedData));
} catch (error) {
console.error('Error saving boundary cache:', error);
}
}
/**
* Clear all cached boundary data
*/
clearBoundaryCache(): void {
if (!this.isBrowser) return;
try {
const keys = Object.keys(localStorage);
keys.forEach(key => {
if (key.startsWith(this.boundaryStoragePrefix)) {
localStorage.removeItem(key);
}
});
} catch (error) {
console.error('Error clearing boundary cache:', error);
}
}
findCitiesStartingWith(prefix: string, state?: string): Observable<GeoResult[]> {
const stateString = state ? `/${state}` : '';
return this.http.get<GeoResult[]>(`${this.apiBaseUrl}/bizmatch/geo/${prefix}${stateString}`);
}
findCitiesAndStatesStartingWith(prefix: string): Observable<CityAndStateResult[]> {
return this.http.get<CityAndStateResult[]>(`${this.apiBaseUrl}/bizmatch/geo/citiesandstates/${prefix}`);
}
findCountiesStartingWith(prefix: string, states?: string[]): Observable<CountyResult[]> {
return this.http.post<CountyResult[]>(`${this.apiBaseUrl}/bizmatch/geo/counties`, { prefix, states });
}
findLocationStartingWith(prefix: string): Observable<Place[]> {
let headers = new HttpHeaders().set('X-Hide-Loading', 'true').set('Accept-Language', 'en-US');
return this.http.get(`${this.baseUrl}?q=${prefix},US&format=json&addressdetails=1&limit=5`, { headers }) as Observable<Place[]>;
}
getCityBoundary(cityName: string, state: string): Observable<any> {
const cacheKey = `city_${cityName}_${state}`.toLowerCase().replace(/\s+/g, '_');
// Check cache first
const cached = this.getCachedBoundary(cacheKey);
if (cached) {
return of(cached);
}
// If not in cache, fetch from API
const query = `${cityName}, ${state}, USA`;
let headers = new HttpHeaders().set('X-Hide-Loading', 'true').set('Accept-Language', 'en-US');
return this.http.get(`${this.baseUrl}?q=${encodeURIComponent(query)}&format=json&polygon_geojson=1&limit=1`, { headers }).pipe(
tap(data => this.setCachedBoundary(cacheKey, data))
);
}
getStateBoundary(state: string): Observable<any> {
const cacheKey = `state_${state}`.toLowerCase().replace(/\s+/g, '_');
// Check cache first
const cached = this.getCachedBoundary(cacheKey);
if (cached) {
return of(cached);
}
// If not in cache, fetch from API
const query = `${state}, USA`;
let headers = new HttpHeaders().set('X-Hide-Loading', 'true').set('Accept-Language', 'en-US');
return this.http.get(`${this.baseUrl}?q=${encodeURIComponent(query)}&format=json&polygon_geojson=1&limit=1&featuretype=state`, { headers }).pipe(
tap(data => this.setCachedBoundary(cacheKey, data))
);
}
private fetchIpAndGeoLocation(): Observable<IpInfo> {
return this.http.get<IpInfo>(`${this.apiBaseUrl}/bizmatch/geo/ipinfo/georesult/wysiwyg`);
}
async getIpInfo(): Promise<IpInfo | null> {
// Versuche zuerst, die Daten aus dem sessionStorage zu holen
if (this.isBrowser) {
const storedData = sessionStorage.getItem(this.storageKey);
if (storedData) {
return JSON.parse(storedData);
}
}
try {
// Wenn keine Daten im Storage, hole sie vom Server
const data = await lastValueFrom(this.http.get<IpInfo>(`${this.apiBaseUrl}/bizmatch/geo/ipinfo/georesult/wysiwyg`));
// Speichere die Daten im sessionStorage
if (this.isBrowser) {
sessionStorage.setItem(this.storageKey, JSON.stringify(data));
}
return data;
} catch (error) {
console.error('Error fetching IP info:', error);
return null;
}
}
}

View File

@@ -1,103 +1,103 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, lastValueFrom } from 'rxjs';
import { BusinessListing, CommercialPropertyListing } from '../../../../bizmatch-server/src/models/db.model';
import { ListingType, ResponseBusinessListingArray, ResponseCommercialPropertyListingArray } from '../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../environments/environment';
import { getCriteriaByListingCategory, getSortByListingCategory } from '../utils/utils';
@Injectable({
providedIn: 'root',
})
export class ListingsService {
private apiBaseUrl = environment.apiBaseUrl;
constructor(private http: HttpClient) { }
async getListings(listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty'): Promise<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray> {
const criteria = getCriteriaByListingCategory(listingsCategory);
const sortBy = getSortByListingCategory(listingsCategory);
const body = { ...criteria, sortBy }; // Merge sortBy in Body
const result = await lastValueFrom(this.http.post<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/find`, body));
return result;
}
getNumberOfListings(listingsCategory: 'business' | 'commercialProperty', crit?: any): Observable<number> {
const criteria = crit ? crit : getCriteriaByListingCategory(listingsCategory);
const sortBy = getSortByListingCategory(listingsCategory);
const body = { ...criteria, sortBy }; // Merge, falls relevant (wenn Backend sortBy für Count braucht; sonst ignorieren)
return this.http.post<number>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/findTotal`, body);
}
getListingById(id: string, listingsCategory?: 'business' | 'commercialProperty'): Observable<ListingType> {
const result = this.http.get<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/${id}`);
return result;
}
getListingsByEmail(email: string, listingsCategory: 'business' | 'commercialProperty'): Promise<ListingType[]> {
return lastValueFrom(this.http.get<BusinessListing[]>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/user/${email}`));
}
getFavoriteListings(listingsCategory: 'business' | 'commercialProperty' | 'user'): Promise<ListingType[]> {
return lastValueFrom(this.http.post<BusinessListing[] | CommercialPropertyListing[] | any[]>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorites/all`, {}));
}
async save(listing: any, listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty') {
if (listing.id) {
return await lastValueFrom(this.http.put<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}`, listing));
} else {
return await lastValueFrom(this.http.post<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}`, listing));
}
}
async deleteBusinessListing(id: string) {
await lastValueFrom(this.http.delete<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/business/listing/${id}`));
}
async deleteCommercialPropertyListing(id: string, imagePath: string) {
await lastValueFrom(this.http.delete<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/commercialProperty/listing/${id}/${imagePath}`));
}
async addToFavorites(id: string, listingsCategory?: 'business' | 'commercialProperty' | 'user') {
const url = `${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorite/${id}`;
console.log('[ListingsService] addToFavorites calling URL:', url);
await lastValueFrom(this.http.post<void>(url, {}));
}
async removeFavorite(id: string, listingsCategory?: 'business' | 'commercialProperty' | 'user') {
const url = `${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorite/${id}`;
console.log('[ListingsService] removeFavorite calling URL:', url);
await lastValueFrom(this.http.delete<ListingType>(url));
}
/**
* Get related listings based on current listing
* Finds listings with same category, same state, and similar price range
* @param currentListing The current listing to find related items for
* @param listingsCategory Type of listings (business or commercialProperty)
* @param limit Maximum number of related listings to return
* @returns Array of related listings
*/
async getRelatedListings(currentListing: any, listingsCategory: 'business' | 'commercialProperty', limit: number = 3): Promise<ListingType[]> {
const criteria: any = {
types: [currentListing.type], // Same category/type
state: currentListing.location.state, // Same state
minPrice: currentListing.price ? Math.floor(currentListing.price * 0.5) : undefined, // 50% lower
maxPrice: currentListing.price ? Math.ceil(currentListing.price * 1.5) : undefined, // 50% higher
page: 0,
resultsPerPage: limit + 1, // Get one extra to filter out current listing
showDraft: false
};
try {
const response = await lastValueFrom(
this.http.post<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray>(
`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/find`,
criteria
)
);
// Filter out the current listing and limit results
const filtered = response.results
.filter(listing => listing.id !== currentListing.id)
.slice(0, limit);
return filtered;
} catch (error) {
console.error('Error fetching related listings:', error);
return [];
}
}
}
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, lastValueFrom } from 'rxjs';
import { BusinessListing, CommercialPropertyListing } from '../../../../bizmatch-server/src/models/db.model';
import { ListingType, ResponseBusinessListingArray, ResponseCommercialPropertyListingArray } from '../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../environments/environment';
import { getCriteriaByListingCategory, getSortByListingCategory } from '../utils/utils';
@Injectable({
providedIn: 'root',
})
export class ListingsService {
private apiBaseUrl = environment.apiBaseUrl;
constructor(private http: HttpClient) { }
async getListings(listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty'): Promise<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray> {
const criteria = getCriteriaByListingCategory(listingsCategory);
const sortBy = getSortByListingCategory(listingsCategory);
const body = { ...criteria, sortBy }; // Merge sortBy in Body
const result = await lastValueFrom(this.http.post<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/find`, body));
return result;
}
getNumberOfListings(listingsCategory: 'business' | 'commercialProperty', crit?: any): Observable<number> {
const criteria = crit ? crit : getCriteriaByListingCategory(listingsCategory);
const sortBy = getSortByListingCategory(listingsCategory);
const body = { ...criteria, sortBy }; // Merge, falls relevant (wenn Backend sortBy für Count braucht; sonst ignorieren)
return this.http.post<number>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/findTotal`, body);
}
getListingById(id: string, listingsCategory?: 'business' | 'commercialProperty'): Observable<ListingType> {
const result = this.http.get<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/${id}`);
return result;
}
getListingsByEmail(email: string, listingsCategory: 'business' | 'commercialProperty'): Promise<ListingType[]> {
return lastValueFrom(this.http.get<BusinessListing[]>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/user/${email}`));
}
getFavoriteListings(listingsCategory: 'business' | 'commercialProperty' | 'user'): Promise<ListingType[]> {
return lastValueFrom(this.http.post<BusinessListing[] | CommercialPropertyListing[] | any[]>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorites/all`, {}));
}
async save(listing: any, listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty') {
if (listing.id) {
return await lastValueFrom(this.http.put<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}`, listing));
} else {
return await lastValueFrom(this.http.post<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}`, listing));
}
}
async deleteBusinessListing(id: string) {
await lastValueFrom(this.http.delete<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/business/listing/${id}`));
}
async deleteCommercialPropertyListing(id: string, imagePath: string) {
await lastValueFrom(this.http.delete<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/commercialProperty/listing/${id}/${imagePath}`));
}
async addToFavorites(id: string, listingsCategory?: 'business' | 'commercialProperty' | 'user') {
const url = `${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorite/${id}`;
console.log('[ListingsService] addToFavorites calling URL:', url);
await lastValueFrom(this.http.post<void>(url, {}));
}
async removeFavorite(id: string, listingsCategory?: 'business' | 'commercialProperty' | 'user') {
const url = `${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorite/${id}`;
console.log('[ListingsService] removeFavorite calling URL:', url);
await lastValueFrom(this.http.delete<ListingType>(url));
}
/**
* Get related listings based on current listing
* Finds listings with same category, same state, and similar price range
* @param currentListing The current listing to find related items for
* @param listingsCategory Type of listings (business or commercialProperty)
* @param limit Maximum number of related listings to return
* @returns Array of related listings
*/
async getRelatedListings(currentListing: any, listingsCategory: 'business' | 'commercialProperty', limit: number = 3): Promise<ListingType[]> {
const criteria: any = {
types: [currentListing.type], // Same category/type
state: currentListing.location.state, // Same state
minPrice: currentListing.price ? Math.floor(currentListing.price * 0.5) : undefined, // 50% lower
maxPrice: currentListing.price ? Math.ceil(currentListing.price * 1.5) : undefined, // 50% higher
page: 0,
resultsPerPage: limit + 1, // Get one extra to filter out current listing
showDraft: false
};
try {
const response = await lastValueFrom(
this.http.post<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray>(
`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/find`,
criteria
)
);
// Filter out the current listing and limit results
const filtered = response.results
.filter(listing => listing.id !== currentListing.id)
.slice(0, limit);
return filtered;
} catch (error) {
console.error('Error fetching related listings:', error);
return [];
}
}
}

View File

@@ -1,124 +1,124 @@
import { isPlatformBrowser } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
import { lastValueFrom } from 'rxjs';
import { KeyValue, KeyValueAsSortBy, KeyValueStyle } from '../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root',
})
export class SelectOptionsService {
private apiBaseUrl = environment.apiBaseUrl;
private platformId = inject(PLATFORM_ID);
constructor(private http: HttpClient) { }
async init() {
// Skip HTTP call on server-side to avoid blocking SSR
if (!isPlatformBrowser(this.platformId)) {
console.log('[SSR] SelectOptionsService.init() - Skipping HTTP call on server');
// Initialize with empty arrays - client will hydrate with real data
this.typesOfBusiness = [];
this.prices = [];
this.listingCategories = [];
this.customerTypes = [];
this.customerSubTypes = [];
this.states = [];
this.gender = [];
this.typesOfCommercialProperty = [];
this.distances = [];
this.sortByOptions = [];
return;
}
try {
const allSelectOptions = await lastValueFrom(this.http.get<any>(`${this.apiBaseUrl}/bizmatch/select-options`));
this.typesOfBusiness = allSelectOptions.typesOfBusiness;
this.prices = allSelectOptions.prices;
this.listingCategories = allSelectOptions.listingCategories;
this.customerTypes = allSelectOptions.customerTypes;
this.customerSubTypes = allSelectOptions.customerSubTypes;
this.states = allSelectOptions.locations;
this.gender = allSelectOptions.gender;
this.typesOfCommercialProperty = allSelectOptions.typesOfCommercialProperty;
this.distances = allSelectOptions.distances;
this.sortByOptions = allSelectOptions.sortByOptions;
} catch (error) {
console.error('[SelectOptionsService] Failed to load select options:', error);
// Initialize with empty arrays as fallback
this.typesOfBusiness = this.typesOfBusiness || [];
this.prices = this.prices || [];
this.listingCategories = this.listingCategories || [];
this.customerTypes = this.customerTypes || [];
this.customerSubTypes = this.customerSubTypes || [];
this.states = this.states || [];
this.gender = this.gender || [];
this.typesOfCommercialProperty = this.typesOfCommercialProperty || [];
this.distances = this.distances || [];
this.sortByOptions = this.sortByOptions || [];
}
}
public typesOfBusiness: Array<KeyValueStyle>;
public typesOfCommercialProperty: Array<KeyValueStyle>;
public prices: Array<KeyValue>;
public listingCategories: Array<KeyValue>;
public customerTypes: Array<KeyValue>;
public gender: Array<KeyValue>;
public states: Array<any>;
public customerSubTypes: Array<KeyValue>;
public distances: Array<KeyValue>;
public sortByOptions: Array<KeyValueAsSortBy>;
getSortByOption(value: string) {
return this.sortByOptions.find(l => l.value === value)?.name;
}
getState(value: string): string {
return this.states.find(l => l.value === value)?.name;
}
getStateInitials(name: string): string {
return this.states.find(l => l.name === name?.toUpperCase())?.value;
}
getBusiness(value: string): string {
return this.typesOfBusiness.find(t => t.value === value)?.name;
}
getCommercialProperty(value: string): string {
return this.typesOfCommercialProperty.find(t => t.value === value)?.name;
}
getListingsCategory(value: string): string {
return this.listingCategories.find(l => l.value === value)?.name;
}
getCustomerType(value: string): string {
return this.customerTypes.find(c => c.value === value)?.name;
}
getCustomerSubType(value: string): string {
return this.customerSubTypes.find(c => c.value === value)?.name;
}
getGender(value: string): string {
return this.gender.find(c => c.value === value)?.name;
}
getIconType(value: string): string {
return this.typesOfBusiness.find(c => c.value === value)?.icon;
}
getTextColorType(value: string): string {
return this.typesOfBusiness.find(c => c.value === value)?.textColorClass;
}
getIconAndTextColorType(value: string): string {
const category = this.typesOfBusiness.find(c => c.value === value);
return `${category?.icon} ${category?.textColorClass}`;
}
getIconTypeOfCommercials(value: string): string {
return this.typesOfCommercialProperty.find(c => c.value === value)?.icon;
}
getIconAndTextColorTypeOfCommercials(value: string): string {
const category = this.typesOfCommercialProperty.find(c => c.value === value);
return `${category?.icon} ${category?.textColorClass}`;
}
getTextColorTypeOfCommercial(value: string): string {
return this.typesOfCommercialProperty.find(c => c.value === value)?.textColorClass;
}
}
import { isPlatformBrowser } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
import { lastValueFrom } from 'rxjs';
import { KeyValue, KeyValueAsSortBy, KeyValueStyle } from '../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root',
})
export class SelectOptionsService {
private apiBaseUrl = environment.apiBaseUrl;
private platformId = inject(PLATFORM_ID);
constructor(private http: HttpClient) { }
async init() {
// Skip HTTP call on server-side to avoid blocking SSR
if (!isPlatformBrowser(this.platformId)) {
console.log('[SSR] SelectOptionsService.init() - Skipping HTTP call on server');
// Initialize with empty arrays - client will hydrate with real data
this.typesOfBusiness = [];
this.prices = [];
this.listingCategories = [];
this.customerTypes = [];
this.customerSubTypes = [];
this.states = [];
this.gender = [];
this.typesOfCommercialProperty = [];
this.distances = [];
this.sortByOptions = [];
return;
}
try {
const allSelectOptions = await lastValueFrom(this.http.get<any>(`${this.apiBaseUrl}/bizmatch/select-options`));
this.typesOfBusiness = allSelectOptions.typesOfBusiness;
this.prices = allSelectOptions.prices;
this.listingCategories = allSelectOptions.listingCategories;
this.customerTypes = allSelectOptions.customerTypes;
this.customerSubTypes = allSelectOptions.customerSubTypes;
this.states = allSelectOptions.locations;
this.gender = allSelectOptions.gender;
this.typesOfCommercialProperty = allSelectOptions.typesOfCommercialProperty;
this.distances = allSelectOptions.distances;
this.sortByOptions = allSelectOptions.sortByOptions;
} catch (error) {
console.error('[SelectOptionsService] Failed to load select options:', error);
// Initialize with empty arrays as fallback
this.typesOfBusiness = this.typesOfBusiness || [];
this.prices = this.prices || [];
this.listingCategories = this.listingCategories || [];
this.customerTypes = this.customerTypes || [];
this.customerSubTypes = this.customerSubTypes || [];
this.states = this.states || [];
this.gender = this.gender || [];
this.typesOfCommercialProperty = this.typesOfCommercialProperty || [];
this.distances = this.distances || [];
this.sortByOptions = this.sortByOptions || [];
}
}
public typesOfBusiness: Array<KeyValueStyle>;
public typesOfCommercialProperty: Array<KeyValueStyle>;
public prices: Array<KeyValue>;
public listingCategories: Array<KeyValue>;
public customerTypes: Array<KeyValue>;
public gender: Array<KeyValue>;
public states: Array<any>;
public customerSubTypes: Array<KeyValue>;
public distances: Array<KeyValue>;
public sortByOptions: Array<KeyValueAsSortBy>;
getSortByOption(value: string) {
return this.sortByOptions.find(l => l.value === value)?.name;
}
getState(value: string): string {
return this.states.find(l => l.value === value)?.name;
}
getStateInitials(name: string): string {
return this.states.find(l => l.name === name?.toUpperCase())?.value;
}
getBusiness(value: string): string {
return this.typesOfBusiness.find(t => t.value === value)?.name;
}
getCommercialProperty(value: string): string {
return this.typesOfCommercialProperty.find(t => t.value === value)?.name;
}
getListingsCategory(value: string): string {
return this.listingCategories.find(l => l.value === value)?.name;
}
getCustomerType(value: string): string {
return this.customerTypes.find(c => c.value === value)?.name;
}
getCustomerSubType(value: string): string {
return this.customerSubTypes.find(c => c.value === value)?.name;
}
getGender(value: string): string {
return this.gender.find(c => c.value === value)?.name;
}
getIconType(value: string): string {
return this.typesOfBusiness.find(c => c.value === value)?.icon;
}
getTextColorType(value: string): string {
return this.typesOfBusiness.find(c => c.value === value)?.textColorClass;
}
getIconAndTextColorType(value: string): string {
const category = this.typesOfBusiness.find(c => c.value === value);
return `${category?.icon} ${category?.textColorClass}`;
}
getIconTypeOfCommercials(value: string): string {
return this.typesOfCommercialProperty.find(c => c.value === value)?.icon;
}
getIconAndTextColorTypeOfCommercials(value: string): string {
const category = this.typesOfCommercialProperty.find(c => c.value === value);
return `${category?.icon} ${category?.textColorClass}`;
}
getTextColorTypeOfCommercial(value: string): string {
return this.typesOfCommercialProperty.find(c => c.value === value)?.textColorClass;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,135 +1,135 @@
import { Injectable } from '@angular/core';
export interface SitemapUrl {
loc: string;
lastmod?: string;
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
priority?: number;
}
@Injectable({
providedIn: 'root'
})
export class SitemapService {
private readonly baseUrl = 'https://biz-match.com';
/**
* Generate XML sitemap content
*/
generateSitemap(urls: SitemapUrl[]): string {
const urlElements = urls.map(url => this.generateUrlElement(url)).join('\n ');
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urlElements}
</urlset>`;
}
/**
* Generate a single URL element for the sitemap
*/
private generateUrlElement(url: SitemapUrl): string {
let element = `<url>\n <loc>${url.loc}</loc>`;
if (url.lastmod) {
element += `\n <lastmod>${url.lastmod}</lastmod>`;
}
if (url.changefreq) {
element += `\n <changefreq>${url.changefreq}</changefreq>`;
}
if (url.priority !== undefined) {
element += `\n <priority>${url.priority.toFixed(1)}</priority>`;
}
element += '\n </url>';
return element;
}
/**
* Generate sitemap URLs for static pages
*/
getStaticPageUrls(): SitemapUrl[] {
return [
{
loc: `${this.baseUrl}/`,
changefreq: 'daily',
priority: 1.0
},
{
loc: `${this.baseUrl}/home`,
changefreq: 'daily',
priority: 1.0
},
{
loc: `${this.baseUrl}/listings`,
changefreq: 'daily',
priority: 0.9
},
{
loc: `${this.baseUrl}/listings-2`,
changefreq: 'daily',
priority: 0.8
},
{
loc: `${this.baseUrl}/listings-3`,
changefreq: 'daily',
priority: 0.8
},
{
loc: `${this.baseUrl}/listings-4`,
changefreq: 'daily',
priority: 0.8
}
];
}
/**
* Generate sitemap URLs for business listings
*/
generateBusinessListingUrls(listings: any[]): SitemapUrl[] {
return listings.map(listing => ({
loc: `${this.baseUrl}/details-business-listing/${listing.id}`,
lastmod: this.formatDate(listing.updated || listing.created),
changefreq: 'weekly' as const,
priority: 0.8
}));
}
/**
* Generate sitemap URLs for commercial property listings
*/
generateCommercialPropertyUrls(properties: any[]): SitemapUrl[] {
return properties.map(property => ({
loc: `${this.baseUrl}/details-commercial-property/${property.id}`,
lastmod: this.formatDate(property.updated || property.created),
changefreq: 'weekly' as const,
priority: 0.8
}));
}
/**
* Format date to ISO 8601 format (YYYY-MM-DD)
*/
private formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toISOString().split('T')[0];
}
/**
* Generate complete sitemap with all URLs
*/
async generateCompleteSitemap(
businessListings: any[],
commercialProperties: any[]
): Promise<string> {
const allUrls = [
...this.getStaticPageUrls(),
...this.generateBusinessListingUrls(businessListings),
...this.generateCommercialPropertyUrls(commercialProperties)
];
return this.generateSitemap(allUrls);
}
}
import { Injectable } from '@angular/core';
export interface SitemapUrl {
loc: string;
lastmod?: string;
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
priority?: number;
}
@Injectable({
providedIn: 'root'
})
export class SitemapService {
private readonly baseUrl = 'https://biz-match.com';
/**
* Generate XML sitemap content
*/
generateSitemap(urls: SitemapUrl[]): string {
const urlElements = urls.map(url => this.generateUrlElement(url)).join('\n ');
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urlElements}
</urlset>`;
}
/**
* Generate a single URL element for the sitemap
*/
private generateUrlElement(url: SitemapUrl): string {
let element = `<url>\n <loc>${url.loc}</loc>`;
if (url.lastmod) {
element += `\n <lastmod>${url.lastmod}</lastmod>`;
}
if (url.changefreq) {
element += `\n <changefreq>${url.changefreq}</changefreq>`;
}
if (url.priority !== undefined) {
element += `\n <priority>${url.priority.toFixed(1)}</priority>`;
}
element += '\n </url>';
return element;
}
/**
* Generate sitemap URLs for static pages
*/
getStaticPageUrls(): SitemapUrl[] {
return [
{
loc: `${this.baseUrl}/`,
changefreq: 'daily',
priority: 1.0
},
{
loc: `${this.baseUrl}/home`,
changefreq: 'daily',
priority: 1.0
},
{
loc: `${this.baseUrl}/listings`,
changefreq: 'daily',
priority: 0.9
},
{
loc: `${this.baseUrl}/listings-2`,
changefreq: 'daily',
priority: 0.8
},
{
loc: `${this.baseUrl}/listings-3`,
changefreq: 'daily',
priority: 0.8
},
{
loc: `${this.baseUrl}/listings-4`,
changefreq: 'daily',
priority: 0.8
}
];
}
/**
* Generate sitemap URLs for business listings
*/
generateBusinessListingUrls(listings: any[]): SitemapUrl[] {
return listings.map(listing => ({
loc: `${this.baseUrl}/details-business-listing/${listing.id}`,
lastmod: this.formatDate(listing.updated || listing.created),
changefreq: 'weekly' as const,
priority: 0.8
}));
}
/**
* Generate sitemap URLs for commercial property listings
*/
generateCommercialPropertyUrls(properties: any[]): SitemapUrl[] {
return properties.map(property => ({
loc: `${this.baseUrl}/details-commercial-property/${property.id}`,
lastmod: this.formatDate(property.updated || property.created),
changefreq: 'weekly' as const,
priority: 0.8
}));
}
/**
* Format date to ISO 8601 format (YYYY-MM-DD)
*/
private formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toISOString().split('T')[0];
}
/**
* Generate complete sitemap with all URLs
*/
async generateCompleteSitemap(
businessListings: any[],
commercialProperties: any[]
): Promise<string> {
const allUrls = [
...this.getStaticPageUrls(),
...this.generateBusinessListingUrls(businessListings),
...this.generateCommercialPropertyUrls(commercialProperties)
];
return this.generateSitemap(allUrls);
}
}

View File

@@ -1,149 +1,149 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { catchError, forkJoin, lastValueFrom, map, Observable, of, Subject } from 'rxjs';
import urlcat from 'urlcat';
import { User } from '../../../../bizmatch-server/src/models/db.model';
import { CombinedUser, FirebaseUserInfo, KeycloakUser, ResponseUsersArray, UserListingCriteria, UserRole, UsersResponse } from '../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root',
})
export class UserService {
private apiBaseUrl = environment.apiBaseUrl;
private userSource = new Subject<User>();
currentUser = this.userSource.asObservable();
constructor(private http: HttpClient) {}
changeUser(user: User) {
this.userSource.next(user);
}
// -----------------------------
// DB services
// -----------------------------
async save(user: User): Promise<User> {
return await lastValueFrom(this.http.post<User>(`${this.apiBaseUrl}/bizmatch/user`, user));
}
async saveGuaranteed(user: User): Promise<User> {
return await lastValueFrom(this.http.post<User>(`${this.apiBaseUrl}/bizmatch/user/guaranteed`, user));
}
async getById(id: string): Promise<User> {
return await lastValueFrom(this.http.get<User>(`${this.apiBaseUrl}/bizmatch/user/${id}`));
}
async getByMail(mail: string, hideLoading: boolean = true): Promise<User> {
const url = urlcat(`${this.apiBaseUrl}/bizmatch/user`, { mail });
let headers = new HttpHeaders();
if (hideLoading) {
headers = headers.set('X-Hide-Loading', 'true');
}
return await lastValueFrom(this.http.get<User>(url, { headers }));
}
async search(criteria?: UserListingCriteria): Promise<ResponseUsersArray> {
return await lastValueFrom(this.http.post<ResponseUsersArray>(`${this.apiBaseUrl}/bizmatch/user/search`, criteria));
}
getNumberOfBroker(criteria?: UserListingCriteria): Observable<number> {
return this.http.post<number>(`${this.apiBaseUrl}/bizmatch/user/findTotal`, criteria);
}
getKeycloakUser(id: string): Promise<KeycloakUser> {
return lastValueFrom(this.http.get<KeycloakUser>(`${this.apiBaseUrl}/bizmatch/auth/users/${id}`));
}
async updateKeycloakUser(keycloakUser: KeycloakUser): Promise<void> {
await lastValueFrom(this.http.put<void>(`${this.apiBaseUrl}/bizmatch/auth/users/${keycloakUser.id}`, keycloakUser));
}
// -------------------------------
// ADMIN SERVICES
// -------------------------------
/**
* Ruft alle Benutzer mit Paginierung ab
*/
getAllUsers(maxResults?: number, pageToken?: string): Observable<UsersResponse> {
let params = new HttpParams();
if (maxResults) {
params = params.set('maxResults', maxResults.toString());
}
if (pageToken) {
params = params.set('pageToken', pageToken);
}
return this.http.get<UsersResponse>(`${this.apiBaseUrl}/bizmatch/auth`, { params });
}
/**
* Ruft Benutzer mit einer bestimmten Rolle ab
*/
getUsersByRole(role: UserRole): Observable<{ users: FirebaseUserInfo[] }> {
return this.http.get<{ users: FirebaseUserInfo[] }>(`${this.apiBaseUrl}/bizmatch/auth/role/${role}`);
}
/**
* Ändert die Rolle eines Benutzers
*/
setUserRole(uid: string, role: UserRole): Observable<{ success: boolean }> {
return this.http.post<{ success: boolean }>(`${this.apiBaseUrl}/bizmatch/auth/${uid}/role`, { role });
}
// -------------------------------
// OLDADMIN SERVICES
// -------------------------------
getKeycloakUsers(): Observable<KeycloakUser[]> {
return this.http.get<KeycloakUser[]>(`${this.apiBaseUrl}/bizmatch/auth/user/all`).pipe(
catchError(error => {
console.error('Fehler beim Laden der Keycloak-Benutzer', error);
return of([]);
}),
);
}
getAppUsers(): Observable<User[]> {
return this.http.get<User[]>(`${this.apiBaseUrl}/bizmatch/user/user/all`).pipe(
catchError(error => {
console.error('Fehler beim Laden der App-Benutzer', error);
return of([]);
}),
);
}
/**
* Lädt alle Benutzer aus den verschiedenen Quellen und kombiniert sie.
* @returns Ein Observable mit einer Liste von CombinedUser.
*/
loadUsers(): Observable<CombinedUser[]> {
return forkJoin({
keycloakUsers: this.getKeycloakUsers(),
appUsers: this.getAppUsers(),
}).pipe(
map(({ keycloakUsers, appUsers }) => {
const combinedUsers: CombinedUser[] = [];
// Map App Users mit Keycloak und Stripe Subscription
appUsers.forEach(appUser => {
const keycloakUser = keycloakUsers.find(kcUser => kcUser.email.toLowerCase() === appUser.email.toLowerCase());
// const stripeSubscription = appUser.subscriptionId ? stripeSubscriptions.find(sub => sub.id === appUser.subscriptionId) : null;
const stripeUser = null;
const stripeSubscription = null;
combinedUsers.push({
appUser,
keycloakUser
});
});
return combinedUsers;
}),
catchError(err => {
console.error('Fehler beim Kombinieren der Benutzer', err);
return of([]);
}),
);
}
async deleteCustomerFromStripe(customerId: string): Promise<void> {
await lastValueFrom(this.http.delete(`${this.apiBaseUrl}/bizmatch/payment/customer/${customerId}`));
}
}
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { catchError, forkJoin, lastValueFrom, map, Observable, of, Subject } from 'rxjs';
import urlcat from 'urlcat';
import { User } from '../../../../bizmatch-server/src/models/db.model';
import { CombinedUser, FirebaseUserInfo, KeycloakUser, ResponseUsersArray, UserListingCriteria, UserRole, UsersResponse } from '../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root',
})
export class UserService {
private apiBaseUrl = environment.apiBaseUrl;
private userSource = new Subject<User>();
currentUser = this.userSource.asObservable();
constructor(private http: HttpClient) {}
changeUser(user: User) {
this.userSource.next(user);
}
// -----------------------------
// DB services
// -----------------------------
async save(user: User): Promise<User> {
return await lastValueFrom(this.http.post<User>(`${this.apiBaseUrl}/bizmatch/user`, user));
}
async saveGuaranteed(user: User): Promise<User> {
return await lastValueFrom(this.http.post<User>(`${this.apiBaseUrl}/bizmatch/user/guaranteed`, user));
}
async getById(id: string): Promise<User> {
return await lastValueFrom(this.http.get<User>(`${this.apiBaseUrl}/bizmatch/user/${id}`));
}
async getByMail(mail: string, hideLoading: boolean = true): Promise<User> {
const url = urlcat(`${this.apiBaseUrl}/bizmatch/user`, { mail });
let headers = new HttpHeaders();
if (hideLoading) {
headers = headers.set('X-Hide-Loading', 'true');
}
return await lastValueFrom(this.http.get<User>(url, { headers }));
}
async search(criteria?: UserListingCriteria): Promise<ResponseUsersArray> {
return await lastValueFrom(this.http.post<ResponseUsersArray>(`${this.apiBaseUrl}/bizmatch/user/search`, criteria));
}
getNumberOfBroker(criteria?: UserListingCriteria): Observable<number> {
return this.http.post<number>(`${this.apiBaseUrl}/bizmatch/user/findTotal`, criteria);
}
getKeycloakUser(id: string): Promise<KeycloakUser> {
return lastValueFrom(this.http.get<KeycloakUser>(`${this.apiBaseUrl}/bizmatch/auth/users/${id}`));
}
async updateKeycloakUser(keycloakUser: KeycloakUser): Promise<void> {
await lastValueFrom(this.http.put<void>(`${this.apiBaseUrl}/bizmatch/auth/users/${keycloakUser.id}`, keycloakUser));
}
// -------------------------------
// ADMIN SERVICES
// -------------------------------
/**
* Ruft alle Benutzer mit Paginierung ab
*/
getAllUsers(maxResults?: number, pageToken?: string): Observable<UsersResponse> {
let params = new HttpParams();
if (maxResults) {
params = params.set('maxResults', maxResults.toString());
}
if (pageToken) {
params = params.set('pageToken', pageToken);
}
return this.http.get<UsersResponse>(`${this.apiBaseUrl}/bizmatch/auth`, { params });
}
/**
* Ruft Benutzer mit einer bestimmten Rolle ab
*/
getUsersByRole(role: UserRole): Observable<{ users: FirebaseUserInfo[] }> {
return this.http.get<{ users: FirebaseUserInfo[] }>(`${this.apiBaseUrl}/bizmatch/auth/role/${role}`);
}
/**
* Ändert die Rolle eines Benutzers
*/
setUserRole(uid: string, role: UserRole): Observable<{ success: boolean }> {
return this.http.post<{ success: boolean }>(`${this.apiBaseUrl}/bizmatch/auth/${uid}/role`, { role });
}
// -------------------------------
// OLDADMIN SERVICES
// -------------------------------
getKeycloakUsers(): Observable<KeycloakUser[]> {
return this.http.get<KeycloakUser[]>(`${this.apiBaseUrl}/bizmatch/auth/user/all`).pipe(
catchError(error => {
console.error('Fehler beim Laden der Keycloak-Benutzer', error);
return of([]);
}),
);
}
getAppUsers(): Observable<User[]> {
return this.http.get<User[]>(`${this.apiBaseUrl}/bizmatch/user/user/all`).pipe(
catchError(error => {
console.error('Fehler beim Laden der App-Benutzer', error);
return of([]);
}),
);
}
/**
* Lädt alle Benutzer aus den verschiedenen Quellen und kombiniert sie.
* @returns Ein Observable mit einer Liste von CombinedUser.
*/
loadUsers(): Observable<CombinedUser[]> {
return forkJoin({
keycloakUsers: this.getKeycloakUsers(),
appUsers: this.getAppUsers(),
}).pipe(
map(({ keycloakUsers, appUsers }) => {
const combinedUsers: CombinedUser[] = [];
// Map App Users mit Keycloak und Stripe Subscription
appUsers.forEach(appUser => {
const keycloakUser = keycloakUsers.find(kcUser => kcUser.email.toLowerCase() === appUser.email.toLowerCase());
// const stripeSubscription = appUser.subscriptionId ? stripeSubscriptions.find(sub => sub.id === appUser.subscriptionId) : null;
const stripeUser = null;
const stripeSubscription = null;
combinedUsers.push({
appUser,
keycloakUser
});
});
return combinedUsers;
}),
catchError(err => {
console.error('Fehler beim Kombinieren der Benutzer', err);
return of([]);
}),
);
}
async deleteCustomerFromStripe(customerId: string): Promise<void> {
await lastValueFrom(this.http.delete(`${this.apiBaseUrl}/bizmatch/payment/customer/${customerId}`));
}
}

View File

@@ -1,377 +1,377 @@
import { Router } from '@angular/router';
import { ConsoleFormattedStream, INFO, createLogger as _createLogger, stdSerializers } from 'browser-bunyan';
import { jwtDecode } from 'jwt-decode';
import onChange from 'on-change';
import { SortByOptions, User } from '../../../../bizmatch-server/src/models/db.model';
import { BusinessListingCriteria, CommercialPropertyListingCriteria, JwtToken, KeycloakUser, MailInfo, UserListingCriteria } from '../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../environments/environment';
export function createEmptyBusinessListingCriteria(): BusinessListingCriteria {
return {
start: 0,
length: 0,
page: 0,
state: null,
city: null,
types: [],
prompt: '',
criteriaType: 'businessListings',
minPrice: null,
maxPrice: null,
minRevenue: null,
maxRevenue: null,
minCashFlow: null,
maxCashFlow: null,
minNumberEmployees: null,
maxNumberEmployees: null,
establishedMin: null,
realEstateChecked: false,
leasedLocation: false,
franchiseResale: false,
title: '',
email: '',
brokerName: '',
searchType: 'exact',
radius: null,
};
}
export function createEmptyCommercialPropertyListingCriteria(): CommercialPropertyListingCriteria {
return {
start: 0,
length: 0,
page: 0,
state: null,
city: null,
types: [],
prompt: '',
criteriaType: 'commercialPropertyListings',
minPrice: null,
maxPrice: null,
title: '',
brokerName: '',
searchType: 'exact',
radius: null,
};
}
export function createEmptyUserListingCriteria(): UserListingCriteria {
return {
start: 0,
length: 12,
page: 1,
city: null,
types: [],
prompt: '',
criteriaType: 'brokerListings',
brokerName: '',
companyName: '',
counties: [],
state: '',
searchType: 'exact',
radius: null,
};
}
export function resetBusinessListingCriteria(criteria: BusinessListingCriteria) {
criteria.start = 0;
criteria.length = 0;
criteria.page = 0;
criteria.state = null;
criteria.city = null;
criteria.types = [];
criteria.prompt = '';
criteria.criteriaType = 'businessListings';
criteria.minPrice = null;
criteria.maxPrice = null;
criteria.minRevenue = null;
criteria.maxRevenue = null;
criteria.minCashFlow = null;
criteria.maxCashFlow = null;
criteria.minNumberEmployees = null;
criteria.maxNumberEmployees = null;
criteria.establishedMin = null;
criteria.realEstateChecked = false;
criteria.leasedLocation = false;
criteria.franchiseResale = false;
criteria.title = '';
criteria.brokerName = '';
criteria.searchType = 'exact';
criteria.radius = null;
}
export function resetCommercialPropertyListingCriteria(criteria: CommercialPropertyListingCriteria) {
criteria.start = 0;
criteria.length = 0;
criteria.page = 0;
criteria.state = null;
criteria.city = null;
criteria.types = [];
criteria.prompt = '';
criteria.criteriaType = 'commercialPropertyListings';
criteria.minPrice = null;
criteria.maxPrice = null;
criteria.title = '';
criteria.searchType = 'exact';
criteria.radius = null;
}
export function resetUserListingCriteria(criteria: UserListingCriteria) {
criteria.start = 0;
criteria.length = 0;
criteria.page = 0;
criteria.city = null;
criteria.types = [];
criteria.prompt = '';
criteria.criteriaType = 'brokerListings';
criteria.brokerName = '';
criteria.companyName = '';
criteria.counties = [];
criteria.state = '';
criteria.searchType = 'exact';
criteria.radius = null;
}
export function createMailInfo(user?: User): MailInfo {
return {
sender: user
? { name: `${user.firstname} ${user.lastname}`, email: user.email, phoneNumber: user.phoneNumber, state: user.location?.state, comments: null }
: { name: '', email: '', phoneNumber: '', state: '', comments: '' },
email: null,
url: environment.mailinfoUrl,
listing: null,
};
}
export function createLogger(name: string, level: number = INFO, options: any = {}) {
return _createLogger({
name,
streams: [{ level, stream: new ConsoleFormattedStream() }],
serializers: stdSerializers,
src: true,
...options,
});
}
export function formatPhoneNumber(phone: string): string {
const cleaned = ('' + phone).replace(/\D/g, '');
const match = cleaned.match(/^(\d{3})(\d{3})(\d{4})$/);
if (match) {
return '(' + match[1] + ') ' + match[2] + '-' + match[3];
}
return phone;
}
export const getSessionStorageHandler = function (criteriaType, path, value, previous, applyData) {
if (typeof sessionStorage !== 'undefined') {
sessionStorage.setItem(`${criteriaType}_criteria`, JSON.stringify(this));
console.log('Zusätzlicher Parameter:', criteriaType);
}
};
export const getSessionStorageHandlerWrapper = param => {
return function (path, value, previous, applyData) {
getSessionStorageHandler.call(this, param, path, value, previous, applyData);
};
};
export function routeListingWithState(router: Router, value: string, data: any) {
if (value === 'business') {
router.navigate(['createBusinessListing'], { state: { data } });
} else {
router.navigate(['createCommercialPropertyListing'], { state: { data } });
}
}
export function map2User(jwt: string | null): KeycloakUser {
if (jwt) {
const token = jwtDecode<JwtToken>(jwt);
return {
id: token.user_id,
firstName: token.given_name,
lastName: token.family_name,
email: token.email,
};
} else {
return null;
}
}
export function getImageDimensions(imageUrl: string): Promise<{ width: number; height: number }> {
return new Promise(resolve => {
// Only use Image in browser context
if (typeof Image === 'undefined') {
resolve({ width: 0, height: 0 });
return;
}
const img = new Image();
img.onload = () => {
resolve({ width: img.width, height: img.height });
};
img.src = imageUrl;
});
}
export function getDialogWidth(dimensions): string {
const aspectRatio = dimensions.width / dimensions.height;
let dialogWidth = '50vw'; // Standardbreite
// Passen Sie die Breite basierend auf dem Seitenverhältnis an
if (aspectRatio < 1) {
dialogWidth = '30vw'; // Hochformat
} else if (aspectRatio > 1) {
dialogWidth = '50vw'; // Querformat
}
return dialogWidth;
}
export function compareObjects<T extends object>(obj1: T, obj2: T, ignoreProperties: (keyof T)[] = []): number {
let differences = 0;
const keys = Object.keys(obj1) as Array<keyof T>;
for (const key of keys) {
if (ignoreProperties.includes(key)) {
continue; // Überspringe diese Eigenschaft, wenn sie in der Ignore-Liste ist
}
const value1 = obj1[key];
const value2 = obj2[key];
if (!areValuesEqual(value1, value2)) {
differences++;
}
}
return differences;
}
function areValuesEqual(value1: any, value2: any): boolean {
if (Array.isArray(value1) || Array.isArray(value2)) {
return arraysEqual(value1, value2);
}
if (typeof value1 === 'string' || typeof value2 === 'string') {
return isEqualString(value1, value2);
}
if (typeof value1 === 'number' || typeof value2 === 'number') {
return isEqualNumber(value1, value2);
}
if (typeof value1 === 'boolean' || typeof value2 === 'boolean') {
return isEqualBoolean(value1, value2);
}
return value1 === value2;
}
function isEqualString(value1: any, value2: any): boolean {
const isEmptyOrNullish1 = value1 === undefined || value1 === null || value1 === '';
const isEmptyOrNullish2 = value2 === undefined || value2 === null || value2 === '';
return (isEmptyOrNullish1 && isEmptyOrNullish2) || value1 === value2;
}
function isEqualNumber(value1: any, value2: any): boolean {
const isZeroOrNullish1 = value1 === undefined || value1 === null || value1 === 0;
const isZeroOrNullish2 = value2 === undefined || value2 === null || value2 === 0;
return (isZeroOrNullish1 && isZeroOrNullish2) || value1 === value2;
}
function isEqualBoolean(value1: any, value2: any): boolean {
const isFalseOrNullish1 = value1 === undefined || value1 === null || value1 === false;
const isFalseOrNullish2 = value2 === undefined || value2 === null || value2 === false;
return (isFalseOrNullish1 && isFalseOrNullish2) || value1 === value2;
}
function arraysEqual(arr1: any[] | null | undefined, arr2: any[] | null | undefined): boolean {
if (arr1 === arr2) return true;
if (arr1 == null || arr2 == null) return false;
if (arr1.length !== arr2.length) return false;
for (let i = 0; i < arr1.length; i++) {
if (!areValuesEqual(arr1[i], arr2[i])) return false;
}
return true;
}
export function assignProperties(target, source) {
for (let key in source) {
if (source.hasOwnProperty(key)) {
target[key] = source[key];
}
}
return target;
}
export function checkAndUpdate(changed: boolean, condition: boolean, assignment: () => any): boolean {
if (condition) {
assignment();
}
return changed || condition;
}
export function removeSortByStorage() {
if (typeof sessionStorage !== 'undefined') {
sessionStorage.removeItem('businessSortBy');
sessionStorage.removeItem('commercialSortBy');
sessionStorage.removeItem('professionalsSortBy');
}
}
// -----------------------------
// Criteria Proxy
// -----------------------------
export function getCriteriaStateObject(criteriaType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings') {
let initialState;
if (criteriaType === 'businessListings') {
initialState = createEmptyBusinessListingCriteria();
} else if (criteriaType === 'commercialPropertyListings') {
initialState = createEmptyCommercialPropertyListingCriteria();
} else {
initialState = createEmptyUserListingCriteria();
}
if (typeof sessionStorage !== 'undefined') {
const storedState = sessionStorage.getItem(`${criteriaType}`);
return storedState ? JSON.parse(storedState) : initialState;
}
return initialState;
}
export function getCriteriaProxy(path: string, component: any): BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria {
if ('businessListings' === path) {
return createEnhancedProxy(getCriteriaStateObject('businessListings'), component);
} else if ('commercialPropertyListings' === path) {
return createEnhancedProxy(getCriteriaStateObject('commercialPropertyListings'), component);
} else if ('brokerListings' === path) {
return createEnhancedProxy(getCriteriaStateObject('brokerListings'), component);
} else {
return undefined;
}
}
export function createEnhancedProxy(obj: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria, component: any) {
const sessionStorageHandler = function (path, value, previous, applyData) {
if (typeof sessionStorage !== 'undefined') {
sessionStorage.setItem(`${obj.criteriaType}`, JSON.stringify(this));
}
};
return onChange(obj, function (path, value, previous, applyData) {
// Call the original sessionStorageHandler
sessionStorageHandler.call(this, path, value, previous, applyData);
// Notify about the criteria change using the component's context
if (component.criteriaChangeService) {
component.criteriaChangeService.notifyCriteriaChange();
}
});
}
export function getCriteriaByListingCategory(listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty') {
if (typeof sessionStorage === 'undefined') return null;
const storedState =
listingsCategory === 'business'
? sessionStorage.getItem('businessListings')
: listingsCategory === 'commercialProperty'
? sessionStorage.getItem('commercialPropertyListings')
: sessionStorage.getItem('brokerListings');
return storedState ? JSON.parse(storedState) : null;
}
export function getSortByListingCategory(listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty') {
if (typeof sessionStorage === 'undefined') return null;
const storedSortBy =
listingsCategory === 'business' ? sessionStorage.getItem('businessSortBy') : listingsCategory === 'commercialProperty' ? sessionStorage.getItem('commercialSortBy') : sessionStorage.getItem('professionalsSortBy');
const sortBy = storedSortBy && storedSortBy !== 'null' ? (storedSortBy as SortByOptions) : null;
return sortBy;
}
import { Router } from '@angular/router';
import { ConsoleFormattedStream, INFO, createLogger as _createLogger, stdSerializers } from 'browser-bunyan';
import { jwtDecode } from 'jwt-decode';
import onChange from 'on-change';
import { SortByOptions, User } from '../../../../bizmatch-server/src/models/db.model';
import { BusinessListingCriteria, CommercialPropertyListingCriteria, JwtToken, KeycloakUser, MailInfo, UserListingCriteria } from '../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../environments/environment';
export function createEmptyBusinessListingCriteria(): BusinessListingCriteria {
return {
start: 0,
length: 0,
page: 0,
state: null,
city: null,
types: [],
prompt: '',
criteriaType: 'businessListings',
minPrice: null,
maxPrice: null,
minRevenue: null,
maxRevenue: null,
minCashFlow: null,
maxCashFlow: null,
minNumberEmployees: null,
maxNumberEmployees: null,
establishedMin: null,
realEstateChecked: false,
leasedLocation: false,
franchiseResale: false,
title: '',
email: '',
brokerName: '',
searchType: 'exact',
radius: null,
};
}
export function createEmptyCommercialPropertyListingCriteria(): CommercialPropertyListingCriteria {
return {
start: 0,
length: 0,
page: 0,
state: null,
city: null,
types: [],
prompt: '',
criteriaType: 'commercialPropertyListings',
minPrice: null,
maxPrice: null,
title: '',
brokerName: '',
searchType: 'exact',
radius: null,
};
}
export function createEmptyUserListingCriteria(): UserListingCriteria {
return {
start: 0,
length: 12,
page: 1,
city: null,
types: [],
prompt: '',
criteriaType: 'brokerListings',
brokerName: '',
companyName: '',
counties: [],
state: '',
searchType: 'exact',
radius: null,
};
}
export function resetBusinessListingCriteria(criteria: BusinessListingCriteria) {
criteria.start = 0;
criteria.length = 0;
criteria.page = 0;
criteria.state = null;
criteria.city = null;
criteria.types = [];
criteria.prompt = '';
criteria.criteriaType = 'businessListings';
criteria.minPrice = null;
criteria.maxPrice = null;
criteria.minRevenue = null;
criteria.maxRevenue = null;
criteria.minCashFlow = null;
criteria.maxCashFlow = null;
criteria.minNumberEmployees = null;
criteria.maxNumberEmployees = null;
criteria.establishedMin = null;
criteria.realEstateChecked = false;
criteria.leasedLocation = false;
criteria.franchiseResale = false;
criteria.title = '';
criteria.brokerName = '';
criteria.searchType = 'exact';
criteria.radius = null;
}
export function resetCommercialPropertyListingCriteria(criteria: CommercialPropertyListingCriteria) {
criteria.start = 0;
criteria.length = 0;
criteria.page = 0;
criteria.state = null;
criteria.city = null;
criteria.types = [];
criteria.prompt = '';
criteria.criteriaType = 'commercialPropertyListings';
criteria.minPrice = null;
criteria.maxPrice = null;
criteria.title = '';
criteria.searchType = 'exact';
criteria.radius = null;
}
export function resetUserListingCriteria(criteria: UserListingCriteria) {
criteria.start = 0;
criteria.length = 0;
criteria.page = 0;
criteria.city = null;
criteria.types = [];
criteria.prompt = '';
criteria.criteriaType = 'brokerListings';
criteria.brokerName = '';
criteria.companyName = '';
criteria.counties = [];
criteria.state = '';
criteria.searchType = 'exact';
criteria.radius = null;
}
export function createMailInfo(user?: User): MailInfo {
return {
sender: user
? { name: `${user.firstname} ${user.lastname}`, email: user.email, phoneNumber: user.phoneNumber, state: user.location?.state, comments: null }
: { name: '', email: '', phoneNumber: '', state: '', comments: '' },
email: null,
url: environment.mailinfoUrl,
listing: null,
};
}
export function createLogger(name: string, level: number = INFO, options: any = {}) {
return _createLogger({
name,
streams: [{ level, stream: new ConsoleFormattedStream() }],
serializers: stdSerializers,
src: true,
...options,
});
}
export function formatPhoneNumber(phone: string): string {
const cleaned = ('' + phone).replace(/\D/g, '');
const match = cleaned.match(/^(\d{3})(\d{3})(\d{4})$/);
if (match) {
return '(' + match[1] + ') ' + match[2] + '-' + match[3];
}
return phone;
}
export const getSessionStorageHandler = function (criteriaType, path, value, previous, applyData) {
if (typeof sessionStorage !== 'undefined') {
sessionStorage.setItem(`${criteriaType}_criteria`, JSON.stringify(this));
console.log('Zusätzlicher Parameter:', criteriaType);
}
};
export const getSessionStorageHandlerWrapper = param => {
return function (path, value, previous, applyData) {
getSessionStorageHandler.call(this, param, path, value, previous, applyData);
};
};
export function routeListingWithState(router: Router, value: string, data: any) {
if (value === 'business') {
router.navigate(['createBusinessListing'], { state: { data } });
} else {
router.navigate(['createCommercialPropertyListing'], { state: { data } });
}
}
export function map2User(jwt: string | null): KeycloakUser {
if (jwt) {
const token = jwtDecode<JwtToken>(jwt);
return {
id: token.user_id,
firstName: token.given_name,
lastName: token.family_name,
email: token.email,
};
} else {
return null;
}
}
export function getImageDimensions(imageUrl: string): Promise<{ width: number; height: number }> {
return new Promise(resolve => {
// Only use Image in browser context
if (typeof Image === 'undefined') {
resolve({ width: 0, height: 0 });
return;
}
const img = new Image();
img.onload = () => {
resolve({ width: img.width, height: img.height });
};
img.src = imageUrl;
});
}
export function getDialogWidth(dimensions): string {
const aspectRatio = dimensions.width / dimensions.height;
let dialogWidth = '50vw'; // Standardbreite
// Passen Sie die Breite basierend auf dem Seitenverhältnis an
if (aspectRatio < 1) {
dialogWidth = '30vw'; // Hochformat
} else if (aspectRatio > 1) {
dialogWidth = '50vw'; // Querformat
}
return dialogWidth;
}
export function compareObjects<T extends object>(obj1: T, obj2: T, ignoreProperties: (keyof T)[] = []): number {
let differences = 0;
const keys = Object.keys(obj1) as Array<keyof T>;
for (const key of keys) {
if (ignoreProperties.includes(key)) {
continue; // Überspringe diese Eigenschaft, wenn sie in der Ignore-Liste ist
}
const value1 = obj1[key];
const value2 = obj2[key];
if (!areValuesEqual(value1, value2)) {
differences++;
}
}
return differences;
}
function areValuesEqual(value1: any, value2: any): boolean {
if (Array.isArray(value1) || Array.isArray(value2)) {
return arraysEqual(value1, value2);
}
if (typeof value1 === 'string' || typeof value2 === 'string') {
return isEqualString(value1, value2);
}
if (typeof value1 === 'number' || typeof value2 === 'number') {
return isEqualNumber(value1, value2);
}
if (typeof value1 === 'boolean' || typeof value2 === 'boolean') {
return isEqualBoolean(value1, value2);
}
return value1 === value2;
}
function isEqualString(value1: any, value2: any): boolean {
const isEmptyOrNullish1 = value1 === undefined || value1 === null || value1 === '';
const isEmptyOrNullish2 = value2 === undefined || value2 === null || value2 === '';
return (isEmptyOrNullish1 && isEmptyOrNullish2) || value1 === value2;
}
function isEqualNumber(value1: any, value2: any): boolean {
const isZeroOrNullish1 = value1 === undefined || value1 === null || value1 === 0;
const isZeroOrNullish2 = value2 === undefined || value2 === null || value2 === 0;
return (isZeroOrNullish1 && isZeroOrNullish2) || value1 === value2;
}
function isEqualBoolean(value1: any, value2: any): boolean {
const isFalseOrNullish1 = value1 === undefined || value1 === null || value1 === false;
const isFalseOrNullish2 = value2 === undefined || value2 === null || value2 === false;
return (isFalseOrNullish1 && isFalseOrNullish2) || value1 === value2;
}
function arraysEqual(arr1: any[] | null | undefined, arr2: any[] | null | undefined): boolean {
if (arr1 === arr2) return true;
if (arr1 == null || arr2 == null) return false;
if (arr1.length !== arr2.length) return false;
for (let i = 0; i < arr1.length; i++) {
if (!areValuesEqual(arr1[i], arr2[i])) return false;
}
return true;
}
export function assignProperties(target, source) {
for (let key in source) {
if (source.hasOwnProperty(key)) {
target[key] = source[key];
}
}
return target;
}
export function checkAndUpdate(changed: boolean, condition: boolean, assignment: () => any): boolean {
if (condition) {
assignment();
}
return changed || condition;
}
export function removeSortByStorage() {
if (typeof sessionStorage !== 'undefined') {
sessionStorage.removeItem('businessSortBy');
sessionStorage.removeItem('commercialSortBy');
sessionStorage.removeItem('professionalsSortBy');
}
}
// -----------------------------
// Criteria Proxy
// -----------------------------
export function getCriteriaStateObject(criteriaType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings') {
let initialState;
if (criteriaType === 'businessListings') {
initialState = createEmptyBusinessListingCriteria();
} else if (criteriaType === 'commercialPropertyListings') {
initialState = createEmptyCommercialPropertyListingCriteria();
} else {
initialState = createEmptyUserListingCriteria();
}
if (typeof sessionStorage !== 'undefined') {
const storedState = sessionStorage.getItem(`${criteriaType}`);
return storedState ? JSON.parse(storedState) : initialState;
}
return initialState;
}
export function getCriteriaProxy(path: string, component: any): BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria {
if ('businessListings' === path) {
return createEnhancedProxy(getCriteriaStateObject('businessListings'), component);
} else if ('commercialPropertyListings' === path) {
return createEnhancedProxy(getCriteriaStateObject('commercialPropertyListings'), component);
} else if ('brokerListings' === path) {
return createEnhancedProxy(getCriteriaStateObject('brokerListings'), component);
} else {
return undefined;
}
}
export function createEnhancedProxy(obj: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria, component: any) {
const sessionStorageHandler = function (path, value, previous, applyData) {
if (typeof sessionStorage !== 'undefined') {
sessionStorage.setItem(`${obj.criteriaType}`, JSON.stringify(this));
}
};
return onChange(obj, function (path, value, previous, applyData) {
// Call the original sessionStorageHandler
sessionStorageHandler.call(this, path, value, previous, applyData);
// Notify about the criteria change using the component's context
if (component.criteriaChangeService) {
component.criteriaChangeService.notifyCriteriaChange();
}
});
}
export function getCriteriaByListingCategory(listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty') {
if (typeof sessionStorage === 'undefined') return null;
const storedState =
listingsCategory === 'business'
? sessionStorage.getItem('businessListings')
: listingsCategory === 'commercialProperty'
? sessionStorage.getItem('commercialPropertyListings')
: sessionStorage.getItem('brokerListings');
return storedState ? JSON.parse(storedState) : null;
}
export function getSortByListingCategory(listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty') {
if (typeof sessionStorage === 'undefined') return null;
const storedSortBy =
listingsCategory === 'business' ? sessionStorage.getItem('businessSortBy') : listingsCategory === 'commercialProperty' ? sessionStorage.getItem('commercialSortBy') : sessionStorage.getItem('professionalsSortBy');
const sortBy = storedSortBy && storedSortBy !== 'null' ? (storedSortBy as SortByOptions) : null;
return sortBy;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 MiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@@ -1,6 +1,6 @@
// Build information, automatically generated by `the_build_script` :zwinkern:
const build = {
timestamp: "GER: 06.01.2026 22:33 | TX: 01/06/2026 3:33 PM"
};
// Build information, automatically generated by `the_build_script` :zwinkern:
const build = {
timestamp: "GER: 06.01.2026 22:33 | TX: 01/06/2026 3:33 PM"
};
export default build;

View File

@@ -1,23 +1,23 @@
// SSR-safe: check if window exists (it doesn't on server-side)
const hostname = typeof window !== 'undefined' ? window.location.hostname : 'localhost';
export const environment_base = {
// apiBaseUrl: 'http://localhost:3000',
apiBaseUrl: `http://${hostname}:4200`,
imageBaseUrl: 'https://dev.bizmatch.net',
buildVersion: '<BUILD_VERSION>',
mailinfoUrl: 'https://dev.bizmatch.net',
ipinfo_token: '7029590fb91214',
firebaseConfig: {
apiKey: 'AIzaSyBqVutQqdgUzwD9tKiKJrJq2Q6rD1hNdzw',
//authDomain: 'bizmatch-net.firebaseapp.com',
authDomain: 'auth.bizmatch.net',
projectId: 'bizmatch-net',
storageBucket: 'bizmatch-net.firebasestorage.app',
messagingSenderId: '1065122571067',
appId: '1:1065122571067:web:1124571ab67bc0f5240d1e',
measurementId: 'G-MHVDK1KSWV',
},
POSTHOG_KEY: '',
POSTHOG_HOST: '',
production: false,
};
// SSR-safe: check if window exists (it doesn't on server-side)
const hostname = typeof window !== 'undefined' ? window.location.hostname : 'localhost';
export const environment_base = {
// apiBaseUrl: 'http://localhost:3000',
apiBaseUrl: `http://${hostname}:4200`,
imageBaseUrl: 'https://dev.bizmatch.net',
buildVersion: '<BUILD_VERSION>',
mailinfoUrl: 'https://dev.bizmatch.net',
ipinfo_token: '7029590fb91214',
firebaseConfig: {
apiKey: 'AIzaSyBqVutQqdgUzwD9tKiKJrJq2Q6rD1hNdzw',
//authDomain: 'bizmatch-net.firebaseapp.com',
authDomain: 'auth.bizmatch.net',
projectId: 'bizmatch-net',
storageBucket: 'bizmatch-net.firebasestorage.app',
messagingSenderId: '1065122571067',
appId: '1:1065122571067:web:1124571ab67bc0f5240d1e',
measurementId: 'G-MHVDK1KSWV',
},
POSTHOG_KEY: '',
POSTHOG_HOST: '',
production: false,
};

View File

@@ -1,7 +1,7 @@
import { environment_base } from './environment.base';
export const environment = environment_base;
environment.apiBaseUrl = 'http://bizsearch.at-powan.ts.net:3001';
environment.mailinfoUrl = 'http://bizsearch.at-powan.ts.net';
environment.imageBaseUrl = 'http://bizsearch.at-powan.ts.net';
import { environment_base } from './environment.base';
export const environment = environment_base;
environment.apiBaseUrl = 'http://bizsearch.at-powan.ts.net:3001';
environment.mailinfoUrl = 'http://bizsearch.at-powan.ts.net';
environment.imageBaseUrl = 'http://bizsearch.at-powan.ts.net';

View File

@@ -1,10 +1,10 @@
import { environment_base } from './environment.base';
export const environment = environment_base;
environment.production = true;
environment.apiBaseUrl = 'https://api.bizmatch.net';
environment.mailinfoUrl = 'https://www.bizmatch.net';
environment.imageBaseUrl = 'https://api.bizmatch.net';
environment.POSTHOG_KEY = 'phc_eUIcIq0UPVzEDtZLy78klKhGudyagBz3goDlKx8SQFe';
environment.POSTHOG_HOST = 'https://eu.i.posthog.com';
import { environment_base } from './environment.base';
export const environment = environment_base;
environment.production = true;
environment.apiBaseUrl = 'https://api.bizmatch.net';
environment.mailinfoUrl = 'https://www.bizmatch.net';
environment.imageBaseUrl = 'https://api.bizmatch.net';
environment.POSTHOG_KEY = 'phc_eUIcIq0UPVzEDtZLy78klKhGudyagBz3goDlKx8SQFe';
environment.POSTHOG_HOST = 'https://eu.i.posthog.com';

View File

@@ -1,5 +1,5 @@
import { environment_base } from './environment.base';
export const environment = environment_base;
environment.mailinfoUrl = 'http://localhost:4200';
environment.imageBaseUrl = 'http://localhost:3001';
import { environment_base } from './environment.base';
export const environment = environment_base;
environment.mailinfoUrl = 'http://localhost:4200';
environment.imageBaseUrl = 'http://localhost:3001';

View File

@@ -1,67 +1,67 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Bizmatch - Find Business for sale</title>
<meta name="description" content="Find or Sell Businesses and Restaurants" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Mobile App & Theme Meta Tags -->
<meta name="theme-color" content="#0066cc" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="BizMatch" />
<meta name="application-name" content="BizMatch" />
<meta name="msapplication-TileColor" content="#0066cc" />
<!-- Resource Hints for Performance -->
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="dns-prefetch" href="https://fonts.googleapis.com" />
<link rel="dns-prefetch" href="https://fonts.gstatic.com" />
<!-- Image CDN -->
<link rel="preconnect" href="https://dev.bizmatch.net" crossorigin />
<link rel="dns-prefetch" href="https://dev.bizmatch.net" />
<!-- Firebase Services -->
<link rel="preconnect" href="https://firebase.google.com" />
<link rel="preconnect" href="https://firebasestorage.googleapis.com" />
<link rel="dns-prefetch" href="https://firebasestorage.googleapis.com" />
<link rel="dns-prefetch" href="https://firebaseapp.com" />
<!-- Preload critical assets -->
<link rel="preload" as="image" href="/assets/images/header-logo.png" type="image/png" />
<!-- Prefetch common assets -->
<link rel="prefetch" as="image" href="/assets/images/business_logo.png" />
<link rel="prefetch" as="image" href="/assets/images/properties_logo.png" />
<link rel="prefetch" as="image" href="/assets/images/placeholder.png" />
<link rel="prefetch" as="image" href="/assets/images/person_placeholder.jpg" />
<meta name="robots" content="index, follow" />
<meta name="googlebot" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1" />
<meta name="bingbot" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1" />
<meta property="og:locale" content="en_US" />
<meta property="og:type" content="website" />
<meta property="og:title" content="front-page - BizMatch" />
<meta property="og:description"
content="We are dedicated to providing a simple to use way for people in business to get in contact with each other." />
<meta property="og:site_name" content="BizMatch" />
<meta property="article:modified_time" content="2016-11-17T15:57:10+00:00" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<base href="/" />
<link rel="icon" href="/assets/cropped-Favicon-32x32.png" sizes="32x32" />
<link rel="icon" href="/assets/cropped-Favicon-192x192.png" sizes="192x192" />
<link rel="apple-touch-icon" href="/assets/cropped-Favicon-180x180.png" />
</head>
<body class="flex flex-col min-h-screen">
<app-root></app-root>
</body>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Bizmatch - Find Business for sale</title>
<meta name="description" content="Find or Sell Businesses and Restaurants" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Mobile App & Theme Meta Tags -->
<meta name="theme-color" content="#0066cc" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="BizMatch" />
<meta name="application-name" content="BizMatch" />
<meta name="msapplication-TileColor" content="#0066cc" />
<!-- Resource Hints for Performance -->
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="dns-prefetch" href="https://fonts.googleapis.com" />
<link rel="dns-prefetch" href="https://fonts.gstatic.com" />
<!-- Image CDN -->
<link rel="preconnect" href="https://dev.bizmatch.net" crossorigin />
<link rel="dns-prefetch" href="https://dev.bizmatch.net" />
<!-- Firebase Services -->
<link rel="preconnect" href="https://firebase.google.com" />
<link rel="preconnect" href="https://firebasestorage.googleapis.com" />
<link rel="dns-prefetch" href="https://firebasestorage.googleapis.com" />
<link rel="dns-prefetch" href="https://firebaseapp.com" />
<!-- Preload critical assets -->
<link rel="preload" as="image" href="/assets/images/header-logo.png" type="image/png" />
<!-- Prefetch common assets -->
<link rel="prefetch" as="image" href="/assets/images/business_logo.png" />
<link rel="prefetch" as="image" href="/assets/images/properties_logo.png" />
<link rel="prefetch" as="image" href="/assets/images/placeholder.png" />
<link rel="prefetch" as="image" href="/assets/images/person_placeholder.jpg" />
<meta name="robots" content="index, follow" />
<meta name="googlebot" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1" />
<meta name="bingbot" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1" />
<meta property="og:locale" content="en_US" />
<meta property="og:type" content="website" />
<meta property="og:title" content="front-page - BizMatch" />
<meta property="og:description"
content="We are dedicated to providing a simple to use way for people in business to get in contact with each other." />
<meta property="og:site_name" content="BizMatch" />
<meta property="article:modified_time" content="2016-11-17T15:57:10+00:00" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<base href="/" />
<link rel="icon" href="/assets/cropped-Favicon-32x32.png" sizes="32x32" />
<link rel="icon" href="/assets/cropped-Favicon-192x192.png" sizes="192x192" />
<link rel="apple-touch-icon" href="/assets/cropped-Favicon-180x180.png" />
</head>
<body class="flex flex-col min-h-screen">
<app-root></app-root>
</body>
</html>

View File

@@ -1,20 +1,20 @@
// IMPORTANT: DOM polyfill must be imported FIRST, before any browser-dependent libraries
import './ssr-dom-polyfill';
import { bootstrapApplication, BootstrapContext } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { config } from './app/app.config.server';
const bootstrap = (context: BootstrapContext) => {
console.log('[SSR] Bootstrap function called');
const appRef = bootstrapApplication(AppComponent, config, context);
appRef.then(() => {
console.log('[SSR] Application bootstrapped successfully');
}).catch((err) => {
console.error('[SSR] Bootstrap error:', err);
console.error('[SSR] Error stack:', err.stack);
});
return appRef;
};
export default bootstrap;
// IMPORTANT: DOM polyfill must be imported FIRST, before any browser-dependent libraries
import './ssr-dom-polyfill';
import { bootstrapApplication, BootstrapContext } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { config } from './app/app.config.server';
const bootstrap = (context: BootstrapContext) => {
console.log('[SSR] Bootstrap function called');
const appRef = bootstrapApplication(AppComponent, config, context);
appRef.then(() => {
console.log('[SSR] Application bootstrapped successfully');
}).catch((err) => {
console.error('[SSR] Bootstrap error:', err);
console.error('[SSR] Error stack:', err.stack);
});
return appRef;
};
export default bootstrap;

View File

@@ -1,140 +1,140 @@
# robots.txt for BizMatch - Business Marketplace
# https://biz-match.com
# Last updated: 2026-01-02
# ===========================================
# Default rules for all crawlers
# ===========================================
User-agent: *
# Allow all public pages
Allow: /
Allow: /home
Allow: /businessListings
Allow: /commercialPropertyListings
Allow: /brokerListings
Allow: /business/*
Allow: /commercial-property/*
Allow: /details-user/*
Allow: /terms-of-use
Allow: /privacy-statement
# Disallow private/admin areas
Disallow: /admin/
Disallow: /account
Disallow: /myListings
Disallow: /myFavorites
Disallow: /createBusinessListing
Disallow: /createCommercialPropertyListing
Disallow: /editBusinessListing/*
Disallow: /editCommercialPropertyListing/*
Disallow: /login
Disallow: /logout
Disallow: /register
Disallow: /emailUs
# Disallow duplicate content / API routes
Disallow: /api/
Disallow: /bizmatch/
# Disallow search result pages with parameters (to avoid duplicate content)
Disallow: /*?*sortBy=
Disallow: /*?*page=
Disallow: /*?*start=
# ===========================================
# Google-specific rules
# ===========================================
User-agent: Googlebot
Allow: /
Crawl-delay: 1
# Allow Google to index images
User-agent: Googlebot-Image
Allow: /assets/
Disallow: /assets/leaflet/
# ===========================================
# Bing-specific rules
# ===========================================
User-agent: Bingbot
Allow: /
Crawl-delay: 2
# ===========================================
# Other major search engines
# ===========================================
User-agent: DuckDuckBot
Allow: /
Crawl-delay: 2
User-agent: Slurp
Allow: /
Crawl-delay: 2
User-agent: Yandex
Allow: /
Crawl-delay: 5
User-agent: Baiduspider
Allow: /
Crawl-delay: 5
# ===========================================
# AI/LLM Crawlers (Answer Engine Optimization)
# ===========================================
User-agent: GPTBot
Allow: /
Allow: /businessListings
Allow: /business/*
Disallow: /admin/
Disallow: /account
User-agent: ChatGPT-User
Allow: /
User-agent: Claude-Web
Allow: /
User-agent: Anthropic-AI
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: Cohere-ai
Allow: /
# ===========================================
# Block unwanted bots
# ===========================================
User-agent: AhrefsBot
Disallow: /
User-agent: SemrushBot
Disallow: /
User-agent: MJ12bot
Disallow: /
User-agent: DotBot
Disallow: /
User-agent: BLEXBot
Disallow: /
# ===========================================
# Sitemap locations
# ===========================================
# Main sitemap index (dynamically generated, contains all sub-sitemaps)
Sitemap: https://biz-match.com/bizmatch/sitemap.xml
# Individual sitemaps (auto-listed in sitemap index)
# - https://biz-match.com/bizmatch/sitemap/static.xml
# - https://biz-match.com/bizmatch/sitemap/business-1.xml
# - https://biz-match.com/bizmatch/sitemap/commercial-1.xml
# ===========================================
# Host directive (for Yandex)
# ===========================================
Host: https://biz-match.com
# robots.txt for BizMatch - Business Marketplace
# https://biz-match.com
# Last updated: 2026-01-02
# ===========================================
# Default rules for all crawlers
# ===========================================
User-agent: *
# Allow all public pages
Allow: /
Allow: /home
Allow: /businessListings
Allow: /commercialPropertyListings
Allow: /brokerListings
Allow: /business/*
Allow: /commercial-property/*
Allow: /details-user/*
Allow: /terms-of-use
Allow: /privacy-statement
# Disallow private/admin areas
Disallow: /admin/
Disallow: /account
Disallow: /myListings
Disallow: /myFavorites
Disallow: /createBusinessListing
Disallow: /createCommercialPropertyListing
Disallow: /editBusinessListing/*
Disallow: /editCommercialPropertyListing/*
Disallow: /login
Disallow: /logout
Disallow: /register
Disallow: /emailUs
# Disallow duplicate content / API routes
Disallow: /api/
Disallow: /bizmatch/
# Disallow search result pages with parameters (to avoid duplicate content)
Disallow: /*?*sortBy=
Disallow: /*?*page=
Disallow: /*?*start=
# ===========================================
# Google-specific rules
# ===========================================
User-agent: Googlebot
Allow: /
Crawl-delay: 1
# Allow Google to index images
User-agent: Googlebot-Image
Allow: /assets/
Disallow: /assets/leaflet/
# ===========================================
# Bing-specific rules
# ===========================================
User-agent: Bingbot
Allow: /
Crawl-delay: 2
# ===========================================
# Other major search engines
# ===========================================
User-agent: DuckDuckBot
Allow: /
Crawl-delay: 2
User-agent: Slurp
Allow: /
Crawl-delay: 2
User-agent: Yandex
Allow: /
Crawl-delay: 5
User-agent: Baiduspider
Allow: /
Crawl-delay: 5
# ===========================================
# AI/LLM Crawlers (Answer Engine Optimization)
# ===========================================
User-agent: GPTBot
Allow: /
Allow: /businessListings
Allow: /business/*
Disallow: /admin/
Disallow: /account
User-agent: ChatGPT-User
Allow: /
User-agent: Claude-Web
Allow: /
User-agent: Anthropic-AI
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: Cohere-ai
Allow: /
# ===========================================
# Block unwanted bots
# ===========================================
User-agent: AhrefsBot
Disallow: /
User-agent: SemrushBot
Disallow: /
User-agent: MJ12bot
Disallow: /
User-agent: DotBot
Disallow: /
User-agent: BLEXBot
Disallow: /
# ===========================================
# Sitemap locations
# ===========================================
# Main sitemap index (dynamically generated, contains all sub-sitemaps)
Sitemap: https://biz-match.com/bizmatch/sitemap.xml
# Individual sitemaps (auto-listed in sitemap index)
# - https://biz-match.com/bizmatch/sitemap/static.xml
# - https://biz-match.com/bizmatch/sitemap/business-1.xml
# - https://biz-match.com/bizmatch/sitemap/commercial-1.xml
# ===========================================
# Host directive (for Yandex)
# ===========================================
Host: https://biz-match.com

View File

@@ -1,142 +1,142 @@
// Use @tailwind directives instead of @import both to silence deprecation warnings
// and because it's the recommended Tailwind CSS syntax
@tailwind base;
@tailwind components;
@tailwind utilities;
// External CSS imports - these URL imports don't trigger deprecation warnings
@import url('https://fonts.googleapis.com/css?family=Open+Sans&display=swap');
@import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css');
// Local CSS files loaded as CSS (not SCSS) to avoid @import deprecation
// Note: These are loaded via angular.json styles array is the preferred approach,
// but for now we keep them here for simplicity
@import '@ng-select/ng-select/themes/default.theme.css';
:root {
--text-color-secondary: rgba(255, 255, 255);
--wrapper-width: 1491px;
// --secondary-color: #ffffff; /* Setzt die secondary Farbe auf weiß */
}
.p-button.p-button-secondary.p-button-outlined {
color: #ffffff;
}
html,
body,
app-root {
margin: 0;
height: 100%;
&:hover a {
cursor: pointer;
}
}
app-root {
display: flex;
flex-direction: column;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
}
body,
input,
button,
select,
textarea {
// font-family: 'Open Sans', sans-serif;
font-family: var(--font-family);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.wrapper {
min-height: 100%;
display: flex;
flex-direction: column;
}
// header {
// height: 64px; /* Feste Höhe */
// }
main {
flex: 1 0 auto;
/* Füllt den verfügbaren Platz */
}
footer {
flex-shrink: 0;
/* Verhindert Schrumpfen */
}
*:focus,
.p-focus {
box-shadow: none !important;
}
p-menubarsub ul {
gap: 4px;
}
::-webkit-scrollbar {
width: 3px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: rgba(155, 155, 155, 0.5);
border-radius: 20px;
border: transparent;
}
.wrapper {
width: var(--wrapper-width);
max-width: 100%;
height: 100%;
margin: auto;
}
.p-editor-container .ql-toolbar {
background: #f9fafb;
border-top-right-radius: 6px;
border-top-left-radius: 6px;
}
.p-dropdown-panel .p-dropdown-header .p-dropdown-filter {
margin-right: 0 !important;
}
input::placeholder,
textarea::placeholder {
color: #999 !important;
}
/* Fix für Marker-Icons in Leaflet */
.leaflet-container {
height: 100%;
width: 100%;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
.leaflet-marker-icon {
/* Optional: Anpassen der Marker-Icon-Größe */
width: 25px;
height: 41px;
// Use @tailwind directives instead of @import both to silence deprecation warnings
// and because it's the recommended Tailwind CSS syntax
@tailwind base;
@tailwind components;
@tailwind utilities;
// External CSS imports - these URL imports don't trigger deprecation warnings
@import url('https://fonts.googleapis.com/css?family=Open+Sans&display=swap');
@import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css');
// Local CSS files loaded as CSS (not SCSS) to avoid @import deprecation
// Note: These are loaded via angular.json styles array is the preferred approach,
// but for now we keep them here for simplicity
@import '@ng-select/ng-select/themes/default.theme.css';
:root {
--text-color-secondary: rgba(255, 255, 255);
--wrapper-width: 1491px;
// --secondary-color: #ffffff; /* Setzt die secondary Farbe auf weiß */
}
.p-button.p-button-secondary.p-button-outlined {
color: #ffffff;
}
html,
body,
app-root {
margin: 0;
height: 100%;
&:hover a {
cursor: pointer;
}
}
app-root {
display: flex;
flex-direction: column;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
}
body,
input,
button,
select,
textarea {
// font-family: 'Open Sans', sans-serif;
font-family: var(--font-family);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.wrapper {
min-height: 100%;
display: flex;
flex-direction: column;
}
// header {
// height: 64px; /* Feste Höhe */
// }
main {
flex: 1 0 auto;
/* Füllt den verfügbaren Platz */
}
footer {
flex-shrink: 0;
/* Verhindert Schrumpfen */
}
*:focus,
.p-focus {
box-shadow: none !important;
}
p-menubarsub ul {
gap: 4px;
}
::-webkit-scrollbar {
width: 3px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: rgba(155, 155, 155, 0.5);
border-radius: 20px;
border: transparent;
}
.wrapper {
width: var(--wrapper-width);
max-width: 100%;
height: 100%;
margin: auto;
}
.p-editor-container .ql-toolbar {
background: #f9fafb;
border-top-right-radius: 6px;
border-top-left-radius: 6px;
}
.p-dropdown-panel .p-dropdown-header .p-dropdown-filter {
margin-right: 0 !important;
}
input::placeholder,
textarea::placeholder {
color: #999 !important;
}
/* Fix für Marker-Icons in Leaflet */
.leaflet-container {
height: 100%;
width: 100%;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
.leaflet-marker-icon {
/* Optional: Anpassen der Marker-Icon-Größe */
width: 25px;
height: 41px;
}

View File

@@ -1,56 +1,56 @@
/* Lazy loading image styles */
img.lazy-loading {
filter: blur(5px);
opacity: 0.6;
transition: filter 0.3s ease-in-out, opacity 0.3s ease-in-out;
}
img.lazy-loaded {
filter: blur(0);
opacity: 1;
animation: fadeIn 0.3s ease-in-out;
}
img.lazy-error {
opacity: 0.5;
background-color: #f3f4f6;
}
@keyframes fadeIn {
from {
opacity: 0.6;
}
to {
opacity: 1;
}
}
/* Aspect ratio placeholders to prevent layout shift */
.img-container-16-9 {
position: relative;
padding-bottom: 56.25%; /* 16:9 aspect ratio */
overflow: hidden;
}
.img-container-4-3 {
position: relative;
padding-bottom: 75%; /* 4:3 aspect ratio */
overflow: hidden;
}
.img-container-1-1 {
position: relative;
padding-bottom: 100%; /* 1:1 aspect ratio (square) */
overflow: hidden;
}
.img-container-16-9 img,
.img-container-4-3 img,
.img-container-1-1 img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Lazy loading image styles */
img.lazy-loading {
filter: blur(5px);
opacity: 0.6;
transition: filter 0.3s ease-in-out, opacity 0.3s ease-in-out;
}
img.lazy-loaded {
filter: blur(0);
opacity: 1;
animation: fadeIn 0.3s ease-in-out;
}
img.lazy-error {
opacity: 0.5;
background-color: #f3f4f6;
}
@keyframes fadeIn {
from {
opacity: 0.6;
}
to {
opacity: 1;
}
}
/* Aspect ratio placeholders to prevent layout shift */
.img-container-16-9 {
position: relative;
padding-bottom: 56.25%; /* 16:9 aspect ratio */
overflow: hidden;
}
.img-container-4-3 {
position: relative;
padding-bottom: 75%; /* 4:3 aspect ratio */
overflow: hidden;
}
.img-container-1-1 {
position: relative;
padding-bottom: 100%; /* 1:1 aspect ratio (square) */
overflow: hidden;
}
.img-container-16-9 img,
.img-container-4-3 img,
.img-container-1-1 img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}

View File

@@ -1,122 +1,122 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
safelist: [
'text-red-400',
'text-purple-400',
'text-pink-400',
'text-teal-400',
'text-green-400',
'text-yellow-400',
'text-blue-400',
'text-cyan-400',
'text-gray-400',
'text-sky-400',
'text-orange-400',
'text-violet-400',
'text-indigo-400',
'text-amber-700'
// Fügen Sie hier alle möglichen Farbklassen hinzu, die dynamisch geladen werden könnten
],
content: [
"./src/**/*.{html,ts}",
"./node_modules/flowbite/**/*.js" // add this line
],
theme: {
extend: {
colors: {
// Primary Brand Color (Trust & Professionalism)
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6', // Main brand color
600: '#2563eb', // Primary hover
700: '#1d4ed8', // Active state
800: '#1e40af',
900: '#1e3a8a',
DEFAULT: '#3b82f6'
},
// Success/Opportunity Green (Money, Growth, Positive Actions)
success: {
50: '#ecfdf5',
100: '#d1fae5',
200: '#a7f3d0',
300: '#6ee7b7',
400: '#34d399',
500: '#10b981', // Main success color
600: '#059669', // Success hover
700: '#047857',
800: '#065f46',
900: '#064e3b',
DEFAULT: '#10b981'
},
// Premium/Featured Amber (Premium Listings, Featured Badges)
premium: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b', // Premium accent
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
DEFAULT: '#f59e0b'
},
// Info Teal (Updates, Information)
info: {
50: '#f0fdfa',
100: '#ccfbf1',
200: '#99f6e4',
300: '#5eead4',
400: '#2dd4bf',
500: '#14b8a6', // Info color
600: '#0d9488',
700: '#0f766e',
800: '#115e59',
900: '#134e4a',
DEFAULT: '#14b8a6'
},
// Warmer neutral grays (stone instead of gray)
neutral: {
50: '#fafaf9',
100: '#f5f5f4',
200: '#e7e5e4',
300: '#d6d3d1',
400: '#a8a29e',
500: '#78716c',
600: '#57534e',
700: '#44403c',
800: '#292524',
900: '#1c1917',
DEFAULT: '#78716c'
}
},
fontSize: {
'xs': '.75rem',
'sm': '.875rem',
'base': '1rem',
'lg': '1.125rem',
'xl': '1.25rem',
'2xl': '1.5rem',
'3xl': '1.875rem',
'4xl': '2.25rem',
'5xl': '3rem',
},
dropShadow: {
'custom-bg': '0 15px 20px rgba(0, 0, 0, 0.3)',
'custom-bg-mobile': '0 1px 2px rgba(0, 0, 0, 0.2)',
'inner-faint': '0 3px 6px rgba(0, 0, 0, 0.1)',
'custom-md': '0 10px 15px rgba(0, 0, 0, 0.25)',
'custom-lg': '0 15px 20px rgba(0, 0, 0, 0.3)'
}
},
},
plugins: [
require('flowbite/plugin') // add this line
],
}
/** @type {import('tailwindcss').Config} */
module.exports = {
safelist: [
'text-red-400',
'text-purple-400',
'text-pink-400',
'text-teal-400',
'text-green-400',
'text-yellow-400',
'text-blue-400',
'text-cyan-400',
'text-gray-400',
'text-sky-400',
'text-orange-400',
'text-violet-400',
'text-indigo-400',
'text-amber-700'
// Fügen Sie hier alle möglichen Farbklassen hinzu, die dynamisch geladen werden könnten
],
content: [
"./src/**/*.{html,ts}",
"./node_modules/flowbite/**/*.js" // add this line
],
theme: {
extend: {
colors: {
// Primary Brand Color (Trust & Professionalism)
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6', // Main brand color
600: '#2563eb', // Primary hover
700: '#1d4ed8', // Active state
800: '#1e40af',
900: '#1e3a8a',
DEFAULT: '#3b82f6'
},
// Success/Opportunity Green (Money, Growth, Positive Actions)
success: {
50: '#ecfdf5',
100: '#d1fae5',
200: '#a7f3d0',
300: '#6ee7b7',
400: '#34d399',
500: '#10b981', // Main success color
600: '#059669', // Success hover
700: '#047857',
800: '#065f46',
900: '#064e3b',
DEFAULT: '#10b981'
},
// Premium/Featured Amber (Premium Listings, Featured Badges)
premium: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b', // Premium accent
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
DEFAULT: '#f59e0b'
},
// Info Teal (Updates, Information)
info: {
50: '#f0fdfa',
100: '#ccfbf1',
200: '#99f6e4',
300: '#5eead4',
400: '#2dd4bf',
500: '#14b8a6', // Info color
600: '#0d9488',
700: '#0f766e',
800: '#115e59',
900: '#134e4a',
DEFAULT: '#14b8a6'
},
// Warmer neutral grays (stone instead of gray)
neutral: {
50: '#fafaf9',
100: '#f5f5f4',
200: '#e7e5e4',
300: '#d6d3d1',
400: '#a8a29e',
500: '#78716c',
600: '#57534e',
700: '#44403c',
800: '#292524',
900: '#1c1917',
DEFAULT: '#78716c'
}
},
fontSize: {
'xs': '.75rem',
'sm': '.875rem',
'base': '1rem',
'lg': '1.125rem',
'xl': '1.25rem',
'2xl': '1.5rem',
'3xl': '1.875rem',
'4xl': '2.25rem',
'5xl': '3rem',
},
dropShadow: {
'custom-bg': '0 15px 20px rgba(0, 0, 0, 0.3)',
'custom-bg-mobile': '0 1px 2px rgba(0, 0, 0, 0.2)',
'inner-faint': '0 3px 6px rgba(0, 0, 0, 0.1)',
'custom-md': '0 10px 15px rgba(0, 0, 0, 0.25)',
'custom-lg': '0 15px 20px rgba(0, 0, 0, 0.3)'
}
},
},
plugins: [
require('flowbite/plugin') // add this line
],
}

View File

@@ -1,38 +1,38 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": ".",
"outDir": "./dist/out-tsc",
"forceConsistentCasingInFileNames": true,
"strict": false,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "node",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"useDefineForClassFields": false,
"lib": [
"ES2022",
"dom"
],
"paths": {
"zod": ["node_modules/zod"],
}
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": ".",
"outDir": "./dist/out-tsc",
"forceConsistentCasingInFileNames": true,
"strict": false,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "node",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"useDefineForClassFields": false,
"lib": [
"ES2022",
"dom"
],
"paths": {
"zod": ["node_modules/zod"],
}
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}