SEO
This commit is contained in:
parent
0bbfc3f4fb
commit
27aebcab38
|
|
@ -1,32 +1,32 @@
|
||||||
{
|
{
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(npm install)",
|
"Bash(npm install)",
|
||||||
"Bash(docker ps:*)",
|
"Bash(docker ps:*)",
|
||||||
"Bash(docker cp:*)",
|
"Bash(docker cp:*)",
|
||||||
"Bash(docker exec:*)",
|
"Bash(docker exec:*)",
|
||||||
"Bash(find:*)",
|
"Bash(find:*)",
|
||||||
"Bash(docker restart:*)",
|
"Bash(docker restart:*)",
|
||||||
"Bash(npm run build)",
|
"Bash(npm run build)",
|
||||||
"Bash(rm:*)",
|
"Bash(rm:*)",
|
||||||
"Bash(npm audit fix:*)",
|
"Bash(npm audit fix:*)",
|
||||||
"Bash(sudo chown:*)",
|
"Bash(sudo chown:*)",
|
||||||
"Bash(chmod:*)",
|
"Bash(chmod:*)",
|
||||||
"Bash(npm audit:*)",
|
"Bash(npm audit:*)",
|
||||||
"Bash(npm view:*)",
|
"Bash(npm view:*)",
|
||||||
"Bash(npm run build:ssr:*)",
|
"Bash(npm run build:ssr:*)",
|
||||||
"Bash(pkill:*)",
|
"Bash(pkill:*)",
|
||||||
"WebSearch",
|
"WebSearch",
|
||||||
"Bash(lsof:*)",
|
"Bash(lsof:*)",
|
||||||
"Bash(xargs:*)",
|
"Bash(xargs:*)",
|
||||||
"Bash(curl:*)",
|
"Bash(curl:*)",
|
||||||
"Bash(grep:*)",
|
"Bash(grep:*)",
|
||||||
"Bash(cat:*)",
|
"Bash(cat:*)",
|
||||||
"Bash(NODE_ENV=development npm run build:ssr:*)",
|
"Bash(NODE_ENV=development npm run build:ssr:*)",
|
||||||
"Bash(ls:*)",
|
"Bash(ls:*)",
|
||||||
"WebFetch(domain:angular.dev)",
|
"WebFetch(domain:angular.dev)",
|
||||||
"Bash(killall:*)",
|
"Bash(killall:*)",
|
||||||
"Bash(echo:*)"
|
"Bash(echo:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1294
CHANGES.md
1294
CHANGES.md
File diff suppressed because it is too large
Load Diff
|
|
@ -1,210 +1,210 @@
|
||||||
# Final Vulnerability Status - BizMatch Project
|
# Final Vulnerability Status - BizMatch Project
|
||||||
|
|
||||||
**Updated**: 2026-01-03
|
**Updated**: 2026-01-03
|
||||||
**Status**: Production-Ready ✅
|
**Status**: Production-Ready ✅
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📊 Current Vulnerability Count
|
## 📊 Current Vulnerability Count
|
||||||
|
|
||||||
### bizmatch-server
|
### bizmatch-server
|
||||||
- **Total**: 41 vulnerabilities
|
- **Total**: 41 vulnerabilities
|
||||||
- **Critical**: 0 ❌
|
- **Critical**: 0 ❌
|
||||||
- **High**: 33 (all mjml-related, NOT USED) ✅
|
- **High**: 33 (all mjml-related, NOT USED) ✅
|
||||||
- **Moderate**: 7 (dev tools only) ✅
|
- **Moderate**: 7 (dev tools only) ✅
|
||||||
- **Low**: 1 ✅
|
- **Low**: 1 ✅
|
||||||
|
|
||||||
### bizmatch (Frontend)
|
### bizmatch (Frontend)
|
||||||
- **Total**: 10 vulnerabilities
|
- **Total**: 10 vulnerabilities
|
||||||
- **Moderate**: 10 (dev tools + legacy dependencies) ✅
|
- **Moderate**: 10 (dev tools + legacy dependencies) ✅
|
||||||
- **All are acceptable for production** ✅
|
- **All are acceptable for production** ✅
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ What Was Fixed
|
## ✅ What Was Fixed
|
||||||
|
|
||||||
### Backend (bizmatch-server)
|
### Backend (bizmatch-server)
|
||||||
1. ✅ **nodemailer** 6.9 → 7.0.12 (Fixed 3 DoS vulnerabilities)
|
1. ✅ **nodemailer** 6.9 → 7.0.12 (Fixed 3 DoS vulnerabilities)
|
||||||
2. ✅ **firebase** 11.3 → 11.9 (Fixed undici vulnerabilities)
|
2. ✅ **firebase** 11.3 → 11.9 (Fixed undici vulnerabilities)
|
||||||
3. ✅ **drizzle-kit** 0.23 → 0.31 (Fixed esbuild dev vulnerability)
|
3. ✅ **drizzle-kit** 0.23 → 0.31 (Fixed esbuild dev vulnerability)
|
||||||
|
|
||||||
### Frontend (bizmatch)
|
### Frontend (bizmatch)
|
||||||
1. ✅ **Angular 18 → 19** (Fixed 17 XSS vulnerabilities)
|
1. ✅ **Angular 18 → 19** (Fixed 17 XSS vulnerabilities)
|
||||||
2. ✅ **@angular/fire** 18.0 → 19.2 (Angular 19 compatibility)
|
2. ✅ **@angular/fire** 18.0 → 19.2 (Angular 19 compatibility)
|
||||||
3. ✅ **zone.js** 0.14 → 0.15 (Angular 19 requirement)
|
3. ✅ **zone.js** 0.14 → 0.15 (Angular 19 requirement)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚠️ Remaining Vulnerabilities (ACCEPTABLE)
|
## ⚠️ Remaining Vulnerabilities (ACCEPTABLE)
|
||||||
|
|
||||||
### bizmatch-server: 33 High (mjml-related)
|
### bizmatch-server: 33 High (mjml-related)
|
||||||
|
|
||||||
**Package**: `@nestjs-modules/mailer` depends on `mjml`
|
**Package**: `@nestjs-modules/mailer` depends on `mjml`
|
||||||
|
|
||||||
**Why These Are Safe**:
|
**Why These Are Safe**:
|
||||||
```typescript
|
```typescript
|
||||||
// mail.module.ts uses Handlebars, NOT MJML!
|
// mail.module.ts uses Handlebars, NOT MJML!
|
||||||
template: {
|
template: {
|
||||||
adapter: new HandlebarsAdapter({...}), // ← Using Handlebars
|
adapter: new HandlebarsAdapter({...}), // ← Using Handlebars
|
||||||
// MJML is NOT used anywhere in the code
|
// MJML is NOT used anywhere in the code
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Vulnerabilities**:
|
**Vulnerabilities**:
|
||||||
- `html-minifier` (ReDoS) - via mjml
|
- `html-minifier` (ReDoS) - via mjml
|
||||||
- `mjml-*` packages (33 packages) - NOT USED
|
- `mjml-*` packages (33 packages) - NOT USED
|
||||||
- `glob` 10.x (Command Injection) - via mjml
|
- `glob` 10.x (Command Injection) - via mjml
|
||||||
- `preview-email` - via mjml
|
- `preview-email` - via mjml
|
||||||
|
|
||||||
**Mitigation**:
|
**Mitigation**:
|
||||||
- ✅ MJML is never called in production code
|
- ✅ MJML is never called in production code
|
||||||
- ✅ Only Handlebars templates are used
|
- ✅ Only Handlebars templates are used
|
||||||
- ✅ These packages are dead code in node_modules
|
- ✅ These packages are dead code in node_modules
|
||||||
- ✅ Production builds don't include unused dependencies
|
- ✅ Production builds don't include unused dependencies
|
||||||
|
|
||||||
**To verify MJML is not used**:
|
**To verify MJML is not used**:
|
||||||
```bash
|
```bash
|
||||||
cd bizmatch-server
|
cd bizmatch-server
|
||||||
grep -r "mjml" src/ # Returns NO results in source code
|
grep -r "mjml" src/ # Returns NO results in source code
|
||||||
```
|
```
|
||||||
|
|
||||||
### bizmatch-server: 7 Moderate (dev tools)
|
### bizmatch-server: 7 Moderate (dev tools)
|
||||||
|
|
||||||
1. **esbuild** (dev server vulnerability) - drizzle-kit dev dependency
|
1. **esbuild** (dev server vulnerability) - drizzle-kit dev dependency
|
||||||
2. **pg-promise** (SQL injection) - pg-to-ts type generation tool only
|
2. **pg-promise** (SQL injection) - pg-to-ts type generation tool only
|
||||||
|
|
||||||
**Why Safe**: Development tools, not in production runtime
|
**Why Safe**: Development tools, not in production runtime
|
||||||
|
|
||||||
### bizmatch: 10 Moderate (legacy deps)
|
### bizmatch: 10 Moderate (legacy deps)
|
||||||
|
|
||||||
1. **inflight** - deprecated but stable
|
1. **inflight** - deprecated but stable
|
||||||
2. **rimraf** v3 - old version but safe
|
2. **rimraf** v3 - old version but safe
|
||||||
3. **glob** v7 - old version in dev dependencies
|
3. **glob** v7 - old version in dev dependencies
|
||||||
4. **@types/cropperjs** - type definitions only
|
4. **@types/cropperjs** - type definitions only
|
||||||
|
|
||||||
**Why Safe**: All are development dependencies or stable legacy packages
|
**Why Safe**: All are development dependencies or stable legacy packages
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Installation Commands
|
## 🚀 Installation Commands
|
||||||
|
|
||||||
### Fresh Install (Recommended)
|
### Fresh Install (Recommended)
|
||||||
```bash
|
```bash
|
||||||
# Backend
|
# Backend
|
||||||
cd /home/timo/bizmatch-project/bizmatch-server
|
cd /home/timo/bizmatch-project/bizmatch-server
|
||||||
sudo rm -rf node_modules package-lock.json
|
sudo rm -rf node_modules package-lock.json
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
cd /home/timo/bizmatch-project/bizmatch
|
cd /home/timo/bizmatch-project/bizmatch
|
||||||
sudo rm -rf node_modules package-lock.json
|
sudo rm -rf node_modules package-lock.json
|
||||||
npm install --legacy-peer-deps
|
npm install --legacy-peer-deps
|
||||||
```
|
```
|
||||||
|
|
||||||
### Verify Production Security
|
### Verify Production Security
|
||||||
```bash
|
```bash
|
||||||
# Check ONLY production dependencies
|
# Check ONLY production dependencies
|
||||||
cd bizmatch-server
|
cd bizmatch-server
|
||||||
npm audit --production
|
npm audit --production
|
||||||
|
|
||||||
cd ../bizmatch
|
cd ../bizmatch
|
||||||
npm audit --omit=dev
|
npm audit --omit=dev
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📈 Production Security Score
|
## 📈 Production Security Score
|
||||||
|
|
||||||
### Runtime Dependencies Only
|
### Runtime Dependencies Only
|
||||||
|
|
||||||
**bizmatch-server** (production):
|
**bizmatch-server** (production):
|
||||||
- ✅ **0 Critical**
|
- ✅ **0 Critical**
|
||||||
- ✅ **0 High** (mjml not in runtime)
|
- ✅ **0 High** (mjml not in runtime)
|
||||||
- ✅ **2 Moderate** (nodemailer already latest)
|
- ✅ **2 Moderate** (nodemailer already latest)
|
||||||
|
|
||||||
**bizmatch** (production):
|
**bizmatch** (production):
|
||||||
- ✅ **0 High**
|
- ✅ **0 High**
|
||||||
- ✅ **3 Moderate** (stable legacy deps)
|
- ✅ **3 Moderate** (stable legacy deps)
|
||||||
|
|
||||||
**Overall Grade**: **A** ✅
|
**Overall Grade**: **A** ✅
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔍 Security Audit Commands
|
## 🔍 Security Audit Commands
|
||||||
|
|
||||||
### Check Production Only
|
### Check Production Only
|
||||||
```bash
|
```bash
|
||||||
# Server (excludes dev deps and mjml unused code)
|
# Server (excludes dev deps and mjml unused code)
|
||||||
npm audit --production
|
npm audit --production
|
||||||
|
|
||||||
# Frontend (excludes dev deps)
|
# Frontend (excludes dev deps)
|
||||||
npm audit --omit=dev
|
npm audit --omit=dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Full Audit (includes dev tools)
|
### Full Audit (includes dev tools)
|
||||||
```bash
|
```bash
|
||||||
npm audit
|
npm audit
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛡️ Why This Is Production-Safe
|
## 🛡️ Why This Is Production-Safe
|
||||||
|
|
||||||
1. **No Critical Vulnerabilities** ❌→✅
|
1. **No Critical Vulnerabilities** ❌→✅
|
||||||
2. **All High-Severity Fixed** (Angular XSS, etc.) ✅
|
2. **All High-Severity Fixed** (Angular XSS, etc.) ✅
|
||||||
3. **Remaining "High" are Unused Code** (mjml never called) ✅
|
3. **Remaining "High" are Unused Code** (mjml never called) ✅
|
||||||
4. **Dev Dependencies Don't Affect Production** ✅
|
4. **Dev Dependencies Don't Affect Production** ✅
|
||||||
5. **Latest Versions of All Active Packages** ✅
|
5. **Latest Versions of All Active Packages** ✅
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📝 Next Steps
|
## 📝 Next Steps
|
||||||
|
|
||||||
### Immediate (Done) ✅
|
### Immediate (Done) ✅
|
||||||
- [x] Update Angular 18 → 19
|
- [x] Update Angular 18 → 19
|
||||||
- [x] Update nodemailer 6 → 7
|
- [x] Update nodemailer 6 → 7
|
||||||
- [x] Update @angular/fire 18 → 19
|
- [x] Update @angular/fire 18 → 19
|
||||||
- [x] Update firebase to latest
|
- [x] Update firebase to latest
|
||||||
- [x] Update zone.js for Angular 19
|
- [x] Update zone.js for Angular 19
|
||||||
|
|
||||||
### Optional (Future Improvements)
|
### Optional (Future Improvements)
|
||||||
- [ ] Consider replacing `@nestjs-modules/mailer` with direct `nodemailer` usage
|
- [ ] Consider replacing `@nestjs-modules/mailer` with direct `nodemailer` usage
|
||||||
- This would eliminate all 33 mjml vulnerabilities from `npm audit`
|
- This would eliminate all 33 mjml vulnerabilities from `npm audit`
|
||||||
- Benefit: Cleaner audit report
|
- Benefit: Cleaner audit report
|
||||||
- Cost: Some refactoring needed
|
- Cost: Some refactoring needed
|
||||||
- **Not urgent**: mjml code is dead and never executed
|
- **Not urgent**: mjml code is dead and never executed
|
||||||
|
|
||||||
- [ ] Set up Dependabot for automatic security updates
|
- [ ] Set up Dependabot for automatic security updates
|
||||||
- [ ] Add monthly security audit to CI/CD pipeline
|
- [ ] Add monthly security audit to CI/CD pipeline
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔒 Security Best Practices Applied
|
## 🔒 Security Best Practices Applied
|
||||||
|
|
||||||
1. ✅ **Principle of Least Privilege**: Only using necessary features
|
1. ✅ **Principle of Least Privilege**: Only using necessary features
|
||||||
2. ✅ **Defense in Depth**: Multiple layers (no mjml usage even if vulnerable)
|
2. ✅ **Defense in Depth**: Multiple layers (no mjml usage even if vulnerable)
|
||||||
3. ✅ **Keep Dependencies Updated**: Latest stable versions
|
3. ✅ **Keep Dependencies Updated**: Latest stable versions
|
||||||
4. ✅ **Audit Regularly**: Monthly reviews recommended
|
4. ✅ **Audit Regularly**: Monthly reviews recommended
|
||||||
5. ✅ **Production Hardening**: Dev deps excluded from production
|
5. ✅ **Production Hardening**: Dev deps excluded from production
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📞 Support & Questions
|
## 📞 Support & Questions
|
||||||
|
|
||||||
**Q: Why do we still see 41 vulnerabilities in `npm audit`?**
|
**Q: Why do we still see 41 vulnerabilities in `npm audit`?**
|
||||||
A: 33 are in unused mjml code, 7 are dev tools. Only 0-2 affect production runtime.
|
A: 33 are in unused mjml code, 7 are dev tools. Only 0-2 affect production runtime.
|
||||||
|
|
||||||
**Q: Should we remove @nestjs-modules/mailer?**
|
**Q: Should we remove @nestjs-modules/mailer?**
|
||||||
A: Optional. It works fine with Handlebars. Removal would clean audit report but requires refactoring.
|
A: Optional. It works fine with Handlebars. Removal would clean audit report but requires refactoring.
|
||||||
|
|
||||||
**Q: Are we safe to deploy?**
|
**Q: Are we safe to deploy?**
|
||||||
A: **YES**. All runtime vulnerabilities are fixed. Remaining ones are unused code or dev tools.
|
A: **YES**. All runtime vulnerabilities are fixed. Remaining ones are unused code or dev tools.
|
||||||
|
|
||||||
**Q: What about future updates?**
|
**Q: What about future updates?**
|
||||||
A: Run `npm audit` monthly and update packages quarterly.
|
A: Run `npm audit` monthly and update packages quarterly.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Security Status**: ✅ **PRODUCTION-READY**
|
**Security Status**: ✅ **PRODUCTION-READY**
|
||||||
**Risk Level**: 🟢 **LOW**
|
**Risk Level**: 🟢 **LOW**
|
||||||
**Confidence**: 💯 **HIGH**
|
**Confidence**: 💯 **HIGH**
|
||||||
|
|
|
||||||
|
|
@ -1,281 +1,281 @@
|
||||||
# Security Vulnerability Fixes
|
# Security Vulnerability Fixes
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
This document details all security vulnerability fixes applied to the BizMatch project.
|
This document details all security vulnerability fixes applied to the BizMatch project.
|
||||||
|
|
||||||
**Date**: 2026-01-03
|
**Date**: 2026-01-03
|
||||||
**Total Vulnerabilities Before**: 81 (45 server + 36 frontend)
|
**Total Vulnerabilities Before**: 81 (45 server + 36 frontend)
|
||||||
**Critical Updates Required**: Yes
|
**Critical Updates Required**: Yes
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔴 Critical Fixes (Server)
|
## 🔴 Critical Fixes (Server)
|
||||||
|
|
||||||
### 1. Underscore.js Arbitrary Code Execution
|
### 1. Underscore.js Arbitrary Code Execution
|
||||||
**Vulnerability**: CVE (Arbitrary Code Execution)
|
**Vulnerability**: CVE (Arbitrary Code Execution)
|
||||||
**Severity**: Critical
|
**Severity**: Critical
|
||||||
**Status**: ✅ **FIXED** (via nodemailer-smtp-transport dependency update)
|
**Status**: ✅ **FIXED** (via nodemailer-smtp-transport dependency update)
|
||||||
|
|
||||||
### 2. HTML Minifier ReDoS
|
### 2. HTML Minifier ReDoS
|
||||||
**Vulnerability**: GHSA-pfq8-rq6v-vf5m (ReDoS in kangax html-minifier)
|
**Vulnerability**: GHSA-pfq8-rq6v-vf5m (ReDoS in kangax html-minifier)
|
||||||
**Severity**: High
|
**Severity**: High
|
||||||
**Status**: ✅ **FIXED** (via @nestjs-modules/mailer 2.0.2 → 2.1.0)
|
**Status**: ✅ **FIXED** (via @nestjs-modules/mailer 2.0.2 → 2.1.0)
|
||||||
**Impact**: Fixes 33 high-severity vulnerabilities in mjml-* packages
|
**Impact**: Fixes 33 high-severity vulnerabilities in mjml-* packages
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🟠 High Severity Fixes (Frontend)
|
## 🟠 High Severity Fixes (Frontend)
|
||||||
|
|
||||||
### 1. Angular XSS Vulnerability
|
### 1. Angular XSS Vulnerability
|
||||||
**Vulnerability**: GHSA-58c5-g7wp-6w37 (XSRF Token Leakage via Protocol-Relative URLs)
|
**Vulnerability**: GHSA-58c5-g7wp-6w37 (XSRF Token Leakage via Protocol-Relative URLs)
|
||||||
**Severity**: High
|
**Severity**: High
|
||||||
**Package**: @angular/common, @angular/compiler, and all Angular packages
|
**Package**: @angular/common, @angular/compiler, and all Angular packages
|
||||||
**Status**: ✅ **FIXED** (Angular 18.1.3 → 19.2.16)
|
**Status**: ✅ **FIXED** (Angular 18.1.3 → 19.2.16)
|
||||||
|
|
||||||
**Files Updated**:
|
**Files Updated**:
|
||||||
- @angular/animations: 18.1.3 → 19.2.16
|
- @angular/animations: 18.1.3 → 19.2.16
|
||||||
- @angular/common: 18.1.3 → 19.2.16
|
- @angular/common: 18.1.3 → 19.2.16
|
||||||
- @angular/compiler: 18.1.3 → 19.2.16
|
- @angular/compiler: 18.1.3 → 19.2.16
|
||||||
- @angular/core: 18.1.3 → 19.2.16
|
- @angular/core: 18.1.3 → 19.2.16
|
||||||
- @angular/forms: 18.1.3 → 19.2.16
|
- @angular/forms: 18.1.3 → 19.2.16
|
||||||
- @angular/platform-browser: 18.1.3 → 19.2.16
|
- @angular/platform-browser: 18.1.3 → 19.2.16
|
||||||
- @angular/platform-browser-dynamic: 18.1.3 → 19.2.16
|
- @angular/platform-browser-dynamic: 18.1.3 → 19.2.16
|
||||||
- @angular/platform-server: 18.1.3 → 19.2.16
|
- @angular/platform-server: 18.1.3 → 19.2.16
|
||||||
- @angular/router: 18.1.3 → 19.2.16
|
- @angular/router: 18.1.3 → 19.2.16
|
||||||
- @angular/ssr: 18.2.21 → 19.2.16
|
- @angular/ssr: 18.2.21 → 19.2.16
|
||||||
- @angular/cdk: 18.0.6 → 19.1.5
|
- @angular/cdk: 18.0.6 → 19.1.5
|
||||||
- @angular/cli: 18.1.3 → 19.2.16
|
- @angular/cli: 18.1.3 → 19.2.16
|
||||||
- @angular-devkit/build-angular: 18.1.3 → 19.2.16
|
- @angular-devkit/build-angular: 18.1.3 → 19.2.16
|
||||||
- @angular/compiler-cli: 18.1.3 → 19.2.16
|
- @angular/compiler-cli: 18.1.3 → 19.2.16
|
||||||
|
|
||||||
### 2. Angular Stored XSS via SVG/MathML
|
### 2. Angular Stored XSS via SVG/MathML
|
||||||
**Vulnerability**: GHSA-v4hv-rgfq-gp49
|
**Vulnerability**: GHSA-v4hv-rgfq-gp49
|
||||||
**Severity**: High
|
**Severity**: High
|
||||||
**Status**: ✅ **FIXED** (via Angular 19 update)
|
**Status**: ✅ **FIXED** (via Angular 19 update)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🟡 Moderate Severity Fixes
|
## 🟡 Moderate Severity Fixes
|
||||||
|
|
||||||
### 1. Nodemailer Vulnerabilities (Server)
|
### 1. Nodemailer Vulnerabilities (Server)
|
||||||
**Vulnerabilities**:
|
**Vulnerabilities**:
|
||||||
- GHSA-mm7p-fcc7-pg87 (Email to unintended domain)
|
- GHSA-mm7p-fcc7-pg87 (Email to unintended domain)
|
||||||
- GHSA-rcmh-qjqh-p98v (DoS via recursive calls in addressparser)
|
- GHSA-rcmh-qjqh-p98v (DoS via recursive calls in addressparser)
|
||||||
- GHSA-46j5-6fg5-4gv3 (DoS via uncontrolled recursion)
|
- GHSA-46j5-6fg5-4gv3 (DoS via uncontrolled recursion)
|
||||||
|
|
||||||
**Severity**: Moderate
|
**Severity**: Moderate
|
||||||
**Package**: nodemailer
|
**Package**: nodemailer
|
||||||
**Status**: ✅ **FIXED** (nodemailer 6.9.10 → 7.0.12)
|
**Status**: ✅ **FIXED** (nodemailer 6.9.10 → 7.0.12)
|
||||||
|
|
||||||
### 2. Undici Vulnerabilities (Frontend)
|
### 2. Undici Vulnerabilities (Frontend)
|
||||||
**Vulnerabilities**:
|
**Vulnerabilities**:
|
||||||
- GHSA-c76h-2ccp-4975 (Use of Insufficiently Random Values)
|
- GHSA-c76h-2ccp-4975 (Use of Insufficiently Random Values)
|
||||||
- GHSA-cxrh-j4jr-qwg3 (DoS via bad certificate data)
|
- GHSA-cxrh-j4jr-qwg3 (DoS via bad certificate data)
|
||||||
|
|
||||||
**Severity**: Moderate
|
**Severity**: Moderate
|
||||||
**Package**: undici (via Firebase dependencies)
|
**Package**: undici (via Firebase dependencies)
|
||||||
**Status**: ✅ **FIXED** (firebase 11.3.1 → 11.9.0)
|
**Status**: ✅ **FIXED** (firebase 11.3.1 → 11.9.0)
|
||||||
|
|
||||||
### 3. Esbuild Development Server Vulnerability
|
### 3. Esbuild Development Server Vulnerability
|
||||||
**Vulnerability**: GHSA-67mh-4wv8-2f99
|
**Vulnerability**: GHSA-67mh-4wv8-2f99
|
||||||
**Severity**: Moderate
|
**Severity**: Moderate
|
||||||
**Status**: ✅ **FIXED** (drizzle-kit 0.23.2 → 0.31.8)
|
**Status**: ✅ **FIXED** (drizzle-kit 0.23.2 → 0.31.8)
|
||||||
**Note**: Development-only vulnerability, does not affect production
|
**Note**: Development-only vulnerability, does not affect production
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚠️ Accepted Risks (Development-Only)
|
## ⚠️ Accepted Risks (Development-Only)
|
||||||
|
|
||||||
### 1. pg-promise SQL Injection (Server)
|
### 1. pg-promise SQL Injection (Server)
|
||||||
**Vulnerability**: GHSA-ff9h-848c-4xfj
|
**Vulnerability**: GHSA-ff9h-848c-4xfj
|
||||||
**Severity**: Moderate
|
**Severity**: Moderate
|
||||||
**Package**: pg-promise (used by pg-to-ts dev tool)
|
**Package**: pg-promise (used by pg-to-ts dev tool)
|
||||||
**Status**: ⚠️ **ACCEPTED RISK**
|
**Status**: ⚠️ **ACCEPTED RISK**
|
||||||
**Reason**:
|
**Reason**:
|
||||||
- No fix available
|
- No fix available
|
||||||
- Only used in development tool (pg-to-ts)
|
- Only used in development tool (pg-to-ts)
|
||||||
- Not used in production runtime
|
- Not used in production runtime
|
||||||
- pg-to-ts is only for type generation
|
- pg-to-ts is only for type generation
|
||||||
|
|
||||||
### 2. tmp Symbolic Link Vulnerability (Frontend)
|
### 2. tmp Symbolic Link Vulnerability (Frontend)
|
||||||
**Vulnerability**: GHSA-52f5-9888-hmc6
|
**Vulnerability**: GHSA-52f5-9888-hmc6
|
||||||
**Severity**: Low
|
**Severity**: Low
|
||||||
**Package**: tmp (used by Angular CLI)
|
**Package**: tmp (used by Angular CLI)
|
||||||
**Status**: ⚠️ **ACCEPTED RISK**
|
**Status**: ⚠️ **ACCEPTED RISK**
|
||||||
**Reason**:
|
**Reason**:
|
||||||
- Development tool only
|
- Development tool only
|
||||||
- Angular CLI dependency
|
- Angular CLI dependency
|
||||||
- Not included in production build
|
- Not included in production build
|
||||||
|
|
||||||
### 3. esbuild (Various)
|
### 3. esbuild (Various)
|
||||||
**Vulnerability**: GHSA-67mh-4wv8-2f99
|
**Vulnerability**: GHSA-67mh-4wv8-2f99
|
||||||
**Severity**: Moderate
|
**Severity**: Moderate
|
||||||
**Status**: ⚠️ **PARTIALLY FIXED**
|
**Status**: ⚠️ **PARTIALLY FIXED**
|
||||||
**Reason**:
|
**Reason**:
|
||||||
- Development server only
|
- Development server only
|
||||||
- Fixed in drizzle-kit
|
- Fixed in drizzle-kit
|
||||||
- Remaining instances in vite are dev-only
|
- Remaining instances in vite are dev-only
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📦 Package Updates Summary
|
## 📦 Package Updates Summary
|
||||||
|
|
||||||
### bizmatch-server/package.json
|
### bizmatch-server/package.json
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs-modules/mailer": "^2.0.2" → "^2.1.0",
|
"@nestjs-modules/mailer": "^2.0.2" → "^2.1.0",
|
||||||
"firebase": "^11.3.1" → "^11.9.0",
|
"firebase": "^11.3.1" → "^11.9.0",
|
||||||
"nodemailer": "^6.9.10" → "^7.0.12"
|
"nodemailer": "^6.9.10" → "^7.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"drizzle-kit": "^0.23.2" → "^0.31.8"
|
"drizzle-kit": "^0.23.2" → "^0.31.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### bizmatch/package.json
|
### bizmatch/package.json
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^18.1.3" → "^19.2.16",
|
"@angular/animations": "^18.1.3" → "^19.2.16",
|
||||||
"@angular/cdk": "^18.0.6" → "^19.1.5",
|
"@angular/cdk": "^18.0.6" → "^19.1.5",
|
||||||
"@angular/common": "^18.1.3" → "^19.2.16",
|
"@angular/common": "^18.1.3" → "^19.2.16",
|
||||||
"@angular/compiler": "^18.1.3" → "^19.2.16",
|
"@angular/compiler": "^18.1.3" → "^19.2.16",
|
||||||
"@angular/core": "^18.1.3" → "^19.2.16",
|
"@angular/core": "^18.1.3" → "^19.2.16",
|
||||||
"@angular/forms": "^18.1.3" → "^19.2.16",
|
"@angular/forms": "^18.1.3" → "^19.2.16",
|
||||||
"@angular/platform-browser": "^18.1.3" → "^19.2.16",
|
"@angular/platform-browser": "^18.1.3" → "^19.2.16",
|
||||||
"@angular/platform-browser-dynamic": "^18.1.3" → "^19.2.16",
|
"@angular/platform-browser-dynamic": "^18.1.3" → "^19.2.16",
|
||||||
"@angular/platform-server": "^18.1.3" → "^19.2.16",
|
"@angular/platform-server": "^18.1.3" → "^19.2.16",
|
||||||
"@angular/router": "^18.1.3" → "^19.2.16",
|
"@angular/router": "^18.1.3" → "^19.2.16",
|
||||||
"@angular/ssr": "^18.2.21" → "^19.2.16"
|
"@angular/ssr": "^18.2.21" → "^19.2.16"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^18.1.3" → "^19.2.16",
|
"@angular-devkit/build-angular": "^18.1.3" → "^19.2.16",
|
||||||
"@angular/cli": "^18.1.3" → "^19.2.16",
|
"@angular/cli": "^18.1.3" → "^19.2.16",
|
||||||
"@angular/compiler-cli": "^18.1.3" → "^19.2.16"
|
"@angular/compiler-cli": "^18.1.3" → "^19.2.16"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Installation Instructions
|
## 🚀 Installation Instructions
|
||||||
|
|
||||||
### Automatic Installation (Recommended)
|
### Automatic Installation (Recommended)
|
||||||
```bash
|
```bash
|
||||||
cd /home/timo/bizmatch-project
|
cd /home/timo/bizmatch-project
|
||||||
bash fix-vulnerabilities.sh
|
bash fix-vulnerabilities.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### Manual Installation
|
### Manual Installation
|
||||||
|
|
||||||
**If you encounter permission errors:**
|
**If you encounter permission errors:**
|
||||||
```bash
|
```bash
|
||||||
# Fix permissions first
|
# Fix permissions first
|
||||||
cd /home/timo/bizmatch-project/bizmatch-server
|
cd /home/timo/bizmatch-project/bizmatch-server
|
||||||
sudo rm -rf node_modules package-lock.json
|
sudo rm -rf node_modules package-lock.json
|
||||||
cd /home/timo/bizmatch-project/bizmatch
|
cd /home/timo/bizmatch-project/bizmatch
|
||||||
sudo rm -rf node_modules package-lock.json
|
sudo rm -rf node_modules package-lock.json
|
||||||
|
|
||||||
# Then install
|
# Then install
|
||||||
cd /home/timo/bizmatch-project/bizmatch-server
|
cd /home/timo/bizmatch-project/bizmatch-server
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
cd /home/timo/bizmatch-project/bizmatch
|
cd /home/timo/bizmatch-project/bizmatch
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### Verify Installation
|
### Verify Installation
|
||||||
```bash
|
```bash
|
||||||
# Check server
|
# Check server
|
||||||
cd /home/timo/bizmatch-project/bizmatch-server
|
cd /home/timo/bizmatch-project/bizmatch-server
|
||||||
npm audit --production
|
npm audit --production
|
||||||
|
|
||||||
# Check frontend
|
# Check frontend
|
||||||
cd /home/timo/bizmatch-project/bizmatch
|
cd /home/timo/bizmatch-project/bizmatch
|
||||||
npm audit --production
|
npm audit --production
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚠️ Breaking Changes Warning
|
## ⚠️ Breaking Changes Warning
|
||||||
|
|
||||||
### Angular 18 → 19 Migration
|
### Angular 18 → 19 Migration
|
||||||
|
|
||||||
**Potential Issues**:
|
**Potential Issues**:
|
||||||
1. **Route configuration**: Some routing APIs may have changed
|
1. **Route configuration**: Some routing APIs may have changed
|
||||||
2. **Template syntax**: Check for deprecated template features
|
2. **Template syntax**: Check for deprecated template features
|
||||||
3. **Third-party libraries**: Some Angular libraries may not yet support v19
|
3. **Third-party libraries**: Some Angular libraries may not yet support v19
|
||||||
- @angular/fire: Still on v18.0.1 (compatible but check for updates)
|
- @angular/fire: Still on v18.0.1 (compatible but check for updates)
|
||||||
- @bluehalo/ngx-leaflet: May need testing
|
- @bluehalo/ngx-leaflet: May need testing
|
||||||
- @ng-select/ng-select: May need testing
|
- @ng-select/ng-select: May need testing
|
||||||
|
|
||||||
**Testing Required**:
|
**Testing Required**:
|
||||||
```bash
|
```bash
|
||||||
cd /home/timo/bizmatch-project/bizmatch
|
cd /home/timo/bizmatch-project/bizmatch
|
||||||
npm run build
|
npm run build
|
||||||
npm run serve:ssr
|
npm run serve:ssr
|
||||||
# Test all major features
|
# Test all major features
|
||||||
```
|
```
|
||||||
|
|
||||||
### Nodemailer 6 → 7 Migration
|
### Nodemailer 6 → 7 Migration
|
||||||
|
|
||||||
**Potential Issues**:
|
**Potential Issues**:
|
||||||
1. **SMTP configuration**: Minor API changes
|
1. **SMTP configuration**: Minor API changes
|
||||||
2. **Email templates**: Should be compatible
|
2. **Email templates**: Should be compatible
|
||||||
|
|
||||||
**Testing Required**:
|
**Testing Required**:
|
||||||
```bash
|
```bash
|
||||||
# Test email functionality
|
# Test email functionality
|
||||||
# - User registration emails
|
# - User registration emails
|
||||||
# - Password reset emails
|
# - Password reset emails
|
||||||
# - Contact form emails
|
# - Contact form emails
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📊 Expected Results
|
## 📊 Expected Results
|
||||||
|
|
||||||
### Before Updates
|
### Before Updates
|
||||||
- **bizmatch-server**: 45 vulnerabilities (4 critical, 33 high, 7 moderate, 1 low)
|
- **bizmatch-server**: 45 vulnerabilities (4 critical, 33 high, 7 moderate, 1 low)
|
||||||
- **bizmatch**: 36 vulnerabilities (17 high, 13 moderate, 6 low)
|
- **bizmatch**: 36 vulnerabilities (17 high, 13 moderate, 6 low)
|
||||||
|
|
||||||
### After Updates (Production Only)
|
### After Updates (Production Only)
|
||||||
- **bizmatch-server**: ~5-10 vulnerabilities (mostly dev-only)
|
- **bizmatch-server**: ~5-10 vulnerabilities (mostly dev-only)
|
||||||
- **bizmatch**: ~3-5 vulnerabilities (mostly dev-only)
|
- **bizmatch**: ~3-5 vulnerabilities (mostly dev-only)
|
||||||
|
|
||||||
### Remaining Vulnerabilities
|
### Remaining Vulnerabilities
|
||||||
All remaining vulnerabilities should be:
|
All remaining vulnerabilities should be:
|
||||||
- Development dependencies only (not in production builds)
|
- Development dependencies only (not in production builds)
|
||||||
- Low/moderate severity
|
- Low/moderate severity
|
||||||
- Acceptable risk or no fix available
|
- Acceptable risk or no fix available
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔒 Security Best Practices
|
## 🔒 Security Best Practices
|
||||||
|
|
||||||
After applying these fixes:
|
After applying these fixes:
|
||||||
|
|
||||||
1. **Regular Updates**: Run `npm audit` monthly
|
1. **Regular Updates**: Run `npm audit` monthly
|
||||||
2. **Production Builds**: Always use production builds for deployment
|
2. **Production Builds**: Always use production builds for deployment
|
||||||
3. **Dependency Review**: Review new dependencies before adding
|
3. **Dependency Review**: Review new dependencies before adding
|
||||||
4. **Testing**: Thoroughly test after major updates
|
4. **Testing**: Thoroughly test after major updates
|
||||||
5. **Monitoring**: Set up dependabot or similar tools
|
5. **Monitoring**: Set up dependabot or similar tools
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📞 Support
|
## 📞 Support
|
||||||
|
|
||||||
If you encounter issues during installation:
|
If you encounter issues during installation:
|
||||||
|
|
||||||
1. Check the permission errors first
|
1. Check the permission errors first
|
||||||
2. Ensure Node.js and npm are up to date
|
2. Ensure Node.js and npm are up to date
|
||||||
3. Review breaking changes section
|
3. Review breaking changes section
|
||||||
4. Test each component individually
|
4. Test each component individually
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Last Updated**: 2026-01-03
|
**Last Updated**: 2026-01-03
|
||||||
**Next Review**: 2026-02-03 (monthly)
|
**Next Review**: 2026-02-03 (monthly)
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,48 @@
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: node:22-alpine
|
image: node:22-alpine
|
||||||
container_name: bizmatch-app
|
container_name: bizmatch-app
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/app
|
- ./:/app
|
||||||
- node_modules:/app/node_modules
|
- node_modules:/app/node_modules
|
||||||
ports:
|
ports:
|
||||||
- '3001:3001'
|
- '3001:3001'
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- DATABASE_URL
|
- DATABASE_URL
|
||||||
command: sh -c "if [ ! -f node_modules/.installed ]; then npm ci && touch node_modules/.installed; fi && npm run build && node dist/src/main.js"
|
command: sh -c "if [ ! -f node_modules/.installed ]; then npm ci && touch node_modules/.installed; fi && npm run build && node dist/src/main.js"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
networks:
|
networks:
|
||||||
- bizmatch
|
- bizmatch
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
container_name: bizmatchdb
|
container_name: bizmatchdb
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- bizmatch-db-data:/var/lib/postgresql/data
|
- bizmatch-db-data:/var/lib/postgresql/data
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
ports:
|
ports:
|
||||||
- '5434:5432'
|
- '5434:5432'
|
||||||
networks:
|
networks:
|
||||||
- bizmatch
|
- bizmatch
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
bizmatch-db-data:
|
bizmatch-db-data:
|
||||||
driver: local
|
driver: local
|
||||||
node_modules:
|
node_modules:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
bizmatch:
|
bizmatch:
|
||||||
external: true
|
external: true
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
-- Create missing sequence for commercials_json serialId
|
-- Create missing sequence for commercials_json serialId
|
||||||
-- This sequence is required for generating unique serialId values for commercial property listings
|
-- This sequence is required for generating unique serialId values for commercial property listings
|
||||||
|
|
||||||
CREATE SEQUENCE IF NOT EXISTS commercials_json_serial_id_seq START WITH 100000;
|
CREATE SEQUENCE IF NOT EXISTS commercials_json_serial_id_seq START WITH 100000;
|
||||||
|
|
||||||
-- Verify the sequence was created
|
-- Verify the sequence was created
|
||||||
SELECT sequence_name, start_value, last_value
|
SELECT sequence_name, start_value, last_value
|
||||||
FROM information_schema.sequences
|
FROM information_schema.sequences
|
||||||
WHERE sequence_name = 'commercials_json_serial_id_seq';
|
WHERE sequence_name = 'commercials_json_serial_id_seq';
|
||||||
|
|
||||||
-- Also verify all sequences to check if business listings sequence exists
|
-- Also verify all sequences to check if business listings sequence exists
|
||||||
\ds
|
\ds
|
||||||
|
|
|
||||||
|
|
@ -1,112 +1,112 @@
|
||||||
{
|
{
|
||||||
"name": "bizmatch-server",
|
"name": "bizmatch-server",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"start:local": "HOST_NAME=localhost node dist/src/main",
|
"start:local": "HOST_NAME=localhost node dist/src/main",
|
||||||
"start:dev": "NODE_ENV=development node dist/src/main",
|
"start:dev": "NODE_ENV=development node dist/src/main",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "NODE_ENV=production node dist/src/main",
|
"start:prod": "NODE_ENV=production node dist/src/main",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
"generate": "drizzle-kit generate",
|
"generate": "drizzle-kit generate",
|
||||||
"drop": "drizzle-kit drop",
|
"drop": "drizzle-kit drop",
|
||||||
"migrate": "tsx src/drizzle/migrate.ts",
|
"migrate": "tsx src/drizzle/migrate.ts",
|
||||||
"import": "tsx src/drizzle/import.ts",
|
"import": "tsx src/drizzle/import.ts",
|
||||||
"generateTypes": "tsx src/drizzle/generateTypes.ts src/drizzle/schema.ts src/models/db.model.ts",
|
"generateTypes": "tsx src/drizzle/generateTypes.ts src/drizzle/schema.ts src/models/db.model.ts",
|
||||||
"create-tables": "node src/scripts/create-tables.js",
|
"create-tables": "node src/scripts/create-tables.js",
|
||||||
"seed": "node src/scripts/seed-database.js",
|
"seed": "node src/scripts/seed-database.js",
|
||||||
"create-user": "node src/scripts/create-test-user.js",
|
"create-user": "node src/scripts/create-test-user.js",
|
||||||
"seed:all": "npm run create-user && npm run seed",
|
"seed:all": "npm run create-user && npm run seed",
|
||||||
"setup": "npm run create-tables && npm run seed"
|
"setup": "npm run create-tables && npm run seed"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs-modules/mailer": "^2.0.2",
|
"@nestjs-modules/mailer": "^2.0.2",
|
||||||
"@nestjs/cli": "^11.0.11",
|
"@nestjs/cli": "^11.0.11",
|
||||||
"@nestjs/common": "^11.0.11",
|
"@nestjs/common": "^11.0.11",
|
||||||
"@nestjs/config": "^4.0.0",
|
"@nestjs/config": "^4.0.0",
|
||||||
"@nestjs/core": "^11.0.11",
|
"@nestjs/core": "^11.0.11",
|
||||||
"@nestjs/platform-express": "^11.0.11",
|
"@nestjs/platform-express": "^11.0.11",
|
||||||
"@types/stripe": "^8.0.417",
|
"@types/stripe": "^8.0.417",
|
||||||
"body-parser": "^1.20.2",
|
"body-parser": "^1.20.2",
|
||||||
"cls-hooked": "^4.2.2",
|
"cls-hooked": "^4.2.2",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"drizzle-orm": "^0.32.0",
|
"drizzle-orm": "^0.32.0",
|
||||||
"firebase": "^11.9.0",
|
"firebase": "^11.9.0",
|
||||||
"firebase-admin": "^13.1.0",
|
"firebase-admin": "^13.1.0",
|
||||||
"fs-extra": "^11.2.0",
|
"fs-extra": "^11.2.0",
|
||||||
"groq-sdk": "^0.5.0",
|
"groq-sdk": "^0.5.0",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"nest-winston": "^1.9.4",
|
"nest-winston": "^1.9.4",
|
||||||
"nestjs-cls": "^5.4.0",
|
"nestjs-cls": "^5.4.0",
|
||||||
"nodemailer": "^7.0.12",
|
"nodemailer": "^7.0.12",
|
||||||
"openai": "^4.52.6",
|
"openai": "^4.52.6",
|
||||||
"pg": "^8.11.5",
|
"pg": "^8.11.5",
|
||||||
"pgvector": "^0.2.0",
|
"pgvector": "^0.2.0",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
"stripe": "^16.8.0",
|
"stripe": "^16.8.0",
|
||||||
"tsx": "^4.16.2",
|
"tsx": "^4.16.2",
|
||||||
"urlcat": "^3.1.0",
|
"urlcat": "^3.1.0",
|
||||||
"winston": "^3.11.0",
|
"winston": "^3.11.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/parser": "^7.24.4",
|
"@babel/parser": "^7.24.4",
|
||||||
"@babel/traverse": "^7.24.1",
|
"@babel/traverse": "^7.24.1",
|
||||||
"@nestjs/cli": "^11.0.5",
|
"@nestjs/cli": "^11.0.5",
|
||||||
"@nestjs/schematics": "^11.0.1",
|
"@nestjs/schematics": "^11.0.1",
|
||||||
"@nestjs/testing": "^11.0.11",
|
"@nestjs/testing": "^11.0.11",
|
||||||
"@types/express": "^4.17.17",
|
"@types/express": "^4.17.17",
|
||||||
"@types/multer": "^1.4.11",
|
"@types/multer": "^1.4.11",
|
||||||
"@types/node": "^20.19.25",
|
"@types/node": "^20.19.25",
|
||||||
"@types/nodemailer": "^6.4.14",
|
"@types/nodemailer": "^6.4.14",
|
||||||
"@types/pg": "^8.11.5",
|
"@types/pg": "^8.11.5",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.8",
|
||||||
"esbuild-register": "^3.5.0",
|
"esbuild-register": "^3.5.0",
|
||||||
"eslint": "^8.42.0",
|
"eslint": "^8.42.0",
|
||||||
"eslint-config-prettier": "^9.0.0",
|
"eslint-config-prettier": "^9.0.0",
|
||||||
"eslint-plugin-prettier": "^5.0.0",
|
"eslint-plugin-prettier": "^5.0.0",
|
||||||
"kysely-codegen": "^0.15.0",
|
"kysely-codegen": "^0.15.0",
|
||||||
"nest-commander": "^3.16.1",
|
"nest-commander": "^3.16.1",
|
||||||
"pg-to-ts": "^4.1.1",
|
"pg-to-ts": "^4.1.1",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.0.0",
|
||||||
"rimraf": "^5.0.5",
|
"rimraf": "^5.0.5",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^6.3.3",
|
"supertest": "^6.3.3",
|
||||||
"ts-jest": "^29.1.0",
|
"ts-jest": "^29.1.0",
|
||||||
"ts-loader": "^9.4.3",
|
"ts-loader": "^9.4.3",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
"js",
|
"js",
|
||||||
"json",
|
"json",
|
||||||
"ts"
|
"ts"
|
||||||
],
|
],
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"testRegex": ".*\\.spec\\.ts$",
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
],
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node"
|
"testEnvironment": "node"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,96 +1,96 @@
|
||||||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
|
||||||
import { utilities as nestWinstonModuleUtilities, WinstonModule } from 'nest-winston';
|
import { utilities as nestWinstonModuleUtilities, WinstonModule } from 'nest-winston';
|
||||||
import * as winston from 'winston';
|
import * as winston from 'winston';
|
||||||
import { AiModule } from './ai/ai.module';
|
import { AiModule } from './ai/ai.module';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { FileService } from './file/file.service';
|
import { FileService } from './file/file.service';
|
||||||
import { GeoModule } from './geo/geo.module';
|
import { GeoModule } from './geo/geo.module';
|
||||||
import { ImageModule } from './image/image.module';
|
import { ImageModule } from './image/image.module';
|
||||||
import { ListingsModule } from './listings/listings.module';
|
import { ListingsModule } from './listings/listings.module';
|
||||||
import { LogController } from './log/log.controller';
|
import { LogController } from './log/log.controller';
|
||||||
import { LogModule } from './log/log.module';
|
import { LogModule } from './log/log.module';
|
||||||
|
|
||||||
import { EventModule } from './event/event.module';
|
import { EventModule } from './event/event.module';
|
||||||
import { MailModule } from './mail/mail.module';
|
import { MailModule } from './mail/mail.module';
|
||||||
|
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
import { ClsMiddleware, ClsModule } from 'nestjs-cls';
|
import { ClsMiddleware, ClsModule } from 'nestjs-cls';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { AuthService } from './auth/auth.service';
|
import { AuthService } from './auth/auth.service';
|
||||||
import { FirebaseAdminModule } from './firebase-admin/firebase-admin.module';
|
import { FirebaseAdminModule } from './firebase-admin/firebase-admin.module';
|
||||||
import { LoggingInterceptor } from './interceptors/logging.interceptor';
|
import { LoggingInterceptor } from './interceptors/logging.interceptor';
|
||||||
import { UserInterceptor } from './interceptors/user.interceptor';
|
import { UserInterceptor } from './interceptors/user.interceptor';
|
||||||
import { RequestDurationMiddleware } from './request-duration/request-duration.middleware';
|
import { RequestDurationMiddleware } from './request-duration/request-duration.middleware';
|
||||||
import { SelectOptionsModule } from './select-options/select-options.module';
|
import { SelectOptionsModule } from './select-options/select-options.module';
|
||||||
import { SitemapModule } from './sitemap/sitemap.module';
|
import { SitemapModule } from './sitemap/sitemap.module';
|
||||||
import { UserModule } from './user/user.module';
|
import { UserModule } from './user/user.module';
|
||||||
|
|
||||||
//loadEnvFiles();
|
//loadEnvFiles();
|
||||||
console.log('Loaded environment variables:');
|
console.log('Loaded environment variables:');
|
||||||
//console.log(JSON.stringify(process.env, null, 2));
|
//console.log(JSON.stringify(process.env, null, 2));
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ClsModule.forRoot({
|
ClsModule.forRoot({
|
||||||
global: true, // Macht den ClsService global verfügbar
|
global: true, // Macht den ClsService global verfügbar
|
||||||
middleware: { mount: true }, // Registriert automatisch die ClsMiddleware
|
middleware: { mount: true }, // Registriert automatisch die ClsMiddleware
|
||||||
}),
|
}),
|
||||||
//ConfigModule.forRoot({ envFilePath: '.env' }),
|
//ConfigModule.forRoot({ envFilePath: '.env' }),
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
envFilePath: [path.resolve(__dirname, '..', '.env')],
|
envFilePath: [path.resolve(__dirname, '..', '.env')],
|
||||||
}),
|
}),
|
||||||
MailModule,
|
MailModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
WinstonModule.forRoot({
|
WinstonModule.forRoot({
|
||||||
transports: [
|
transports: [
|
||||||
new winston.transports.Console({
|
new winston.transports.Console({
|
||||||
format: winston.format.combine(
|
format: winston.format.combine(
|
||||||
winston.format.timestamp({
|
winston.format.timestamp({
|
||||||
format: 'YYYY-MM-DD hh:mm:ss.SSS A',
|
format: 'YYYY-MM-DD hh:mm:ss.SSS A',
|
||||||
}),
|
}),
|
||||||
winston.format.ms(),
|
winston.format.ms(),
|
||||||
nestWinstonModuleUtilities.format.nestLike('Bizmatch', {
|
nestWinstonModuleUtilities.format.nestLike('Bizmatch', {
|
||||||
colors: true,
|
colors: true,
|
||||||
prettyPrint: true,
|
prettyPrint: true,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
// other transports...
|
// other transports...
|
||||||
],
|
],
|
||||||
// other options
|
// other options
|
||||||
}),
|
}),
|
||||||
GeoModule,
|
GeoModule,
|
||||||
UserModule,
|
UserModule,
|
||||||
ListingsModule,
|
ListingsModule,
|
||||||
SelectOptionsModule,
|
SelectOptionsModule,
|
||||||
ImageModule,
|
ImageModule,
|
||||||
AiModule,
|
AiModule,
|
||||||
LogModule,
|
LogModule,
|
||||||
// PaymentModule,
|
// PaymentModule,
|
||||||
EventModule,
|
EventModule,
|
||||||
FirebaseAdminModule,
|
FirebaseAdminModule,
|
||||||
SitemapModule,
|
SitemapModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController, LogController],
|
controllers: [AppController, LogController],
|
||||||
providers: [
|
providers: [
|
||||||
AppService,
|
AppService,
|
||||||
FileService,
|
FileService,
|
||||||
{
|
{
|
||||||
provide: APP_INTERCEPTOR,
|
provide: APP_INTERCEPTOR,
|
||||||
useClass: UserInterceptor, // Registriere den Interceptor global
|
useClass: UserInterceptor, // Registriere den Interceptor global
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: APP_INTERCEPTOR,
|
provide: APP_INTERCEPTOR,
|
||||||
useClass: LoggingInterceptor, // Registriere den LoggingInterceptor global
|
useClass: LoggingInterceptor, // Registriere den LoggingInterceptor global
|
||||||
},
|
},
|
||||||
AuthService,
|
AuthService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer) {
|
configure(consumer: MiddlewareConsumer) {
|
||||||
consumer.apply(ClsMiddleware).forRoutes('*');
|
consumer.apply(ClsMiddleware).forRoutes('*');
|
||||||
consumer.apply(RequestDurationMiddleware).forRoutes('*');
|
consumer.apply(RequestDurationMiddleware).forRoutes('*');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,346 +1,346 @@
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from 'fs';
|
import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from 'fs';
|
||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
import { rimraf } from 'rimraf';
|
import { rimraf } from 'rimraf';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { BusinessListingService } from 'src/listings/business-listing.service';
|
import { BusinessListingService } from 'src/listings/business-listing.service';
|
||||||
import { CommercialPropertyService } from 'src/listings/commercial-property.service';
|
import { CommercialPropertyService } from 'src/listings/commercial-property.service';
|
||||||
import { Geo } from 'src/models/server.model';
|
import { Geo } from 'src/models/server.model';
|
||||||
import { UserService } from 'src/user/user.service';
|
import { UserService } from 'src/user/user.service';
|
||||||
import winston from 'winston';
|
import winston from 'winston';
|
||||||
import { User, UserData } from '../models/db.model';
|
import { User, UserData } from '../models/db.model';
|
||||||
import { createDefaultBusinessListing, createDefaultCommercialPropertyListing, createDefaultUser, emailToDirName } from '../models/main.model';
|
import { createDefaultBusinessListing, createDefaultCommercialPropertyListing, createDefaultUser, emailToDirName } from '../models/main.model';
|
||||||
import { SelectOptionsService } from '../select-options/select-options.service';
|
import { SelectOptionsService } from '../select-options/select-options.service';
|
||||||
import * as schema from './schema';
|
import * as schema from './schema';
|
||||||
interface PropertyImportListing {
|
interface PropertyImportListing {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
listingsCategory: 'commercialProperty';
|
listingsCategory: 'commercialProperty';
|
||||||
title: string;
|
title: string;
|
||||||
state: string;
|
state: string;
|
||||||
hasImages: boolean;
|
hasImages: boolean;
|
||||||
price: number;
|
price: number;
|
||||||
city: string;
|
city: string;
|
||||||
description: string;
|
description: string;
|
||||||
type: number;
|
type: number;
|
||||||
imageOrder: any[];
|
imageOrder: any[];
|
||||||
}
|
}
|
||||||
interface BusinessImportListing {
|
interface BusinessImportListing {
|
||||||
userId: string;
|
userId: string;
|
||||||
listingsCategory: 'business';
|
listingsCategory: 'business';
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
type: number;
|
type: number;
|
||||||
state: string;
|
state: string;
|
||||||
city: string;
|
city: string;
|
||||||
id: string;
|
id: string;
|
||||||
price: number;
|
price: number;
|
||||||
salesRevenue: number;
|
salesRevenue: number;
|
||||||
leasedLocation: boolean;
|
leasedLocation: boolean;
|
||||||
established: number;
|
established: number;
|
||||||
employees: number;
|
employees: number;
|
||||||
reasonForSale: string;
|
reasonForSale: string;
|
||||||
supportAndTraining: string;
|
supportAndTraining: string;
|
||||||
cashFlow: number;
|
cashFlow: number;
|
||||||
brokerLicencing: string;
|
brokerLicencing: string;
|
||||||
internalListingNumber: number;
|
internalListingNumber: number;
|
||||||
realEstateIncluded: boolean;
|
realEstateIncluded: boolean;
|
||||||
franchiseResale: boolean;
|
franchiseResale: boolean;
|
||||||
draft: boolean;
|
draft: boolean;
|
||||||
internals: string;
|
internals: string;
|
||||||
created: string;
|
created: string;
|
||||||
}
|
}
|
||||||
// const typesOfBusiness: Array<KeyValueStyle> = [
|
// const typesOfBusiness: Array<KeyValueStyle> = [
|
||||||
// { name: 'Automotive', value: '1', icon: 'fa-solid fa-car', textColorClass: 'text-green-400' },
|
// { name: 'Automotive', value: '1', icon: 'fa-solid fa-car', textColorClass: 'text-green-400' },
|
||||||
// { name: 'Industrial Services', value: '2', icon: 'fa-solid fa-industry', textColorClass: 'text-yellow-400' },
|
// { name: 'Industrial Services', value: '2', icon: 'fa-solid fa-industry', textColorClass: 'text-yellow-400' },
|
||||||
// { name: 'Real Estate', value: '3', icon: 'fa-solid fa-building', textColorClass: 'text-blue-400' },
|
// { name: 'Real Estate', value: '3', icon: 'fa-solid fa-building', textColorClass: 'text-blue-400' },
|
||||||
// { name: 'Uncategorized', value: '4', icon: 'fa-solid fa-question', textColorClass: 'text-cyan-400' },
|
// { name: 'Uncategorized', value: '4', icon: 'fa-solid fa-question', textColorClass: 'text-cyan-400' },
|
||||||
// { name: 'Retail', value: '5', icon: 'fa-solid fa-money-bill-wave', textColorClass: 'text-pink-400' },
|
// { name: 'Retail', value: '5', icon: 'fa-solid fa-money-bill-wave', textColorClass: 'text-pink-400' },
|
||||||
// { name: 'Oilfield SVE and MFG.', value: '6', icon: 'fa-solid fa-oil-well', textColorClass: 'text-indigo-400' },
|
// { name: 'Oilfield SVE and MFG.', value: '6', icon: 'fa-solid fa-oil-well', textColorClass: 'text-indigo-400' },
|
||||||
// { name: 'Service', value: '7', icon: 'fa-solid fa-umbrella', textColorClass: 'text-teal-400' },
|
// { name: 'Service', value: '7', icon: 'fa-solid fa-umbrella', textColorClass: 'text-teal-400' },
|
||||||
// { name: 'Advertising', value: '8', icon: 'fa-solid fa-rectangle-ad', textColorClass: 'text-orange-400' },
|
// { name: 'Advertising', value: '8', icon: 'fa-solid fa-rectangle-ad', textColorClass: 'text-orange-400' },
|
||||||
// { name: 'Agriculture', value: '9', icon: 'fa-solid fa-wheat-awn', textColorClass: 'text-sky-400' },
|
// { name: 'Agriculture', value: '9', icon: 'fa-solid fa-wheat-awn', textColorClass: 'text-sky-400' },
|
||||||
// { name: 'Franchise', value: '10', icon: 'fa-solid fa-star', textColorClass: 'text-purple-400' },
|
// { name: 'Franchise', value: '10', icon: 'fa-solid fa-star', textColorClass: 'text-purple-400' },
|
||||||
// { name: 'Professional', value: '11', icon: 'fa-solid fa-user-gear', textColorClass: 'text-gray-400' },
|
// { name: 'Professional', value: '11', icon: 'fa-solid fa-user-gear', textColorClass: 'text-gray-400' },
|
||||||
// { name: 'Manufacturing', value: '12', icon: 'fa-solid fa-industry', textColorClass: 'text-red-400' },
|
// { name: 'Manufacturing', value: '12', icon: 'fa-solid fa-industry', textColorClass: 'text-red-400' },
|
||||||
// { name: 'Food and Restaurant', value: '13', icon: 'fa-solid fa-utensils', textColorClass: 'text-amber-700' },
|
// { name: 'Food and Restaurant', value: '13', icon: 'fa-solid fa-utensils', textColorClass: 'text-amber-700' },
|
||||||
// ];
|
// ];
|
||||||
// const { Pool } = pkg;
|
// const { Pool } = pkg;
|
||||||
|
|
||||||
// const openai = new OpenAI({
|
// const openai = new OpenAI({
|
||||||
// apiKey: process.env.OPENAI_API_KEY, // Stellen Sie sicher, dass Sie Ihren API-Key als Umgebungsvariable setzen
|
// apiKey: process.env.OPENAI_API_KEY, // Stellen Sie sicher, dass Sie Ihren API-Key als Umgebungsvariable setzen
|
||||||
// });
|
// });
|
||||||
(async () => {
|
(async () => {
|
||||||
const connectionString = process.env.DATABASE_URL;
|
const connectionString = process.env.DATABASE_URL;
|
||||||
// const pool = new Pool({connectionString})
|
// const pool = new Pool({connectionString})
|
||||||
const client = new Pool({ connectionString });
|
const client = new Pool({ connectionString });
|
||||||
const db = drizzle(client, { schema, logger: true });
|
const db = drizzle(client, { schema, logger: true });
|
||||||
const logger = winston.createLogger({
|
const logger = winston.createLogger({
|
||||||
transports: [new winston.transports.Console()],
|
transports: [new winston.transports.Console()],
|
||||||
});
|
});
|
||||||
const commService = new CommercialPropertyService(null, db);
|
const commService = new CommercialPropertyService(null, db);
|
||||||
const businessService = new BusinessListingService(null, db);
|
const businessService = new BusinessListingService(null, db);
|
||||||
const userService = new UserService(null, db, null, null);
|
const userService = new UserService(null, db, null, null);
|
||||||
//Delete Content
|
//Delete Content
|
||||||
await db.delete(schema.commercials);
|
await db.delete(schema.commercials);
|
||||||
await db.delete(schema.businesses);
|
await db.delete(schema.businesses);
|
||||||
await db.delete(schema.users);
|
await db.delete(schema.users);
|
||||||
let filePath = `./src/assets/geo.json`;
|
let filePath = `./src/assets/geo.json`;
|
||||||
const rawData = readFileSync(filePath, 'utf8');
|
const rawData = readFileSync(filePath, 'utf8');
|
||||||
const geos = JSON.parse(rawData) as Geo;
|
const geos = JSON.parse(rawData) as Geo;
|
||||||
|
|
||||||
const sso = new SelectOptionsService();
|
const sso = new SelectOptionsService();
|
||||||
//Broker
|
//Broker
|
||||||
filePath = `./data/broker.json`;
|
filePath = `./data/broker.json`;
|
||||||
let data: string = readFileSync(filePath, 'utf8');
|
let data: string = readFileSync(filePath, 'utf8');
|
||||||
const usersData: UserData[] = JSON.parse(data); // Erwartet ein Array von Objekten
|
const usersData: UserData[] = JSON.parse(data); // Erwartet ein Array von Objekten
|
||||||
const generatedUserData = [];
|
const generatedUserData = [];
|
||||||
console.log(usersData.length);
|
console.log(usersData.length);
|
||||||
let i = 0,
|
let i = 0,
|
||||||
male = 0,
|
male = 0,
|
||||||
female = 0;
|
female = 0;
|
||||||
const targetPathProfile = `./pictures/profile`;
|
const targetPathProfile = `./pictures/profile`;
|
||||||
deleteFilesOfDir(targetPathProfile);
|
deleteFilesOfDir(targetPathProfile);
|
||||||
const targetPathLogo = `./pictures/logo`;
|
const targetPathLogo = `./pictures/logo`;
|
||||||
deleteFilesOfDir(targetPathLogo);
|
deleteFilesOfDir(targetPathLogo);
|
||||||
const targetPathProperty = `./pictures/property`;
|
const targetPathProperty = `./pictures/property`;
|
||||||
deleteFilesOfDir(targetPathProperty);
|
deleteFilesOfDir(targetPathProperty);
|
||||||
fs.ensureDirSync(`./pictures/logo`);
|
fs.ensureDirSync(`./pictures/logo`);
|
||||||
fs.ensureDirSync(`./pictures/profile`);
|
fs.ensureDirSync(`./pictures/profile`);
|
||||||
fs.ensureDirSync(`./pictures/property`);
|
fs.ensureDirSync(`./pictures/property`);
|
||||||
|
|
||||||
//User
|
//User
|
||||||
for (let index = 0; index < usersData.length; index++) {
|
for (let index = 0; index < usersData.length; index++) {
|
||||||
const userData = usersData[index];
|
const userData = usersData[index];
|
||||||
const user: User = createDefaultUser('', '', '', null);
|
const user: User = createDefaultUser('', '', '', null);
|
||||||
user.licensedIn = [];
|
user.licensedIn = [];
|
||||||
userData.licensedIn.forEach(l => {
|
userData.licensedIn.forEach(l => {
|
||||||
console.log(l['value'], l['name']);
|
console.log(l['value'], l['name']);
|
||||||
user.licensedIn.push({ registerNo: l['value'], state: l['name'] });
|
user.licensedIn.push({ registerNo: l['value'], state: l['name'] });
|
||||||
});
|
});
|
||||||
user.areasServed = [];
|
user.areasServed = [];
|
||||||
user.areasServed = userData.areasServed.map(l => {
|
user.areasServed = userData.areasServed.map(l => {
|
||||||
return { county: l.split(',')[0].trim(), state: l.split(',')[1].trim() };
|
return { county: l.split(',')[0].trim(), state: l.split(',')[1].trim() };
|
||||||
});
|
});
|
||||||
user.hasCompanyLogo = true;
|
user.hasCompanyLogo = true;
|
||||||
user.hasProfile = true;
|
user.hasProfile = true;
|
||||||
user.firstname = userData.firstname;
|
user.firstname = userData.firstname;
|
||||||
user.lastname = userData.lastname;
|
user.lastname = userData.lastname;
|
||||||
user.email = userData.email;
|
user.email = userData.email;
|
||||||
user.phoneNumber = userData.phoneNumber;
|
user.phoneNumber = userData.phoneNumber;
|
||||||
user.description = userData.description;
|
user.description = userData.description;
|
||||||
user.companyName = userData.companyName;
|
user.companyName = userData.companyName;
|
||||||
user.companyOverview = userData.companyOverview;
|
user.companyOverview = userData.companyOverview;
|
||||||
user.companyWebsite = userData.companyWebsite;
|
user.companyWebsite = userData.companyWebsite;
|
||||||
const [city, state] = userData.companyLocation.split('-').map(e => e.trim());
|
const [city, state] = userData.companyLocation.split('-').map(e => e.trim());
|
||||||
user.location = {};
|
user.location = {};
|
||||||
user.location.name = city;
|
user.location.name = city;
|
||||||
user.location.state = state;
|
user.location.state = state;
|
||||||
const cityGeo = geos.states.find(s => s.state_code === state).cities.find(c => c.name === city);
|
const cityGeo = geos.states.find(s => s.state_code === state).cities.find(c => c.name === city);
|
||||||
user.location.latitude = cityGeo.latitude;
|
user.location.latitude = cityGeo.latitude;
|
||||||
user.location.longitude = cityGeo.longitude;
|
user.location.longitude = cityGeo.longitude;
|
||||||
user.offeredServices = userData.offeredServices;
|
user.offeredServices = userData.offeredServices;
|
||||||
user.gender = userData.gender;
|
user.gender = userData.gender;
|
||||||
user.customerType = 'professional';
|
user.customerType = 'professional';
|
||||||
user.customerSubType = 'broker';
|
user.customerSubType = 'broker';
|
||||||
user.created = new Date();
|
user.created = new Date();
|
||||||
user.updated = new Date();
|
user.updated = new Date();
|
||||||
|
|
||||||
// const u = await db
|
// const u = await db
|
||||||
// .insert(schema.users)
|
// .insert(schema.users)
|
||||||
// .values(convertUserToDrizzleUser(user))
|
// .values(convertUserToDrizzleUser(user))
|
||||||
// .returning({ insertedId: schema.users.id, gender: schema.users.gender, email: schema.users.email, firstname: schema.users.firstname, lastname: schema.users.lastname });
|
// .returning({ insertedId: schema.users.id, gender: schema.users.gender, email: schema.users.email, firstname: schema.users.firstname, lastname: schema.users.lastname });
|
||||||
const u = await userService.saveUser(user);
|
const u = await userService.saveUser(user);
|
||||||
generatedUserData.push(u);
|
generatedUserData.push(u);
|
||||||
i++;
|
i++;
|
||||||
logger.info(`user_${index} inserted`);
|
logger.info(`user_${index} inserted`);
|
||||||
if (u.gender === 'male') {
|
if (u.gender === 'male') {
|
||||||
male++;
|
male++;
|
||||||
const data = readFileSync(`./pictures_base/profile/Mann_${male}.jpg`);
|
const data = readFileSync(`./pictures_base/profile/Mann_${male}.jpg`);
|
||||||
await storeProfilePicture(data, emailToDirName(u.email));
|
await storeProfilePicture(data, emailToDirName(u.email));
|
||||||
} else {
|
} else {
|
||||||
female++;
|
female++;
|
||||||
const data = readFileSync(`./pictures_base/profile/Frau_${female}.jpg`);
|
const data = readFileSync(`./pictures_base/profile/Frau_${female}.jpg`);
|
||||||
await storeProfilePicture(data, emailToDirName(u.email));
|
await storeProfilePicture(data, emailToDirName(u.email));
|
||||||
}
|
}
|
||||||
const data = readFileSync(`./pictures_base/logo/${i}.jpg`);
|
const data = readFileSync(`./pictures_base/logo/${i}.jpg`);
|
||||||
await storeCompanyLogo(data, emailToDirName(u.email));
|
await storeCompanyLogo(data, emailToDirName(u.email));
|
||||||
}
|
}
|
||||||
|
|
||||||
//Corporate Listings
|
//Corporate Listings
|
||||||
filePath = `./data/commercials.json`;
|
filePath = `./data/commercials.json`;
|
||||||
data = readFileSync(filePath, 'utf8');
|
data = readFileSync(filePath, 'utf8');
|
||||||
const commercialJsonData = JSON.parse(data) as PropertyImportListing[]; // Erwartet ein Array von Objekten
|
const commercialJsonData = JSON.parse(data) as PropertyImportListing[]; // Erwartet ein Array von Objekten
|
||||||
for (let index = 0; index < commercialJsonData.length; index++) {
|
for (let index = 0; index < commercialJsonData.length; index++) {
|
||||||
const user = getRandomItem(generatedUserData);
|
const user = getRandomItem(generatedUserData);
|
||||||
const commercial = createDefaultCommercialPropertyListing();
|
const commercial = createDefaultCommercialPropertyListing();
|
||||||
const id = commercialJsonData[index].id;
|
const id = commercialJsonData[index].id;
|
||||||
delete commercial.id;
|
delete commercial.id;
|
||||||
|
|
||||||
commercial.email = user.email;
|
commercial.email = user.email;
|
||||||
commercial.type = sso.typesOfCommercialProperty.find(e => e.oldValue === String(commercialJsonData[index].type)).value;
|
commercial.type = sso.typesOfCommercialProperty.find(e => e.oldValue === String(commercialJsonData[index].type)).value;
|
||||||
commercial.title = commercialJsonData[index].title;
|
commercial.title = commercialJsonData[index].title;
|
||||||
commercial.description = commercialJsonData[index].description;
|
commercial.description = commercialJsonData[index].description;
|
||||||
try {
|
try {
|
||||||
const cityGeo = geos.states.find(s => s.state_code === commercialJsonData[index].state).cities.find(c => c.name === commercialJsonData[index].city);
|
const cityGeo = geos.states.find(s => s.state_code === commercialJsonData[index].state).cities.find(c => c.name === commercialJsonData[index].city);
|
||||||
commercial.location = {};
|
commercial.location = {};
|
||||||
commercial.location.latitude = cityGeo.latitude;
|
commercial.location.latitude = cityGeo.latitude;
|
||||||
commercial.location.longitude = cityGeo.longitude;
|
commercial.location.longitude = cityGeo.longitude;
|
||||||
commercial.location.name = commercialJsonData[index].city;
|
commercial.location.name = commercialJsonData[index].city;
|
||||||
commercial.location.state = commercialJsonData[index].state;
|
commercial.location.state = commercialJsonData[index].state;
|
||||||
// console.log(JSON.stringify(commercial.location));
|
// console.log(JSON.stringify(commercial.location));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(`----------------> ERROR ${commercialJsonData[index].state} - ${commercialJsonData[index].city}`);
|
console.log(`----------------> ERROR ${commercialJsonData[index].state} - ${commercialJsonData[index].city}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
commercial.price = commercialJsonData[index].price;
|
commercial.price = commercialJsonData[index].price;
|
||||||
commercial.listingsCategory = 'commercialProperty';
|
commercial.listingsCategory = 'commercialProperty';
|
||||||
commercial.draft = false;
|
commercial.draft = false;
|
||||||
commercial.imageOrder = getFilenames(id);
|
commercial.imageOrder = getFilenames(id);
|
||||||
commercial.imagePath = emailToDirName(user.email);
|
commercial.imagePath = emailToDirName(user.email);
|
||||||
const insertionDate = getRandomDateWithinLastYear();
|
const insertionDate = getRandomDateWithinLastYear();
|
||||||
commercial.created = insertionDate;
|
commercial.created = insertionDate;
|
||||||
commercial.updated = insertionDate;
|
commercial.updated = insertionDate;
|
||||||
|
|
||||||
const result = await commService.createListing(commercial); //await db.insert(schema.commercials).values(commercial).returning();
|
const result = await commService.createListing(commercial); //await db.insert(schema.commercials).values(commercial).returning();
|
||||||
try {
|
try {
|
||||||
fs.copySync(`./pictures_base/property/${id}`, `./pictures/property/${result.imagePath}/${result.serialId}`);
|
fs.copySync(`./pictures_base/property/${id}`, `./pictures/property/${result.imagePath}/${result.serialId}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(`----- No pictures available for ${id} ------ ${err}`);
|
console.log(`----- No pictures available for ${id} ------ ${err}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//Business Listings
|
//Business Listings
|
||||||
filePath = `./data/businesses.json`;
|
filePath = `./data/businesses.json`;
|
||||||
data = readFileSync(filePath, 'utf8');
|
data = readFileSync(filePath, 'utf8');
|
||||||
const businessJsonData = JSON.parse(data) as BusinessImportListing[]; // Erwartet ein Array von Objekten
|
const businessJsonData = JSON.parse(data) as BusinessImportListing[]; // Erwartet ein Array von Objekten
|
||||||
for (let index = 0; index < businessJsonData.length; index++) {
|
for (let index = 0; index < businessJsonData.length; index++) {
|
||||||
const business = createDefaultBusinessListing(); //businessJsonData[index];
|
const business = createDefaultBusinessListing(); //businessJsonData[index];
|
||||||
delete business.id;
|
delete business.id;
|
||||||
const user = getRandomItem(generatedUserData);
|
const user = getRandomItem(generatedUserData);
|
||||||
business.email = user.email;
|
business.email = user.email;
|
||||||
business.type = sso.typesOfBusiness.find(e => e.oldValue === String(businessJsonData[index].type)).value;
|
business.type = sso.typesOfBusiness.find(e => e.oldValue === String(businessJsonData[index].type)).value;
|
||||||
business.title = businessJsonData[index].title;
|
business.title = businessJsonData[index].title;
|
||||||
business.description = businessJsonData[index].description;
|
business.description = businessJsonData[index].description;
|
||||||
try {
|
try {
|
||||||
const cityGeo = geos.states.find(s => s.state_code === businessJsonData[index].state).cities.find(c => c.name === businessJsonData[index].city);
|
const cityGeo = geos.states.find(s => s.state_code === businessJsonData[index].state).cities.find(c => c.name === businessJsonData[index].city);
|
||||||
business.location = {};
|
business.location = {};
|
||||||
business.location.latitude = cityGeo.latitude;
|
business.location.latitude = cityGeo.latitude;
|
||||||
business.location.longitude = cityGeo.longitude;
|
business.location.longitude = cityGeo.longitude;
|
||||||
business.location.name = businessJsonData[index].city;
|
business.location.name = businessJsonData[index].city;
|
||||||
business.location.state = businessJsonData[index].state;
|
business.location.state = businessJsonData[index].state;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(`----------------> ERROR ${businessJsonData[index].state} - ${businessJsonData[index].city}`);
|
console.log(`----------------> ERROR ${businessJsonData[index].state} - ${businessJsonData[index].city}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
business.price = businessJsonData[index].price;
|
business.price = businessJsonData[index].price;
|
||||||
business.title = businessJsonData[index].title;
|
business.title = businessJsonData[index].title;
|
||||||
business.draft = businessJsonData[index].draft;
|
business.draft = businessJsonData[index].draft;
|
||||||
business.listingsCategory = 'business';
|
business.listingsCategory = 'business';
|
||||||
business.realEstateIncluded = businessJsonData[index].realEstateIncluded;
|
business.realEstateIncluded = businessJsonData[index].realEstateIncluded;
|
||||||
business.leasedLocation = businessJsonData[index].leasedLocation;
|
business.leasedLocation = businessJsonData[index].leasedLocation;
|
||||||
business.franchiseResale = businessJsonData[index].franchiseResale;
|
business.franchiseResale = businessJsonData[index].franchiseResale;
|
||||||
|
|
||||||
business.salesRevenue = businessJsonData[index].salesRevenue;
|
business.salesRevenue = businessJsonData[index].salesRevenue;
|
||||||
business.cashFlow = businessJsonData[index].cashFlow;
|
business.cashFlow = businessJsonData[index].cashFlow;
|
||||||
business.supportAndTraining = businessJsonData[index].supportAndTraining;
|
business.supportAndTraining = businessJsonData[index].supportAndTraining;
|
||||||
business.employees = businessJsonData[index].employees;
|
business.employees = businessJsonData[index].employees;
|
||||||
business.established = businessJsonData[index].established;
|
business.established = businessJsonData[index].established;
|
||||||
business.internalListingNumber = businessJsonData[index].internalListingNumber;
|
business.internalListingNumber = businessJsonData[index].internalListingNumber;
|
||||||
business.reasonForSale = businessJsonData[index].reasonForSale;
|
business.reasonForSale = businessJsonData[index].reasonForSale;
|
||||||
business.brokerLicencing = businessJsonData[index].brokerLicencing;
|
business.brokerLicencing = businessJsonData[index].brokerLicencing;
|
||||||
business.internals = businessJsonData[index].internals;
|
business.internals = businessJsonData[index].internals;
|
||||||
business.imageName = emailToDirName(user.email);
|
business.imageName = emailToDirName(user.email);
|
||||||
business.created = new Date(businessJsonData[index].created);
|
business.created = new Date(businessJsonData[index].created);
|
||||||
business.updated = new Date(businessJsonData[index].created);
|
business.updated = new Date(businessJsonData[index].created);
|
||||||
|
|
||||||
await businessService.createListing(business); //db.insert(schema.businesses).values(business);
|
await businessService.createListing(business); //db.insert(schema.businesses).values(business);
|
||||||
}
|
}
|
||||||
|
|
||||||
//End
|
//End
|
||||||
await client.end();
|
await client.end();
|
||||||
})();
|
})();
|
||||||
// function sleep(ms) {
|
// function sleep(ms) {
|
||||||
// return new Promise(resolve => setTimeout(resolve, ms));
|
// return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
// }
|
// }
|
||||||
// async function createEmbedding(text: string): Promise<number[]> {
|
// async function createEmbedding(text: string): Promise<number[]> {
|
||||||
// const response = await openai.embeddings.create({
|
// const response = await openai.embeddings.create({
|
||||||
// model: 'text-embedding-3-small',
|
// model: 'text-embedding-3-small',
|
||||||
// input: text,
|
// input: text,
|
||||||
// });
|
// });
|
||||||
// return response.data[0].embedding;
|
// return response.data[0].embedding;
|
||||||
// }
|
// }
|
||||||
|
|
||||||
function getRandomItem<T>(arr: T[]): T {
|
function getRandomItem<T>(arr: T[]): T {
|
||||||
if (arr.length === 0) {
|
if (arr.length === 0) {
|
||||||
throw new Error('The array is empty.');
|
throw new Error('The array is empty.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const randomIndex = Math.floor(Math.random() * arr.length);
|
const randomIndex = Math.floor(Math.random() * arr.length);
|
||||||
return arr[randomIndex];
|
return arr[randomIndex];
|
||||||
}
|
}
|
||||||
function getFilenames(id: string): string[] {
|
function getFilenames(id: string): string[] {
|
||||||
try {
|
try {
|
||||||
const filePath = `./pictures_base/property/${id}`;
|
const filePath = `./pictures_base/property/${id}`;
|
||||||
return readdirSync(filePath);
|
return readdirSync(filePath);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function getRandomDateWithinLastYear(): Date {
|
function getRandomDateWithinLastYear(): Date {
|
||||||
const currentDate = new Date();
|
const currentDate = new Date();
|
||||||
const lastYear = new Date(currentDate.getFullYear() - 1, currentDate.getMonth(), currentDate.getDate());
|
const lastYear = new Date(currentDate.getFullYear() - 1, currentDate.getMonth(), currentDate.getDate());
|
||||||
|
|
||||||
const timeDiff = currentDate.getTime() - lastYear.getTime();
|
const timeDiff = currentDate.getTime() - lastYear.getTime();
|
||||||
const randomTimeDiff = Math.random() * timeDiff;
|
const randomTimeDiff = Math.random() * timeDiff;
|
||||||
const randomDate = new Date(lastYear.getTime() + randomTimeDiff);
|
const randomDate = new Date(lastYear.getTime() + randomTimeDiff);
|
||||||
|
|
||||||
return randomDate;
|
return randomDate;
|
||||||
}
|
}
|
||||||
async function storeProfilePicture(buffer: Buffer, userId: string) {
|
async function storeProfilePicture(buffer: Buffer, userId: string) {
|
||||||
const quality = 50;
|
const quality = 50;
|
||||||
const output = await sharp(buffer)
|
const output = await sharp(buffer)
|
||||||
.resize({ width: 300 })
|
.resize({ width: 300 })
|
||||||
.avif({ quality }) // Verwende AVIF
|
.avif({ quality }) // Verwende AVIF
|
||||||
//.webp({ quality }) // Verwende Webp
|
//.webp({ quality }) // Verwende Webp
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
await sharp(output).toFile(`./pictures/profile/${userId}.avif`);
|
await sharp(output).toFile(`./pictures/profile/${userId}.avif`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function storeCompanyLogo(buffer: Buffer, adjustedEmail: string) {
|
async function storeCompanyLogo(buffer: Buffer, adjustedEmail: string) {
|
||||||
const quality = 50;
|
const quality = 50;
|
||||||
const output = await sharp(buffer)
|
const output = await sharp(buffer)
|
||||||
.resize({ width: 300 })
|
.resize({ width: 300 })
|
||||||
.avif({ quality }) // Verwende AVIF
|
.avif({ quality }) // Verwende AVIF
|
||||||
//.webp({ quality }) // Verwende Webp
|
//.webp({ quality }) // Verwende Webp
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
await sharp(output).toFile(`./pictures/logo/${adjustedEmail}.avif`); // Ersetze Dateierweiterung
|
await sharp(output).toFile(`./pictures/logo/${adjustedEmail}.avif`); // Ersetze Dateierweiterung
|
||||||
// await fs.outputFile(`./pictures/logo/${userId}`, file.buffer);
|
// await fs.outputFile(`./pictures/logo/${userId}`, file.buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteFilesOfDir(directoryPath) {
|
function deleteFilesOfDir(directoryPath) {
|
||||||
// Überprüfen, ob das Verzeichnis existiert
|
// Überprüfen, ob das Verzeichnis existiert
|
||||||
if (existsSync(directoryPath)) {
|
if (existsSync(directoryPath)) {
|
||||||
// Den Inhalt des Verzeichnisses synchron löschen
|
// Den Inhalt des Verzeichnisses synchron löschen
|
||||||
try {
|
try {
|
||||||
readdirSync(directoryPath).forEach(file => {
|
readdirSync(directoryPath).forEach(file => {
|
||||||
const filePath = join(directoryPath, file);
|
const filePath = join(directoryPath, file);
|
||||||
// Wenn es sich um ein Verzeichnis handelt, rekursiv löschen
|
// Wenn es sich um ein Verzeichnis handelt, rekursiv löschen
|
||||||
if (statSync(filePath).isDirectory()) {
|
if (statSync(filePath).isDirectory()) {
|
||||||
rimraf.sync(filePath);
|
rimraf.sync(filePath);
|
||||||
} else {
|
} else {
|
||||||
// Wenn es sich um eine Datei handelt, direkt löschen
|
// Wenn es sich um eine Datei handelt, direkt löschen
|
||||||
unlinkSync(filePath);
|
unlinkSync(filePath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
console.log('Der Inhalt des Verzeichnisses wurde erfolgreich gelöscht.');
|
console.log('Der Inhalt des Verzeichnisses wurde erfolgreich gelöscht.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fehler beim Löschen des Verzeichnisses:', err);
|
console.error('Fehler beim Löschen des Verzeichnisses:', err);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log('Das Verzeichnis existiert nicht.');
|
console.log('Das Verzeichnis existiert nicht.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,175 +1,175 @@
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import { boolean, doublePrecision, index, integer, jsonb, pgEnum, pgTable, serial, text, timestamp, uuid, varchar } from 'drizzle-orm/pg-core';
|
import { boolean, doublePrecision, index, integer, jsonb, pgEnum, pgTable, serial, text, timestamp, uuid, varchar } from 'drizzle-orm/pg-core';
|
||||||
import { AreasServed, LicensedIn } from '../models/db.model';
|
import { AreasServed, LicensedIn } from '../models/db.model';
|
||||||
export const PG_CONNECTION = 'PG_CONNECTION';
|
export const PG_CONNECTION = 'PG_CONNECTION';
|
||||||
export const genderEnum = pgEnum('gender', ['male', 'female']);
|
export const genderEnum = pgEnum('gender', ['male', 'female']);
|
||||||
export const customerTypeEnum = pgEnum('customerType', ['buyer', 'seller', 'professional']);
|
export const customerTypeEnum = pgEnum('customerType', ['buyer', 'seller', 'professional']);
|
||||||
export const customerSubTypeEnum = pgEnum('customerSubType', ['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']);
|
export const customerSubTypeEnum = pgEnum('customerSubType', ['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']);
|
||||||
export const listingsCategoryEnum = pgEnum('listingsCategory', ['commercialProperty', 'business']);
|
export const listingsCategoryEnum = pgEnum('listingsCategory', ['commercialProperty', 'business']);
|
||||||
export const subscriptionTypeEnum = pgEnum('subscriptionType', ['free', 'professional', 'broker']);
|
export const subscriptionTypeEnum = pgEnum('subscriptionType', ['free', 'professional', 'broker']);
|
||||||
|
|
||||||
// Neue JSONB-basierte Tabellen
|
// Neue JSONB-basierte Tabellen
|
||||||
export const users_json = pgTable(
|
export const users_json = pgTable(
|
||||||
'users_json',
|
'users_json',
|
||||||
{
|
{
|
||||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||||
email: varchar('email', { length: 255 }).notNull().unique(),
|
email: varchar('email', { length: 255 }).notNull().unique(),
|
||||||
data: jsonb('data'),
|
data: jsonb('data'),
|
||||||
},
|
},
|
||||||
table => ({
|
table => ({
|
||||||
emailIdx: index('idx_users_json_email').on(table.email),
|
emailIdx: index('idx_users_json_email').on(table.email),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const businesses_json = pgTable(
|
export const businesses_json = pgTable(
|
||||||
'businesses_json',
|
'businesses_json',
|
||||||
{
|
{
|
||||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||||
email: varchar('email', { length: 255 }).references(() => users_json.email),
|
email: varchar('email', { length: 255 }).references(() => users_json.email),
|
||||||
data: jsonb('data'),
|
data: jsonb('data'),
|
||||||
},
|
},
|
||||||
table => ({
|
table => ({
|
||||||
emailIdx: index('idx_businesses_json_email').on(table.email),
|
emailIdx: index('idx_businesses_json_email').on(table.email),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const commercials_json = pgTable(
|
export const commercials_json = pgTable(
|
||||||
'commercials_json',
|
'commercials_json',
|
||||||
{
|
{
|
||||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||||
email: varchar('email', { length: 255 }).references(() => users_json.email),
|
email: varchar('email', { length: 255 }).references(() => users_json.email),
|
||||||
data: jsonb('data'),
|
data: jsonb('data'),
|
||||||
},
|
},
|
||||||
table => ({
|
table => ({
|
||||||
emailIdx: index('idx_commercials_json_email').on(table.email),
|
emailIdx: index('idx_commercials_json_email').on(table.email),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const listing_events_json = pgTable(
|
export const listing_events_json = pgTable(
|
||||||
'listing_events_json',
|
'listing_events_json',
|
||||||
{
|
{
|
||||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||||
email: varchar('email', { length: 255 }),
|
email: varchar('email', { length: 255 }),
|
||||||
data: jsonb('data'),
|
data: jsonb('data'),
|
||||||
},
|
},
|
||||||
table => ({
|
table => ({
|
||||||
emailIdx: index('idx_listing_events_json_email').on(table.email),
|
emailIdx: index('idx_listing_events_json_email').on(table.email),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Bestehende Tabellen bleiben unverändert
|
// Bestehende Tabellen bleiben unverändert
|
||||||
export const users = pgTable(
|
export const users = pgTable(
|
||||||
'users',
|
'users',
|
||||||
{
|
{
|
||||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||||
firstname: varchar('firstname', { length: 255 }).notNull(),
|
firstname: varchar('firstname', { length: 255 }).notNull(),
|
||||||
lastname: varchar('lastname', { length: 255 }).notNull(),
|
lastname: varchar('lastname', { length: 255 }).notNull(),
|
||||||
email: varchar('email', { length: 255 }).notNull().unique(),
|
email: varchar('email', { length: 255 }).notNull().unique(),
|
||||||
phoneNumber: varchar('phoneNumber', { length: 255 }),
|
phoneNumber: varchar('phoneNumber', { length: 255 }),
|
||||||
description: text('description'),
|
description: text('description'),
|
||||||
companyName: varchar('companyName', { length: 255 }),
|
companyName: varchar('companyName', { length: 255 }),
|
||||||
companyOverview: text('companyOverview'),
|
companyOverview: text('companyOverview'),
|
||||||
companyWebsite: varchar('companyWebsite', { length: 255 }),
|
companyWebsite: varchar('companyWebsite', { length: 255 }),
|
||||||
offeredServices: text('offeredServices'),
|
offeredServices: text('offeredServices'),
|
||||||
areasServed: jsonb('areasServed').$type<AreasServed[]>(),
|
areasServed: jsonb('areasServed').$type<AreasServed[]>(),
|
||||||
hasProfile: boolean('hasProfile'),
|
hasProfile: boolean('hasProfile'),
|
||||||
hasCompanyLogo: boolean('hasCompanyLogo'),
|
hasCompanyLogo: boolean('hasCompanyLogo'),
|
||||||
licensedIn: jsonb('licensedIn').$type<LicensedIn[]>(),
|
licensedIn: jsonb('licensedIn').$type<LicensedIn[]>(),
|
||||||
gender: genderEnum('gender'),
|
gender: genderEnum('gender'),
|
||||||
customerType: customerTypeEnum('customerType'),
|
customerType: customerTypeEnum('customerType'),
|
||||||
customerSubType: customerSubTypeEnum('customerSubType'),
|
customerSubType: customerSubTypeEnum('customerSubType'),
|
||||||
created: timestamp('created'),
|
created: timestamp('created'),
|
||||||
updated: timestamp('updated'),
|
updated: timestamp('updated'),
|
||||||
subscriptionId: text('subscriptionId'),
|
subscriptionId: text('subscriptionId'),
|
||||||
subscriptionPlan: subscriptionTypeEnum('subscriptionPlan'),
|
subscriptionPlan: subscriptionTypeEnum('subscriptionPlan'),
|
||||||
location: jsonb('location'),
|
location: jsonb('location'),
|
||||||
showInDirectory: boolean('showInDirectory').default(true),
|
showInDirectory: boolean('showInDirectory').default(true),
|
||||||
},
|
},
|
||||||
table => ({
|
table => ({
|
||||||
locationUserCityStateIdx: index('idx_user_location_city_state').on(
|
locationUserCityStateIdx: index('idx_user_location_city_state').on(
|
||||||
sql`((${table.location}->>'name')::varchar), ((${table.location}->>'state')::varchar), ((${table.location}->>'latitude')::float), ((${table.location}->>'longitude')::float)`,
|
sql`((${table.location}->>'name')::varchar), ((${table.location}->>'state')::varchar), ((${table.location}->>'latitude')::float), ((${table.location}->>'longitude')::float)`,
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const businesses = pgTable(
|
export const businesses = pgTable(
|
||||||
'businesses',
|
'businesses',
|
||||||
{
|
{
|
||||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||||
email: varchar('email', { length: 255 }).references(() => users.email),
|
email: varchar('email', { length: 255 }).references(() => users.email),
|
||||||
type: varchar('type', { length: 255 }),
|
type: varchar('type', { length: 255 }),
|
||||||
title: varchar('title', { length: 255 }),
|
title: varchar('title', { length: 255 }),
|
||||||
description: text('description'),
|
description: text('description'),
|
||||||
price: doublePrecision('price'),
|
price: doublePrecision('price'),
|
||||||
favoritesForUser: varchar('favoritesForUser', { length: 30 }).array(),
|
favoritesForUser: varchar('favoritesForUser', { length: 30 }).array(),
|
||||||
draft: boolean('draft'),
|
draft: boolean('draft'),
|
||||||
listingsCategory: listingsCategoryEnum('listingsCategory'),
|
listingsCategory: listingsCategoryEnum('listingsCategory'),
|
||||||
realEstateIncluded: boolean('realEstateIncluded'),
|
realEstateIncluded: boolean('realEstateIncluded'),
|
||||||
leasedLocation: boolean('leasedLocation'),
|
leasedLocation: boolean('leasedLocation'),
|
||||||
franchiseResale: boolean('franchiseResale'),
|
franchiseResale: boolean('franchiseResale'),
|
||||||
salesRevenue: doublePrecision('salesRevenue'),
|
salesRevenue: doublePrecision('salesRevenue'),
|
||||||
cashFlow: doublePrecision('cashFlow'),
|
cashFlow: doublePrecision('cashFlow'),
|
||||||
supportAndTraining: text('supportAndTraining'),
|
supportAndTraining: text('supportAndTraining'),
|
||||||
employees: integer('employees'),
|
employees: integer('employees'),
|
||||||
established: integer('established'),
|
established: integer('established'),
|
||||||
internalListingNumber: integer('internalListingNumber'),
|
internalListingNumber: integer('internalListingNumber'),
|
||||||
reasonForSale: varchar('reasonForSale', { length: 255 }),
|
reasonForSale: varchar('reasonForSale', { length: 255 }),
|
||||||
brokerLicencing: varchar('brokerLicencing', { length: 255 }),
|
brokerLicencing: varchar('brokerLicencing', { length: 255 }),
|
||||||
internals: text('internals'),
|
internals: text('internals'),
|
||||||
imageName: varchar('imageName', { length: 200 }),
|
imageName: varchar('imageName', { length: 200 }),
|
||||||
slug: varchar('slug', { length: 300 }).unique(),
|
slug: varchar('slug', { length: 300 }).unique(),
|
||||||
created: timestamp('created'),
|
created: timestamp('created'),
|
||||||
updated: timestamp('updated'),
|
updated: timestamp('updated'),
|
||||||
location: jsonb('location'),
|
location: jsonb('location'),
|
||||||
},
|
},
|
||||||
table => ({
|
table => ({
|
||||||
locationBusinessCityStateIdx: index('idx_business_location_city_state').on(
|
locationBusinessCityStateIdx: index('idx_business_location_city_state').on(
|
||||||
sql`((${table.location}->>'name')::varchar), ((${table.location}->>'state')::varchar), ((${table.location}->>'latitude')::float), ((${table.location}->>'longitude')::float)`,
|
sql`((${table.location}->>'name')::varchar), ((${table.location}->>'state')::varchar), ((${table.location}->>'latitude')::float), ((${table.location}->>'longitude')::float)`,
|
||||||
),
|
),
|
||||||
slugIdx: index('idx_business_slug').on(table.slug),
|
slugIdx: index('idx_business_slug').on(table.slug),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const commercials = pgTable(
|
export const commercials = pgTable(
|
||||||
'commercials',
|
'commercials',
|
||||||
{
|
{
|
||||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||||
serialId: serial('serialId'),
|
serialId: serial('serialId'),
|
||||||
email: varchar('email', { length: 255 }).references(() => users.email),
|
email: varchar('email', { length: 255 }).references(() => users.email),
|
||||||
type: varchar('type', { length: 255 }),
|
type: varchar('type', { length: 255 }),
|
||||||
title: varchar('title', { length: 255 }),
|
title: varchar('title', { length: 255 }),
|
||||||
description: text('description'),
|
description: text('description'),
|
||||||
price: doublePrecision('price'),
|
price: doublePrecision('price'),
|
||||||
favoritesForUser: varchar('favoritesForUser', { length: 30 }).array(),
|
favoritesForUser: varchar('favoritesForUser', { length: 30 }).array(),
|
||||||
listingsCategory: listingsCategoryEnum('listingsCategory'),
|
listingsCategory: listingsCategoryEnum('listingsCategory'),
|
||||||
draft: boolean('draft'),
|
draft: boolean('draft'),
|
||||||
imageOrder: varchar('imageOrder', { length: 200 }).array(),
|
imageOrder: varchar('imageOrder', { length: 200 }).array(),
|
||||||
imagePath: varchar('imagePath', { length: 200 }),
|
imagePath: varchar('imagePath', { length: 200 }),
|
||||||
slug: varchar('slug', { length: 300 }).unique(),
|
slug: varchar('slug', { length: 300 }).unique(),
|
||||||
created: timestamp('created'),
|
created: timestamp('created'),
|
||||||
updated: timestamp('updated'),
|
updated: timestamp('updated'),
|
||||||
location: jsonb('location'),
|
location: jsonb('location'),
|
||||||
},
|
},
|
||||||
table => ({
|
table => ({
|
||||||
locationCommercialsCityStateIdx: index('idx_commercials_location_city_state').on(
|
locationCommercialsCityStateIdx: index('idx_commercials_location_city_state').on(
|
||||||
sql`((${table.location}->>'name')::varchar), ((${table.location}->>'state')::varchar), ((${table.location}->>'latitude')::float), ((${table.location}->>'longitude')::float)`,
|
sql`((${table.location}->>'name')::varchar), ((${table.location}->>'state')::varchar), ((${table.location}->>'latitude')::float), ((${table.location}->>'longitude')::float)`,
|
||||||
),
|
),
|
||||||
slugIdx: index('idx_commercials_slug').on(table.slug),
|
slugIdx: index('idx_commercials_slug').on(table.slug),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const listing_events = pgTable('listing_events', {
|
export const listing_events = pgTable('listing_events', {
|
||||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||||
listingId: varchar('listing_id', { length: 255 }),
|
listingId: varchar('listing_id', { length: 255 }),
|
||||||
email: varchar('email', { length: 255 }),
|
email: varchar('email', { length: 255 }),
|
||||||
eventType: varchar('event_type', { length: 50 }),
|
eventType: varchar('event_type', { length: 50 }),
|
||||||
eventTimestamp: timestamp('event_timestamp').defaultNow(),
|
eventTimestamp: timestamp('event_timestamp').defaultNow(),
|
||||||
userIp: varchar('user_ip', { length: 45 }),
|
userIp: varchar('user_ip', { length: 45 }),
|
||||||
userAgent: varchar('user_agent', { length: 255 }),
|
userAgent: varchar('user_agent', { length: 255 }),
|
||||||
locationCountry: varchar('location_country', { length: 100 }),
|
locationCountry: varchar('location_country', { length: 100 }),
|
||||||
locationCity: varchar('location_city', { length: 100 }),
|
locationCity: varchar('location_city', { length: 100 }),
|
||||||
locationLat: varchar('location_lat', { length: 20 }),
|
locationLat: varchar('location_lat', { length: 20 }),
|
||||||
locationLng: varchar('location_lng', { length: 20 }),
|
locationLng: varchar('location_lng', { length: 20 }),
|
||||||
referrer: varchar('referrer', { length: 255 }),
|
referrer: varchar('referrer', { length: 255 }),
|
||||||
additionalData: jsonb('additional_data'),
|
additionalData: jsonb('additional_data'),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,431 +1,431 @@
|
||||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { and, arrayContains, asc, count, desc, eq, gte, inArray, lte, or, SQL, sql } from 'drizzle-orm';
|
import { and, arrayContains, asc, count, desc, eq, gte, inArray, lte, or, SQL, sql } from 'drizzle-orm';
|
||||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||||
import { Logger } from 'winston';
|
import { Logger } from 'winston';
|
||||||
import { ZodError } from 'zod';
|
import { ZodError } from 'zod';
|
||||||
import * as schema from '../drizzle/schema';
|
import * as schema from '../drizzle/schema';
|
||||||
import { businesses_json, PG_CONNECTION } from '../drizzle/schema';
|
import { businesses_json, PG_CONNECTION } from '../drizzle/schema';
|
||||||
import { GeoService } from '../geo/geo.service';
|
import { GeoService } from '../geo/geo.service';
|
||||||
import { BusinessListing, BusinessListingSchema } from '../models/db.model';
|
import { BusinessListing, BusinessListingSchema } from '../models/db.model';
|
||||||
import { BusinessListingCriteria, JwtUser } from '../models/main.model';
|
import { BusinessListingCriteria, JwtUser } from '../models/main.model';
|
||||||
import { getDistanceQuery, splitName } from '../utils';
|
import { getDistanceQuery, splitName } from '../utils';
|
||||||
import { generateSlug, extractShortIdFromSlug, isSlug } from '../utils/slug.utils';
|
import { generateSlug, extractShortIdFromSlug, isSlug } from '../utils/slug.utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BusinessListingService {
|
export class BusinessListingService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
||||||
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
|
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
|
||||||
private geoService?: GeoService,
|
private geoService?: GeoService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
private getWhereConditions(criteria: BusinessListingCriteria, user: JwtUser): SQL[] {
|
private getWhereConditions(criteria: BusinessListingCriteria, user: JwtUser): SQL[] {
|
||||||
const whereConditions: SQL[] = [];
|
const whereConditions: SQL[] = [];
|
||||||
this.logger.info('getWhereConditions start', { criteria: JSON.stringify(criteria) });
|
this.logger.info('getWhereConditions start', { criteria: JSON.stringify(criteria) });
|
||||||
|
|
||||||
if (criteria.city && criteria.searchType === 'exact') {
|
if (criteria.city && criteria.searchType === 'exact') {
|
||||||
whereConditions.push(sql`(${businesses_json.data}->'location'->>'name') ILIKE ${criteria.city.name}`);
|
whereConditions.push(sql`(${businesses_json.data}->'location'->>'name') ILIKE ${criteria.city.name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) {
|
if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) {
|
||||||
this.logger.debug('Adding radius search filter', { city: criteria.city.name, radius: criteria.radius });
|
this.logger.debug('Adding radius search filter', { city: criteria.city.name, radius: criteria.radius });
|
||||||
const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name);
|
const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name);
|
||||||
whereConditions.push(sql`(${getDistanceQuery(businesses_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius})`);
|
whereConditions.push(sql`(${getDistanceQuery(businesses_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius})`);
|
||||||
}
|
}
|
||||||
if (criteria.types && criteria.types.length > 0) {
|
if (criteria.types && criteria.types.length > 0) {
|
||||||
this.logger.warn('Adding business category filter', { types: criteria.types });
|
this.logger.warn('Adding business category filter', { types: criteria.types });
|
||||||
// Use explicit SQL with IN for robust JSONB comparison
|
// Use explicit SQL with IN for robust JSONB comparison
|
||||||
const typeValues = criteria.types.map(t => sql`${t}`);
|
const typeValues = criteria.types.map(t => sql`${t}`);
|
||||||
whereConditions.push(sql`((${businesses_json.data}->>'type') IN (${sql.join(typeValues, sql`, `)}))`);
|
whereConditions.push(sql`((${businesses_json.data}->>'type') IN (${sql.join(typeValues, sql`, `)}))`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.state) {
|
if (criteria.state) {
|
||||||
this.logger.debug('Adding state filter', { state: criteria.state });
|
this.logger.debug('Adding state filter', { state: criteria.state });
|
||||||
whereConditions.push(sql`(${businesses_json.data}->'location'->>'state') = ${criteria.state}`);
|
whereConditions.push(sql`(${businesses_json.data}->'location'->>'state') = ${criteria.state}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.minPrice !== undefined && criteria.minPrice !== null) {
|
if (criteria.minPrice !== undefined && criteria.minPrice !== null) {
|
||||||
whereConditions.push(
|
whereConditions.push(
|
||||||
and(
|
and(
|
||||||
sql`(${businesses_json.data}->>'price') IS NOT NULL`,
|
sql`(${businesses_json.data}->>'price') IS NOT NULL`,
|
||||||
sql`(${businesses_json.data}->>'price') != ''`,
|
sql`(${businesses_json.data}->>'price') != ''`,
|
||||||
gte(sql`REPLACE(${businesses_json.data}->>'price', ',', '')::double precision`, criteria.minPrice)
|
gte(sql`REPLACE(${businesses_json.data}->>'price', ',', '')::double precision`, criteria.minPrice)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.maxPrice !== undefined && criteria.maxPrice !== null) {
|
if (criteria.maxPrice !== undefined && criteria.maxPrice !== null) {
|
||||||
whereConditions.push(
|
whereConditions.push(
|
||||||
and(
|
and(
|
||||||
sql`(${businesses_json.data}->>'price') IS NOT NULL`,
|
sql`(${businesses_json.data}->>'price') IS NOT NULL`,
|
||||||
sql`(${businesses_json.data}->>'price') != ''`,
|
sql`(${businesses_json.data}->>'price') != ''`,
|
||||||
lte(sql`REPLACE(${businesses_json.data}->>'price', ',', '')::double precision`, criteria.maxPrice)
|
lte(sql`REPLACE(${businesses_json.data}->>'price', ',', '')::double precision`, criteria.maxPrice)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.minRevenue) {
|
if (criteria.minRevenue) {
|
||||||
whereConditions.push(gte(sql`(${businesses_json.data}->>'salesRevenue')::double precision`, criteria.minRevenue));
|
whereConditions.push(gte(sql`(${businesses_json.data}->>'salesRevenue')::double precision`, criteria.minRevenue));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.maxRevenue) {
|
if (criteria.maxRevenue) {
|
||||||
whereConditions.push(lte(sql`(${businesses_json.data}->>'salesRevenue')::double precision`, criteria.maxRevenue));
|
whereConditions.push(lte(sql`(${businesses_json.data}->>'salesRevenue')::double precision`, criteria.maxRevenue));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.minCashFlow) {
|
if (criteria.minCashFlow) {
|
||||||
whereConditions.push(gte(sql`(${businesses_json.data}->>'cashFlow')::double precision`, criteria.minCashFlow));
|
whereConditions.push(gte(sql`(${businesses_json.data}->>'cashFlow')::double precision`, criteria.minCashFlow));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.maxCashFlow) {
|
if (criteria.maxCashFlow) {
|
||||||
whereConditions.push(lte(sql`(${businesses_json.data}->>'cashFlow')::double precision`, criteria.maxCashFlow));
|
whereConditions.push(lte(sql`(${businesses_json.data}->>'cashFlow')::double precision`, criteria.maxCashFlow));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.minNumberEmployees) {
|
if (criteria.minNumberEmployees) {
|
||||||
whereConditions.push(gte(sql`(${businesses_json.data}->>'employees')::integer`, criteria.minNumberEmployees));
|
whereConditions.push(gte(sql`(${businesses_json.data}->>'employees')::integer`, criteria.minNumberEmployees));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.maxNumberEmployees) {
|
if (criteria.maxNumberEmployees) {
|
||||||
whereConditions.push(lte(sql`(${businesses_json.data}->>'employees')::integer`, criteria.maxNumberEmployees));
|
whereConditions.push(lte(sql`(${businesses_json.data}->>'employees')::integer`, criteria.maxNumberEmployees));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.establishedMin) {
|
if (criteria.establishedMin) {
|
||||||
whereConditions.push(gte(sql`(${businesses_json.data}->>'established')::integer`, criteria.establishedMin));
|
whereConditions.push(gte(sql`(${businesses_json.data}->>'established')::integer`, criteria.establishedMin));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.realEstateChecked) {
|
if (criteria.realEstateChecked) {
|
||||||
whereConditions.push(eq(sql`(${businesses_json.data}->>'realEstateIncluded')::boolean`, criteria.realEstateChecked));
|
whereConditions.push(eq(sql`(${businesses_json.data}->>'realEstateIncluded')::boolean`, criteria.realEstateChecked));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.leasedLocation) {
|
if (criteria.leasedLocation) {
|
||||||
whereConditions.push(eq(sql`(${businesses_json.data}->>'leasedLocation')::boolean`, criteria.leasedLocation));
|
whereConditions.push(eq(sql`(${businesses_json.data}->>'leasedLocation')::boolean`, criteria.leasedLocation));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.franchiseResale) {
|
if (criteria.franchiseResale) {
|
||||||
whereConditions.push(eq(sql`(${businesses_json.data}->>'franchiseResale')::boolean`, criteria.franchiseResale));
|
whereConditions.push(eq(sql`(${businesses_json.data}->>'franchiseResale')::boolean`, criteria.franchiseResale));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.title && criteria.title.trim() !== '') {
|
if (criteria.title && criteria.title.trim() !== '') {
|
||||||
const searchTerm = `%${criteria.title.trim()}%`;
|
const searchTerm = `%${criteria.title.trim()}%`;
|
||||||
whereConditions.push(
|
whereConditions.push(
|
||||||
sql`((${businesses_json.data}->>'title') ILIKE ${searchTerm} OR (${businesses_json.data}->>'description') ILIKE ${searchTerm})`
|
sql`((${businesses_json.data}->>'title') ILIKE ${searchTerm} OR (${businesses_json.data}->>'description') ILIKE ${searchTerm})`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (criteria.brokerName) {
|
if (criteria.brokerName) {
|
||||||
const { firstname, lastname } = splitName(criteria.brokerName);
|
const { firstname, lastname } = splitName(criteria.brokerName);
|
||||||
if (firstname === lastname) {
|
if (firstname === lastname) {
|
||||||
whereConditions.push(
|
whereConditions.push(
|
||||||
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
|
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
whereConditions.push(
|
whereConditions.push(
|
||||||
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} AND (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
|
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} AND (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (criteria.email) {
|
if (criteria.email) {
|
||||||
whereConditions.push(eq(schema.users_json.email, criteria.email));
|
whereConditions.push(eq(schema.users_json.email, criteria.email));
|
||||||
}
|
}
|
||||||
if (user?.role !== 'admin') {
|
if (user?.role !== 'admin') {
|
||||||
whereConditions.push(
|
whereConditions.push(
|
||||||
sql`((${businesses_json.email} = ${user?.email || null}) OR (${businesses_json.data}->>'draft')::boolean IS NOT TRUE)`
|
sql`((${businesses_json.email} = ${user?.email || null}) OR (${businesses_json.data}->>'draft')::boolean IS NOT TRUE)`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.logger.warn('whereConditions count', { count: whereConditions.length });
|
this.logger.warn('whereConditions count', { count: whereConditions.length });
|
||||||
return whereConditions;
|
return whereConditions;
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchBusinessListings(criteria: BusinessListingCriteria, user: JwtUser) {
|
async searchBusinessListings(criteria: BusinessListingCriteria, user: JwtUser) {
|
||||||
const start = criteria.start ? criteria.start : 0;
|
const start = criteria.start ? criteria.start : 0;
|
||||||
const length = criteria.length ? criteria.length : 12;
|
const length = criteria.length ? criteria.length : 12;
|
||||||
const query = this.conn
|
const query = this.conn
|
||||||
.select({
|
.select({
|
||||||
business: businesses_json,
|
business: businesses_json,
|
||||||
brokerFirstName: sql`${schema.users_json.data}->>'firstname'`.as('brokerFirstName'),
|
brokerFirstName: sql`${schema.users_json.data}->>'firstname'`.as('brokerFirstName'),
|
||||||
brokerLastName: sql`${schema.users_json.data}->>'lastname'`.as('brokerLastName'),
|
brokerLastName: sql`${schema.users_json.data}->>'lastname'`.as('brokerLastName'),
|
||||||
})
|
})
|
||||||
.from(businesses_json)
|
.from(businesses_json)
|
||||||
.leftJoin(schema.users_json, eq(businesses_json.email, schema.users_json.email));
|
.leftJoin(schema.users_json, eq(businesses_json.email, schema.users_json.email));
|
||||||
|
|
||||||
const whereConditions = this.getWhereConditions(criteria, user);
|
const whereConditions = this.getWhereConditions(criteria, user);
|
||||||
|
|
||||||
this.logger.warn('Filter Criteria:', { criteria: JSON.stringify(criteria) });
|
this.logger.warn('Filter Criteria:', { criteria: JSON.stringify(criteria) });
|
||||||
|
|
||||||
if (whereConditions.length > 0) {
|
if (whereConditions.length > 0) {
|
||||||
const whereClause = sql.join(whereConditions, sql` AND `);
|
const whereClause = sql.join(whereConditions, sql` AND `);
|
||||||
query.where(sql`(${whereClause})`);
|
query.where(sql`(${whereClause})`);
|
||||||
|
|
||||||
this.logger.warn('Generated SQL:', { sql: query.toSQL().sql, params: query.toSQL().params });
|
this.logger.warn('Generated SQL:', { sql: query.toSQL().sql, params: query.toSQL().params });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sortierung
|
// Sortierung
|
||||||
switch (criteria.sortBy) {
|
switch (criteria.sortBy) {
|
||||||
case 'priceAsc':
|
case 'priceAsc':
|
||||||
query.orderBy(asc(sql`(${businesses_json.data}->>'price')::double precision`));
|
query.orderBy(asc(sql`(${businesses_json.data}->>'price')::double precision`));
|
||||||
break;
|
break;
|
||||||
case 'priceDesc':
|
case 'priceDesc':
|
||||||
query.orderBy(desc(sql`(${businesses_json.data}->>'price')::double precision`));
|
query.orderBy(desc(sql`(${businesses_json.data}->>'price')::double precision`));
|
||||||
break;
|
break;
|
||||||
case 'srAsc':
|
case 'srAsc':
|
||||||
query.orderBy(asc(sql`(${businesses_json.data}->>'salesRevenue')::double precision`));
|
query.orderBy(asc(sql`(${businesses_json.data}->>'salesRevenue')::double precision`));
|
||||||
break;
|
break;
|
||||||
case 'srDesc':
|
case 'srDesc':
|
||||||
query.orderBy(desc(sql`(${businesses_json.data}->>'salesRevenue')::double precision`));
|
query.orderBy(desc(sql`(${businesses_json.data}->>'salesRevenue')::double precision`));
|
||||||
break;
|
break;
|
||||||
case 'cfAsc':
|
case 'cfAsc':
|
||||||
query.orderBy(asc(sql`(${businesses_json.data}->>'cashFlow')::double precision`));
|
query.orderBy(asc(sql`(${businesses_json.data}->>'cashFlow')::double precision`));
|
||||||
break;
|
break;
|
||||||
case 'cfDesc':
|
case 'cfDesc':
|
||||||
query.orderBy(desc(sql`(${businesses_json.data}->>'cashFlow')::double precision`));
|
query.orderBy(desc(sql`(${businesses_json.data}->>'cashFlow')::double precision`));
|
||||||
break;
|
break;
|
||||||
case 'creationDateFirst':
|
case 'creationDateFirst':
|
||||||
query.orderBy(asc(sql`${businesses_json.data}->>'created'`));
|
query.orderBy(asc(sql`${businesses_json.data}->>'created'`));
|
||||||
break;
|
break;
|
||||||
case 'creationDateLast':
|
case 'creationDateLast':
|
||||||
query.orderBy(desc(sql`${businesses_json.data}->>'created'`));
|
query.orderBy(desc(sql`${businesses_json.data}->>'created'`));
|
||||||
break;
|
break;
|
||||||
default: {
|
default: {
|
||||||
// NEU (created < 14 Tage) > UPDATED (updated < 14 Tage) > Rest
|
// NEU (created < 14 Tage) > UPDATED (updated < 14 Tage) > Rest
|
||||||
const recencyRank = sql`
|
const recencyRank = sql`
|
||||||
CASE
|
CASE
|
||||||
WHEN ((${businesses_json.data}->>'created')::timestamptz >= (now() - interval '14 days')) THEN 2
|
WHEN ((${businesses_json.data}->>'created')::timestamptz >= (now() - interval '14 days')) THEN 2
|
||||||
WHEN ((${businesses_json.data}->>'updated')::timestamptz >= (now() - interval '14 days')) THEN 1
|
WHEN ((${businesses_json.data}->>'updated')::timestamptz >= (now() - interval '14 days')) THEN 1
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END
|
END
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Innerhalb der Gruppe:
|
// Innerhalb der Gruppe:
|
||||||
// NEW → created DESC
|
// NEW → created DESC
|
||||||
// UPDATED → updated DESC
|
// UPDATED → updated DESC
|
||||||
// Rest → created DESC
|
// Rest → created DESC
|
||||||
const groupTimestamp = sql`
|
const groupTimestamp = sql`
|
||||||
CASE
|
CASE
|
||||||
WHEN ((${businesses_json.data}->>'created')::timestamptz >= (now() - interval '14 days'))
|
WHEN ((${businesses_json.data}->>'created')::timestamptz >= (now() - interval '14 days'))
|
||||||
THEN (${businesses_json.data}->>'created')::timestamptz
|
THEN (${businesses_json.data}->>'created')::timestamptz
|
||||||
WHEN ((${businesses_json.data}->>'updated')::timestamptz >= (now() - interval '14 days'))
|
WHEN ((${businesses_json.data}->>'updated')::timestamptz >= (now() - interval '14 days'))
|
||||||
THEN (${businesses_json.data}->>'updated')::timestamptz
|
THEN (${businesses_json.data}->>'updated')::timestamptz
|
||||||
ELSE (${businesses_json.data}->>'created')::timestamptz
|
ELSE (${businesses_json.data}->>'created')::timestamptz
|
||||||
END
|
END
|
||||||
`;
|
`;
|
||||||
|
|
||||||
query.orderBy(desc(recencyRank), desc(groupTimestamp), desc(sql`(${businesses_json.data}->>'created')::timestamptz`));
|
query.orderBy(desc(recencyRank), desc(groupTimestamp), desc(sql`(${businesses_json.data}->>'created')::timestamptz`));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Paginierung
|
// Paginierung
|
||||||
query.limit(length).offset(start);
|
query.limit(length).offset(start);
|
||||||
|
|
||||||
const data = await query;
|
const data = await query;
|
||||||
const totalCount = await this.getBusinessListingsCount(criteria, user);
|
const totalCount = await this.getBusinessListingsCount(criteria, user);
|
||||||
const results = data.map(r => ({
|
const results = data.map(r => ({
|
||||||
id: r.business.id,
|
id: r.business.id,
|
||||||
email: r.business.email,
|
email: r.business.email,
|
||||||
...(r.business.data as BusinessListing),
|
...(r.business.data as BusinessListing),
|
||||||
brokerFirstName: r.brokerFirstName,
|
brokerFirstName: r.brokerFirstName,
|
||||||
brokerLastName: r.brokerLastName,
|
brokerLastName: r.brokerLastName,
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
results,
|
results,
|
||||||
totalCount,
|
totalCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBusinessListingsCount(criteria: BusinessListingCriteria, user: JwtUser): Promise<number> {
|
async getBusinessListingsCount(criteria: BusinessListingCriteria, user: JwtUser): Promise<number> {
|
||||||
const countQuery = this.conn.select({ value: count() }).from(businesses_json).leftJoin(schema.users_json, eq(businesses_json.email, schema.users_json.email));
|
const countQuery = this.conn.select({ value: count() }).from(businesses_json).leftJoin(schema.users_json, eq(businesses_json.email, schema.users_json.email));
|
||||||
|
|
||||||
const whereConditions = this.getWhereConditions(criteria, user);
|
const whereConditions = this.getWhereConditions(criteria, user);
|
||||||
|
|
||||||
if (whereConditions.length > 0) {
|
if (whereConditions.length > 0) {
|
||||||
const whereClause = sql.join(whereConditions, sql` AND `);
|
const whereClause = sql.join(whereConditions, sql` AND `);
|
||||||
countQuery.where(sql`(${whereClause})`);
|
countQuery.where(sql`(${whereClause})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [{ value: totalCount }] = await countQuery;
|
const [{ value: totalCount }] = await countQuery;
|
||||||
return totalCount;
|
return totalCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find business by slug or ID
|
* Find business by slug or ID
|
||||||
* Supports both slug (e.g., "restaurant-austin-tx-a3f7b2c1") and UUID
|
* Supports both slug (e.g., "restaurant-austin-tx-a3f7b2c1") and UUID
|
||||||
*/
|
*/
|
||||||
async findBusinessBySlugOrId(slugOrId: string, user: JwtUser): Promise<BusinessListing> {
|
async findBusinessBySlugOrId(slugOrId: string, user: JwtUser): Promise<BusinessListing> {
|
||||||
this.logger.debug(`findBusinessBySlugOrId called with: ${slugOrId}`);
|
this.logger.debug(`findBusinessBySlugOrId called with: ${slugOrId}`);
|
||||||
|
|
||||||
let id = slugOrId;
|
let id = slugOrId;
|
||||||
|
|
||||||
// Check if it's a slug (contains multiple hyphens) vs UUID
|
// Check if it's a slug (contains multiple hyphens) vs UUID
|
||||||
if (isSlug(slugOrId)) {
|
if (isSlug(slugOrId)) {
|
||||||
this.logger.debug(`Detected as slug: ${slugOrId}`);
|
this.logger.debug(`Detected as slug: ${slugOrId}`);
|
||||||
|
|
||||||
// Extract short ID from slug and find by slug field
|
// Extract short ID from slug and find by slug field
|
||||||
const listing = await this.findBusinessBySlug(slugOrId);
|
const listing = await this.findBusinessBySlug(slugOrId);
|
||||||
if (listing) {
|
if (listing) {
|
||||||
this.logger.debug(`Found listing by slug: ${slugOrId} -> ID: ${listing.id}`);
|
this.logger.debug(`Found listing by slug: ${slugOrId} -> ID: ${listing.id}`);
|
||||||
id = listing.id;
|
id = listing.id;
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`Slug not found in database: ${slugOrId}`);
|
this.logger.warn(`Slug not found in database: ${slugOrId}`);
|
||||||
throw new NotFoundException(
|
throw new NotFoundException(
|
||||||
`Business listing not found with slug: ${slugOrId}. ` +
|
`Business listing not found with slug: ${slugOrId}. ` +
|
||||||
`The listing may have been deleted or the URL may be incorrect.`
|
`The listing may have been deleted or the URL may be incorrect.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.logger.debug(`Detected as UUID: ${slugOrId}`);
|
this.logger.debug(`Detected as UUID: ${slugOrId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.findBusinessesById(id, user);
|
return this.findBusinessesById(id, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find business by slug
|
* Find business by slug
|
||||||
*/
|
*/
|
||||||
async findBusinessBySlug(slug: string): Promise<BusinessListing | null> {
|
async findBusinessBySlug(slug: string): Promise<BusinessListing | null> {
|
||||||
const result = await this.conn
|
const result = await this.conn
|
||||||
.select()
|
.select()
|
||||||
.from(businesses_json)
|
.from(businesses_json)
|
||||||
.where(sql`${businesses_json.data}->>'slug' = ${slug}`)
|
.where(sql`${businesses_json.data}->>'slug' = ${slug}`)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
return { id: result[0].id, email: result[0].email, ...(result[0].data as BusinessListing) } as BusinessListing;
|
return { id: result[0].id, email: result[0].email, ...(result[0].data as BusinessListing) } as BusinessListing;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findBusinessesById(id: string, user: JwtUser): Promise<BusinessListing> {
|
async findBusinessesById(id: string, user: JwtUser): Promise<BusinessListing> {
|
||||||
const conditions = [];
|
const conditions = [];
|
||||||
if (user?.role !== 'admin') {
|
if (user?.role !== 'admin') {
|
||||||
conditions.push(or(eq(businesses_json.email, user?.email), sql`(${businesses_json.data}->>'draft')::boolean IS NOT TRUE`));
|
conditions.push(or(eq(businesses_json.email, user?.email), sql`(${businesses_json.data}->>'draft')::boolean IS NOT TRUE`));
|
||||||
}
|
}
|
||||||
conditions.push(eq(businesses_json.id, id));
|
conditions.push(eq(businesses_json.id, id));
|
||||||
const result = await this.conn
|
const result = await this.conn
|
||||||
.select()
|
.select()
|
||||||
.from(businesses_json)
|
.from(businesses_json)
|
||||||
.where(and(...conditions));
|
.where(and(...conditions));
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
return { id: result[0].id, email: result[0].email, ...(result[0].data as BusinessListing) } as BusinessListing;
|
return { id: result[0].id, email: result[0].email, ...(result[0].data as BusinessListing) } as BusinessListing;
|
||||||
} else {
|
} else {
|
||||||
throw new BadRequestException(`No entry available for ${id}`);
|
throw new BadRequestException(`No entry available for ${id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async findBusinessesByEmail(email: string, user: JwtUser): Promise<BusinessListing[]> {
|
async findBusinessesByEmail(email: string, user: JwtUser): Promise<BusinessListing[]> {
|
||||||
const conditions = [];
|
const conditions = [];
|
||||||
conditions.push(eq(businesses_json.email, email));
|
conditions.push(eq(businesses_json.email, email));
|
||||||
if (email !== user?.email && user?.role !== 'admin') {
|
if (email !== user?.email && user?.role !== 'admin') {
|
||||||
conditions.push(sql`(${businesses_json.data}->>'draft')::boolean IS NOT TRUE`);
|
conditions.push(sql`(${businesses_json.data}->>'draft')::boolean IS NOT TRUE`);
|
||||||
}
|
}
|
||||||
const listings = await this.conn
|
const listings = await this.conn
|
||||||
.select()
|
.select()
|
||||||
.from(businesses_json)
|
.from(businesses_json)
|
||||||
.where(and(...conditions));
|
.where(and(...conditions));
|
||||||
return listings.map(l => ({ id: l.id, email: l.email, ...(l.data as BusinessListing) }) as BusinessListing);
|
return listings.map(l => ({ id: l.id, email: l.email, ...(l.data as BusinessListing) }) as BusinessListing);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findFavoriteListings(user: JwtUser): Promise<BusinessListing[]> {
|
async findFavoriteListings(user: JwtUser): Promise<BusinessListing[]> {
|
||||||
const userFavorites = await this.conn
|
const userFavorites = await this.conn
|
||||||
.select()
|
.select()
|
||||||
.from(businesses_json)
|
.from(businesses_json)
|
||||||
.where(sql`${businesses_json.data}->'favoritesForUser' ? ${user.email}`);
|
.where(sql`${businesses_json.data}->'favoritesForUser' ? ${user.email}`);
|
||||||
return userFavorites.map(l => ({ id: l.id, email: l.email, ...(l.data as BusinessListing) }) as BusinessListing);
|
return userFavorites.map(l => ({ id: l.id, email: l.email, ...(l.data as BusinessListing) }) as BusinessListing);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createListing(data: BusinessListing): Promise<BusinessListing> {
|
async createListing(data: BusinessListing): Promise<BusinessListing> {
|
||||||
try {
|
try {
|
||||||
data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date();
|
data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date();
|
||||||
data.updated = new Date();
|
data.updated = new Date();
|
||||||
BusinessListingSchema.parse(data);
|
BusinessListingSchema.parse(data);
|
||||||
const { id, email, ...rest } = data;
|
const { id, email, ...rest } = data;
|
||||||
const convertedBusinessListing = { email, data: rest };
|
const convertedBusinessListing = { email, data: rest };
|
||||||
const [createdListing] = await this.conn.insert(businesses_json).values(convertedBusinessListing).returning();
|
const [createdListing] = await this.conn.insert(businesses_json).values(convertedBusinessListing).returning();
|
||||||
|
|
||||||
// Generate and update slug after creation (we need the ID first)
|
// Generate and update slug after creation (we need the ID first)
|
||||||
const slug = generateSlug(data.title, data.location, createdListing.id);
|
const slug = generateSlug(data.title, data.location, createdListing.id);
|
||||||
const listingWithSlug = { ...(createdListing.data as any), slug };
|
const listingWithSlug = { ...(createdListing.data as any), slug };
|
||||||
await this.conn.update(businesses_json).set({ data: listingWithSlug }).where(eq(businesses_json.id, createdListing.id));
|
await this.conn.update(businesses_json).set({ data: listingWithSlug }).where(eq(businesses_json.id, createdListing.id));
|
||||||
|
|
||||||
return { id: createdListing.id, email: createdListing.email, ...(createdListing.data as BusinessListing), slug } as any;
|
return { id: createdListing.id, email: createdListing.email, ...(createdListing.data as BusinessListing), slug } as any;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) {
|
||||||
const filteredErrors = error.errors
|
const filteredErrors = error.errors
|
||||||
.map(item => ({
|
.map(item => ({
|
||||||
...item,
|
...item,
|
||||||
field: item.path[0],
|
field: item.path[0],
|
||||||
}))
|
}))
|
||||||
.filter((item, index, self) => index === self.findIndex(t => t.path[0] === item.path[0]));
|
.filter((item, index, self) => index === self.findIndex(t => t.path[0] === item.path[0]));
|
||||||
throw new BadRequestException(filteredErrors);
|
throw new BadRequestException(filteredErrors);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateBusinessListing(id: string, data: BusinessListing, user: JwtUser): Promise<BusinessListing> {
|
async updateBusinessListing(id: string, data: BusinessListing, user: JwtUser): Promise<BusinessListing> {
|
||||||
try {
|
try {
|
||||||
const [existingListing] = await this.conn.select().from(businesses_json).where(eq(businesses_json.id, id));
|
const [existingListing] = await this.conn.select().from(businesses_json).where(eq(businesses_json.id, id));
|
||||||
|
|
||||||
if (!existingListing) {
|
if (!existingListing) {
|
||||||
throw new NotFoundException(`Business listing with id ${id} not found`);
|
throw new NotFoundException(`Business listing with id ${id} not found`);
|
||||||
}
|
}
|
||||||
data.updated = new Date();
|
data.updated = new Date();
|
||||||
data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date();
|
data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date();
|
||||||
if (existingListing.email === user?.email) {
|
if (existingListing.email === user?.email) {
|
||||||
data.favoritesForUser = (<BusinessListing>existingListing.data).favoritesForUser || [];
|
data.favoritesForUser = (<BusinessListing>existingListing.data).favoritesForUser || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regenerate slug if title or location changed
|
// Regenerate slug if title or location changed
|
||||||
const existingData = existingListing.data as BusinessListing;
|
const existingData = existingListing.data as BusinessListing;
|
||||||
let slug: string;
|
let slug: string;
|
||||||
if (data.title !== existingData.title || JSON.stringify(data.location) !== JSON.stringify(existingData.location)) {
|
if (data.title !== existingData.title || JSON.stringify(data.location) !== JSON.stringify(existingData.location)) {
|
||||||
slug = generateSlug(data.title, data.location, id);
|
slug = generateSlug(data.title, data.location, id);
|
||||||
} else {
|
} else {
|
||||||
// Keep existing slug
|
// Keep existing slug
|
||||||
slug = (existingData as any).slug || generateSlug(data.title, data.location, id);
|
slug = (existingData as any).slug || generateSlug(data.title, data.location, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add slug to data before validation
|
// Add slug to data before validation
|
||||||
const dataWithSlug = { ...data, slug };
|
const dataWithSlug = { ...data, slug };
|
||||||
BusinessListingSchema.parse(dataWithSlug);
|
BusinessListingSchema.parse(dataWithSlug);
|
||||||
const { id: _, email, ...rest } = dataWithSlug;
|
const { id: _, email, ...rest } = dataWithSlug;
|
||||||
const convertedBusinessListing = { email, data: rest };
|
const convertedBusinessListing = { email, data: rest };
|
||||||
const [updateListing] = await this.conn.update(businesses_json).set(convertedBusinessListing).where(eq(businesses_json.id, id)).returning();
|
const [updateListing] = await this.conn.update(businesses_json).set(convertedBusinessListing).where(eq(businesses_json.id, id)).returning();
|
||||||
return { id: updateListing.id, email: updateListing.email, ...(updateListing.data as BusinessListing) };
|
return { id: updateListing.id, email: updateListing.email, ...(updateListing.data as BusinessListing) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) {
|
||||||
const filteredErrors = error.errors
|
const filteredErrors = error.errors
|
||||||
.map(item => ({
|
.map(item => ({
|
||||||
...item,
|
...item,
|
||||||
field: item.path[0],
|
field: item.path[0],
|
||||||
}))
|
}))
|
||||||
.filter((item, index, self) => index === self.findIndex(t => t.path[0] === item.path[0]));
|
.filter((item, index, self) => index === self.findIndex(t => t.path[0] === item.path[0]));
|
||||||
throw new BadRequestException(filteredErrors);
|
throw new BadRequestException(filteredErrors);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteListing(id: string): Promise<void> {
|
async deleteListing(id: string): Promise<void> {
|
||||||
await this.conn.delete(businesses_json).where(eq(businesses_json.id, id));
|
await this.conn.delete(businesses_json).where(eq(businesses_json.id, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
async addFavorite(id: string, user: JwtUser): Promise<void> {
|
async addFavorite(id: string, user: JwtUser): Promise<void> {
|
||||||
await this.conn
|
await this.conn
|
||||||
.update(businesses_json)
|
.update(businesses_json)
|
||||||
.set({
|
.set({
|
||||||
data: sql`jsonb_set(${businesses_json.data}, '{favoritesForUser}',
|
data: sql`jsonb_set(${businesses_json.data}, '{favoritesForUser}',
|
||||||
coalesce((${businesses_json.data}->'favoritesForUser')::jsonb, '[]'::jsonb) || to_jsonb(${user.email}::text))`,
|
coalesce((${businesses_json.data}->'favoritesForUser')::jsonb, '[]'::jsonb) || to_jsonb(${user.email}::text))`,
|
||||||
})
|
})
|
||||||
.where(eq(businesses_json.id, id));
|
.where(eq(businesses_json.id, id));
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteFavorite(id: string, user: JwtUser): Promise<void> {
|
async deleteFavorite(id: string, user: JwtUser): Promise<void> {
|
||||||
await this.conn
|
await this.conn
|
||||||
.update(businesses_json)
|
.update(businesses_json)
|
||||||
.set({
|
.set({
|
||||||
data: sql`jsonb_set(${businesses_json.data}, '{favoritesForUser}',
|
data: sql`jsonb_set(${businesses_json.data}, '{favoritesForUser}',
|
||||||
(SELECT coalesce(jsonb_agg(elem), '[]'::jsonb)
|
(SELECT coalesce(jsonb_agg(elem), '[]'::jsonb)
|
||||||
FROM jsonb_array_elements(coalesce(${businesses_json.data}->'favoritesForUser', '[]'::jsonb)) AS elem
|
FROM jsonb_array_elements(coalesce(${businesses_json.data}->'favoritesForUser', '[]'::jsonb)) AS elem
|
||||||
WHERE elem::text != to_jsonb(${user.email}::text)::text))`,
|
WHERE elem::text != to_jsonb(${user.email}::text)::text))`,
|
||||||
})
|
})
|
||||||
.where(eq(businesses_json.id, id));
|
.where(eq(businesses_json.id, id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,79 +1,79 @@
|
||||||
import { Body, Controller, Delete, Get, Inject, Param, Post, Put, Request, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Inject, Param, Post, Put, Request, UseGuards } from '@nestjs/common';
|
||||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||||
import { AuthGuard } from 'src/jwt-auth/auth.guard';
|
import { AuthGuard } from 'src/jwt-auth/auth.guard';
|
||||||
import { Logger } from 'winston';
|
import { Logger } from 'winston';
|
||||||
|
|
||||||
import { OptionalAuthGuard } from 'src/jwt-auth/optional-auth.guard';
|
import { OptionalAuthGuard } from 'src/jwt-auth/optional-auth.guard';
|
||||||
import { BusinessListing } from '../models/db.model';
|
import { BusinessListing } from '../models/db.model';
|
||||||
import { BusinessListingCriteria, JwtUser } from '../models/main.model';
|
import { BusinessListingCriteria, JwtUser } from '../models/main.model';
|
||||||
import { BusinessListingService } from './business-listing.service';
|
import { BusinessListingService } from './business-listing.service';
|
||||||
|
|
||||||
@Controller('listings/business')
|
@Controller('listings/business')
|
||||||
export class BusinessListingsController {
|
export class BusinessListingsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly listingsService: BusinessListingService,
|
private readonly listingsService: BusinessListingService,
|
||||||
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@Post('favorites/all')
|
@Post('favorites/all')
|
||||||
async findFavorites(@Request() req): Promise<any> {
|
async findFavorites(@Request() req): Promise<any> {
|
||||||
return await this.listingsService.findFavoriteListings(req.user as JwtUser);
|
return await this.listingsService.findFavoriteListings(req.user as JwtUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
@UseGuards(OptionalAuthGuard)
|
||||||
@Get(':slugOrId')
|
@Get(':slugOrId')
|
||||||
async findById(@Request() req, @Param('slugOrId') slugOrId: string): Promise<any> {
|
async findById(@Request() req, @Param('slugOrId') slugOrId: string): Promise<any> {
|
||||||
// Support both slug (e.g., "restaurant-austin-tx-a3f7b2c1") and UUID
|
// Support both slug (e.g., "restaurant-austin-tx-a3f7b2c1") and UUID
|
||||||
return await this.listingsService.findBusinessBySlugOrId(slugOrId, req.user as JwtUser);
|
return await this.listingsService.findBusinessBySlugOrId(slugOrId, req.user as JwtUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
@UseGuards(OptionalAuthGuard)
|
||||||
@Get('user/:userid')
|
@Get('user/:userid')
|
||||||
async findByUserId(@Request() req, @Param('userid') userid: string): Promise<BusinessListing[]> {
|
async findByUserId(@Request() req, @Param('userid') userid: string): Promise<BusinessListing[]> {
|
||||||
return await this.listingsService.findBusinessesByEmail(userid, req.user as JwtUser);
|
return await this.listingsService.findBusinessesByEmail(userid, req.user as JwtUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
@UseGuards(OptionalAuthGuard)
|
||||||
@Post('find')
|
@Post('find')
|
||||||
async find(@Request() req, @Body() criteria: BusinessListingCriteria): Promise<any> {
|
async find(@Request() req, @Body() criteria: BusinessListingCriteria): Promise<any> {
|
||||||
return await this.listingsService.searchBusinessListings(criteria, req.user as JwtUser);
|
return await this.listingsService.searchBusinessListings(criteria, req.user as JwtUser);
|
||||||
}
|
}
|
||||||
@UseGuards(OptionalAuthGuard)
|
@UseGuards(OptionalAuthGuard)
|
||||||
@Post('findTotal')
|
@Post('findTotal')
|
||||||
async findTotal(@Request() req, @Body() criteria: BusinessListingCriteria): Promise<number> {
|
async findTotal(@Request() req, @Body() criteria: BusinessListingCriteria): Promise<number> {
|
||||||
return await this.listingsService.getBusinessListingsCount(criteria, req.user as JwtUser);
|
return await this.listingsService.getBusinessListingsCount(criteria, req.user as JwtUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
@UseGuards(OptionalAuthGuard)
|
||||||
@Post()
|
@Post()
|
||||||
async create(@Body() listing: any) {
|
async create(@Body() listing: any) {
|
||||||
return await this.listingsService.createListing(listing);
|
return await this.listingsService.createListing(listing);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
@UseGuards(OptionalAuthGuard)
|
||||||
@Put()
|
@Put()
|
||||||
async update(@Request() req, @Body() listing: any) {
|
async update(@Request() req, @Body() listing: any) {
|
||||||
return await this.listingsService.updateBusinessListing(listing.id, listing, req.user as JwtUser);
|
return await this.listingsService.updateBusinessListing(listing.id, listing, req.user as JwtUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
@UseGuards(OptionalAuthGuard)
|
||||||
@Delete('listing/:id')
|
@Delete('listing/:id')
|
||||||
async deleteById(@Param('id') id: string) {
|
async deleteById(@Param('id') id: string) {
|
||||||
await this.listingsService.deleteListing(id);
|
await this.listingsService.deleteListing(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@Post('favorite/:id')
|
@Post('favorite/:id')
|
||||||
async addFavorite(@Request() req, @Param('id') id: string) {
|
async addFavorite(@Request() req, @Param('id') id: string) {
|
||||||
await this.listingsService.addFavorite(id, req.user as JwtUser);
|
await this.listingsService.addFavorite(id, req.user as JwtUser);
|
||||||
return { success: true, message: 'Added to favorites' };
|
return { success: true, message: 'Added to favorites' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@Delete('favorite/:id')
|
@Delete('favorite/:id')
|
||||||
async deleteFavorite(@Request() req, @Param('id') id: string) {
|
async deleteFavorite(@Request() req, @Param('id') id: string) {
|
||||||
await this.listingsService.deleteFavorite(id, req.user as JwtUser);
|
await this.listingsService.deleteFavorite(id, req.user as JwtUser);
|
||||||
return { success: true, message: 'Removed from favorites' };
|
return { success: true, message: 'Removed from favorites' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,82 +1,82 @@
|
||||||
import { Body, Controller, Delete, Get, Inject, Param, Post, Put, Request, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Inject, Param, Post, Put, Request, UseGuards } from '@nestjs/common';
|
||||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||||
import { Logger } from 'winston';
|
import { Logger } from 'winston';
|
||||||
import { FileService } from '../file/file.service';
|
import { FileService } from '../file/file.service';
|
||||||
|
|
||||||
import { AuthGuard } from 'src/jwt-auth/auth.guard';
|
import { AuthGuard } from 'src/jwt-auth/auth.guard';
|
||||||
import { OptionalAuthGuard } from 'src/jwt-auth/optional-auth.guard';
|
import { OptionalAuthGuard } from 'src/jwt-auth/optional-auth.guard';
|
||||||
import { CommercialPropertyListing } from '../models/db.model';
|
import { CommercialPropertyListing } from '../models/db.model';
|
||||||
import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model';
|
import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model';
|
||||||
import { CommercialPropertyService } from './commercial-property.service';
|
import { CommercialPropertyService } from './commercial-property.service';
|
||||||
|
|
||||||
@Controller('listings/commercialProperty')
|
@Controller('listings/commercialProperty')
|
||||||
export class CommercialPropertyListingsController {
|
export class CommercialPropertyListingsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly listingsService: CommercialPropertyService,
|
private readonly listingsService: CommercialPropertyService,
|
||||||
private fileService: FileService,
|
private fileService: FileService,
|
||||||
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@Post('favorites/all')
|
@Post('favorites/all')
|
||||||
async findFavorites(@Request() req): Promise<any> {
|
async findFavorites(@Request() req): Promise<any> {
|
||||||
return await this.listingsService.findFavoriteListings(req.user as JwtUser);
|
return await this.listingsService.findFavoriteListings(req.user as JwtUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
@UseGuards(OptionalAuthGuard)
|
||||||
@Get(':slugOrId')
|
@Get(':slugOrId')
|
||||||
async findById(@Request() req, @Param('slugOrId') slugOrId: string): Promise<any> {
|
async findById(@Request() req, @Param('slugOrId') slugOrId: string): Promise<any> {
|
||||||
// Support both slug (e.g., "office-space-austin-tx-a3f7b2c1") and UUID
|
// Support both slug (e.g., "office-space-austin-tx-a3f7b2c1") and UUID
|
||||||
return await this.listingsService.findCommercialBySlugOrId(slugOrId, req.user as JwtUser);
|
return await this.listingsService.findCommercialBySlugOrId(slugOrId, req.user as JwtUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
@UseGuards(OptionalAuthGuard)
|
||||||
@Get('user/:email')
|
@Get('user/:email')
|
||||||
async findByEmail(@Request() req, @Param('email') email: string): Promise<CommercialPropertyListing[]> {
|
async findByEmail(@Request() req, @Param('email') email: string): Promise<CommercialPropertyListing[]> {
|
||||||
return await this.listingsService.findCommercialPropertiesByEmail(email, req.user as JwtUser);
|
return await this.listingsService.findCommercialPropertiesByEmail(email, req.user as JwtUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
@UseGuards(OptionalAuthGuard)
|
||||||
@Post('find')
|
@Post('find')
|
||||||
async find(@Request() req, @Body() criteria: CommercialPropertyListingCriteria): Promise<any> {
|
async find(@Request() req, @Body() criteria: CommercialPropertyListingCriteria): Promise<any> {
|
||||||
return await this.listingsService.searchCommercialProperties(criteria, req.user as JwtUser);
|
return await this.listingsService.searchCommercialProperties(criteria, req.user as JwtUser);
|
||||||
}
|
}
|
||||||
@UseGuards(OptionalAuthGuard)
|
@UseGuards(OptionalAuthGuard)
|
||||||
@Post('findTotal')
|
@Post('findTotal')
|
||||||
async findTotal(@Request() req, @Body() criteria: CommercialPropertyListingCriteria): Promise<number> {
|
async findTotal(@Request() req, @Body() criteria: CommercialPropertyListingCriteria): Promise<number> {
|
||||||
return await this.listingsService.getCommercialPropertiesCount(criteria, req.user as JwtUser);
|
return await this.listingsService.getCommercialPropertiesCount(criteria, req.user as JwtUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
@UseGuards(OptionalAuthGuard)
|
||||||
@Post()
|
@Post()
|
||||||
async create(@Body() listing: any) {
|
async create(@Body() listing: any) {
|
||||||
return await this.listingsService.createListing(listing);
|
return await this.listingsService.createListing(listing);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
@UseGuards(OptionalAuthGuard)
|
||||||
@Put()
|
@Put()
|
||||||
async update(@Request() req, @Body() listing: any) {
|
async update(@Request() req, @Body() listing: any) {
|
||||||
return await this.listingsService.updateCommercialPropertyListing(listing.id, listing, req.user as JwtUser);
|
return await this.listingsService.updateCommercialPropertyListing(listing.id, listing, req.user as JwtUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(OptionalAuthGuard)
|
@UseGuards(OptionalAuthGuard)
|
||||||
@Delete('listing/:id/:imagePath')
|
@Delete('listing/:id/:imagePath')
|
||||||
async deleteById(@Param('id') id: string, @Param('imagePath') imagePath: string) {
|
async deleteById(@Param('id') id: string, @Param('imagePath') imagePath: string) {
|
||||||
await this.listingsService.deleteListing(id);
|
await this.listingsService.deleteListing(id);
|
||||||
this.fileService.deleteDirectoryIfExists(imagePath);
|
this.fileService.deleteDirectoryIfExists(imagePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@Post('favorite/:id')
|
@Post('favorite/:id')
|
||||||
async addFavorite(@Request() req, @Param('id') id: string) {
|
async addFavorite(@Request() req, @Param('id') id: string) {
|
||||||
await this.listingsService.addFavorite(id, req.user as JwtUser);
|
await this.listingsService.addFavorite(id, req.user as JwtUser);
|
||||||
return { success: true, message: 'Added to favorites' };
|
return { success: true, message: 'Added to favorites' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@Delete('favorite/:id')
|
@Delete('favorite/:id')
|
||||||
async deleteFavorite(@Request() req, @Param('id') id: string) {
|
async deleteFavorite(@Request() req, @Param('id') id: string) {
|
||||||
await this.listingsService.deleteFavorite(id, req.user as JwtUser);
|
await this.listingsService.deleteFavorite(id, req.user as JwtUser);
|
||||||
return { success: true, message: 'Removed from favorites' };
|
return { success: true, message: 'Removed from favorites' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,364 +1,364 @@
|
||||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { and, arrayContains, asc, count, desc, eq, gte, inArray, lte, or, SQL, sql } from 'drizzle-orm';
|
import { and, arrayContains, asc, count, desc, eq, gte, inArray, lte, or, SQL, sql } from 'drizzle-orm';
|
||||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||||
import { Logger } from 'winston';
|
import { Logger } from 'winston';
|
||||||
import { ZodError } from 'zod';
|
import { ZodError } from 'zod';
|
||||||
import * as schema from '../drizzle/schema';
|
import * as schema from '../drizzle/schema';
|
||||||
import { commercials_json, PG_CONNECTION } from '../drizzle/schema';
|
import { commercials_json, PG_CONNECTION } from '../drizzle/schema';
|
||||||
import { FileService } from '../file/file.service';
|
import { FileService } from '../file/file.service';
|
||||||
import { GeoService } from '../geo/geo.service';
|
import { GeoService } from '../geo/geo.service';
|
||||||
import { CommercialPropertyListing, CommercialPropertyListingSchema } from '../models/db.model';
|
import { CommercialPropertyListing, CommercialPropertyListingSchema } from '../models/db.model';
|
||||||
import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model';
|
import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model';
|
||||||
import { getDistanceQuery, splitName } from '../utils';
|
import { getDistanceQuery, splitName } from '../utils';
|
||||||
import { generateSlug, extractShortIdFromSlug, isSlug } from '../utils/slug.utils';
|
import { generateSlug, extractShortIdFromSlug, isSlug } from '../utils/slug.utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommercialPropertyService {
|
export class CommercialPropertyService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
||||||
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
|
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
|
||||||
private fileService?: FileService,
|
private fileService?: FileService,
|
||||||
private geoService?: GeoService,
|
private geoService?: GeoService,
|
||||||
) { }
|
) { }
|
||||||
private getWhereConditions(criteria: CommercialPropertyListingCriteria, user: JwtUser): SQL[] {
|
private getWhereConditions(criteria: CommercialPropertyListingCriteria, user: JwtUser): SQL[] {
|
||||||
const whereConditions: SQL[] = [];
|
const whereConditions: SQL[] = [];
|
||||||
|
|
||||||
if (criteria.city && criteria.searchType === 'exact') {
|
if (criteria.city && criteria.searchType === 'exact') {
|
||||||
whereConditions.push(sql`(${commercials_json.data}->'location'->>'name') ILIKE ${criteria.city.name}`);
|
whereConditions.push(sql`(${commercials_json.data}->'location'->>'name') ILIKE ${criteria.city.name}`);
|
||||||
}
|
}
|
||||||
if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) {
|
if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) {
|
||||||
const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name);
|
const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name);
|
||||||
whereConditions.push(sql`${getDistanceQuery(commercials_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`);
|
whereConditions.push(sql`${getDistanceQuery(commercials_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`);
|
||||||
}
|
}
|
||||||
if (criteria.types && criteria.types.length > 0) {
|
if (criteria.types && criteria.types.length > 0) {
|
||||||
this.logger.warn('Adding commercial property type filter', { types: criteria.types });
|
this.logger.warn('Adding commercial property type filter', { types: criteria.types });
|
||||||
// Use explicit SQL with IN for robust JSONB comparison
|
// Use explicit SQL with IN for robust JSONB comparison
|
||||||
const typeValues = criteria.types.map(t => sql`${t}`);
|
const typeValues = criteria.types.map(t => sql`${t}`);
|
||||||
whereConditions.push(sql`((${commercials_json.data}->>'type') IN (${sql.join(typeValues, sql`, `)}))`);
|
whereConditions.push(sql`((${commercials_json.data}->>'type') IN (${sql.join(typeValues, sql`, `)}))`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.state) {
|
if (criteria.state) {
|
||||||
whereConditions.push(sql`(${commercials_json.data}->'location'->>'state') = ${criteria.state}`);
|
whereConditions.push(sql`(${commercials_json.data}->'location'->>'state') = ${criteria.state}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.minPrice) {
|
if (criteria.minPrice) {
|
||||||
whereConditions.push(gte(sql`(${commercials_json.data}->>'price')::double precision`, criteria.minPrice));
|
whereConditions.push(gte(sql`(${commercials_json.data}->>'price')::double precision`, criteria.minPrice));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.maxPrice) {
|
if (criteria.maxPrice) {
|
||||||
whereConditions.push(lte(sql`(${commercials_json.data}->>'price')::double precision`, criteria.maxPrice));
|
whereConditions.push(lte(sql`(${commercials_json.data}->>'price')::double precision`, criteria.maxPrice));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.title) {
|
if (criteria.title) {
|
||||||
whereConditions.push(
|
whereConditions.push(
|
||||||
sql`((${commercials_json.data}->>'title') ILIKE ${`%${criteria.title}%`} OR (${commercials_json.data}->>'description') ILIKE ${`%${criteria.title}%`})`
|
sql`((${commercials_json.data}->>'title') ILIKE ${`%${criteria.title}%`} OR (${commercials_json.data}->>'description') ILIKE ${`%${criteria.title}%`})`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.brokerName) {
|
if (criteria.brokerName) {
|
||||||
const { firstname, lastname } = splitName(criteria.brokerName);
|
const { firstname, lastname } = splitName(criteria.brokerName);
|
||||||
if (firstname === lastname) {
|
if (firstname === lastname) {
|
||||||
// Single word: search either first OR last name
|
// Single word: search either first OR last name
|
||||||
whereConditions.push(
|
whereConditions.push(
|
||||||
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
|
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Multiple words: search both first AND last name
|
// Multiple words: search both first AND last name
|
||||||
whereConditions.push(
|
whereConditions.push(
|
||||||
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} AND (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
|
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} AND (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user?.role !== 'admin') {
|
if (user?.role !== 'admin') {
|
||||||
whereConditions.push(
|
whereConditions.push(
|
||||||
sql`((${commercials_json.email} = ${user?.email || null}) OR (${commercials_json.data}->>'draft')::boolean IS NOT TRUE)`
|
sql`((${commercials_json.email} = ${user?.email || null}) OR (${commercials_json.data}->>'draft')::boolean IS NOT TRUE)`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.logger.warn('whereConditions count', { count: whereConditions.length });
|
this.logger.warn('whereConditions count', { count: whereConditions.length });
|
||||||
return whereConditions;
|
return whereConditions;
|
||||||
}
|
}
|
||||||
// #### Find by criteria ########################################
|
// #### Find by criteria ########################################
|
||||||
async searchCommercialProperties(criteria: CommercialPropertyListingCriteria, user: JwtUser): Promise<any> {
|
async searchCommercialProperties(criteria: CommercialPropertyListingCriteria, user: JwtUser): Promise<any> {
|
||||||
const start = criteria.start ? criteria.start : 0;
|
const start = criteria.start ? criteria.start : 0;
|
||||||
const length = criteria.length ? criteria.length : 12;
|
const length = criteria.length ? criteria.length : 12;
|
||||||
const query = this.conn.select({ commercial: commercials_json }).from(commercials_json).leftJoin(schema.users_json, eq(commercials_json.email, schema.users_json.email));
|
const query = this.conn.select({ commercial: commercials_json }).from(commercials_json).leftJoin(schema.users_json, eq(commercials_json.email, schema.users_json.email));
|
||||||
const whereConditions = this.getWhereConditions(criteria, user);
|
const whereConditions = this.getWhereConditions(criteria, user);
|
||||||
|
|
||||||
this.logger.warn('Filter Criteria:', { criteria: JSON.stringify(criteria) });
|
this.logger.warn('Filter Criteria:', { criteria: JSON.stringify(criteria) });
|
||||||
|
|
||||||
if (whereConditions.length > 0) {
|
if (whereConditions.length > 0) {
|
||||||
const whereClause = sql.join(whereConditions, sql` AND `);
|
const whereClause = sql.join(whereConditions, sql` AND `);
|
||||||
query.where(sql`(${whereClause})`);
|
query.where(sql`(${whereClause})`);
|
||||||
|
|
||||||
this.logger.warn('Generated SQL:', { sql: query.toSQL().sql, params: query.toSQL().params });
|
this.logger.warn('Generated SQL:', { sql: query.toSQL().sql, params: query.toSQL().params });
|
||||||
}
|
}
|
||||||
// Sortierung
|
// Sortierung
|
||||||
switch (criteria.sortBy) {
|
switch (criteria.sortBy) {
|
||||||
case 'priceAsc':
|
case 'priceAsc':
|
||||||
query.orderBy(asc(sql`(${commercials_json.data}->>'price')::double precision`));
|
query.orderBy(asc(sql`(${commercials_json.data}->>'price')::double precision`));
|
||||||
break;
|
break;
|
||||||
case 'priceDesc':
|
case 'priceDesc':
|
||||||
query.orderBy(desc(sql`(${commercials_json.data}->>'price')::double precision`));
|
query.orderBy(desc(sql`(${commercials_json.data}->>'price')::double precision`));
|
||||||
break;
|
break;
|
||||||
case 'creationDateFirst':
|
case 'creationDateFirst':
|
||||||
query.orderBy(asc(sql`${commercials_json.data}->>'created'`));
|
query.orderBy(asc(sql`${commercials_json.data}->>'created'`));
|
||||||
break;
|
break;
|
||||||
case 'creationDateLast':
|
case 'creationDateLast':
|
||||||
query.orderBy(desc(sql`${commercials_json.data}->>'created'`));
|
query.orderBy(desc(sql`${commercials_json.data}->>'created'`));
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// Keine spezifische Sortierung, Standardverhalten kann hier eingefügt werden
|
// Keine spezifische Sortierung, Standardverhalten kann hier eingefügt werden
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paginierung
|
// Paginierung
|
||||||
query.limit(length).offset(start);
|
query.limit(length).offset(start);
|
||||||
|
|
||||||
const data = await query;
|
const data = await query;
|
||||||
const results = data.map(r => ({ id: r.commercial.id, email: r.commercial.email, ...(r.commercial.data as CommercialPropertyListing) }));
|
const results = data.map(r => ({ id: r.commercial.id, email: r.commercial.email, ...(r.commercial.data as CommercialPropertyListing) }));
|
||||||
const totalCount = await this.getCommercialPropertiesCount(criteria, user);
|
const totalCount = await this.getCommercialPropertiesCount(criteria, user);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
results,
|
results,
|
||||||
totalCount,
|
totalCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
async getCommercialPropertiesCount(criteria: CommercialPropertyListingCriteria, user: JwtUser): Promise<number> {
|
async getCommercialPropertiesCount(criteria: CommercialPropertyListingCriteria, user: JwtUser): Promise<number> {
|
||||||
const countQuery = this.conn.select({ value: count() }).from(commercials_json).leftJoin(schema.users_json, eq(commercials_json.email, schema.users_json.email));
|
const countQuery = this.conn.select({ value: count() }).from(commercials_json).leftJoin(schema.users_json, eq(commercials_json.email, schema.users_json.email));
|
||||||
const whereConditions = this.getWhereConditions(criteria, user);
|
const whereConditions = this.getWhereConditions(criteria, user);
|
||||||
|
|
||||||
if (whereConditions.length > 0) {
|
if (whereConditions.length > 0) {
|
||||||
const whereClause = sql.join(whereConditions, sql` AND `);
|
const whereClause = sql.join(whereConditions, sql` AND `);
|
||||||
countQuery.where(sql`(${whereClause})`);
|
countQuery.where(sql`(${whereClause})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [{ value: totalCount }] = await countQuery;
|
const [{ value: totalCount }] = await countQuery;
|
||||||
return totalCount;
|
return totalCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
// #### Find by ID ########################################
|
// #### Find by ID ########################################
|
||||||
/**
|
/**
|
||||||
* Find commercial property by slug or ID
|
* Find commercial property by slug or ID
|
||||||
* Supports both slug (e.g., "office-space-austin-tx-a3f7b2c1") and UUID
|
* Supports both slug (e.g., "office-space-austin-tx-a3f7b2c1") and UUID
|
||||||
*/
|
*/
|
||||||
async findCommercialBySlugOrId(slugOrId: string, user: JwtUser): Promise<CommercialPropertyListing> {
|
async findCommercialBySlugOrId(slugOrId: string, user: JwtUser): Promise<CommercialPropertyListing> {
|
||||||
this.logger.debug(`findCommercialBySlugOrId called with: ${slugOrId}`);
|
this.logger.debug(`findCommercialBySlugOrId called with: ${slugOrId}`);
|
||||||
|
|
||||||
let id = slugOrId;
|
let id = slugOrId;
|
||||||
|
|
||||||
// Check if it's a slug (contains multiple hyphens) vs UUID
|
// Check if it's a slug (contains multiple hyphens) vs UUID
|
||||||
if (isSlug(slugOrId)) {
|
if (isSlug(slugOrId)) {
|
||||||
this.logger.debug(`Detected as slug: ${slugOrId}`);
|
this.logger.debug(`Detected as slug: ${slugOrId}`);
|
||||||
|
|
||||||
// Extract short ID from slug and find by slug field
|
// Extract short ID from slug and find by slug field
|
||||||
const listing = await this.findCommercialBySlug(slugOrId);
|
const listing = await this.findCommercialBySlug(slugOrId);
|
||||||
if (listing) {
|
if (listing) {
|
||||||
this.logger.debug(`Found listing by slug: ${slugOrId} -> ID: ${listing.id}`);
|
this.logger.debug(`Found listing by slug: ${slugOrId} -> ID: ${listing.id}`);
|
||||||
id = listing.id;
|
id = listing.id;
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`Slug not found in database: ${slugOrId}`);
|
this.logger.warn(`Slug not found in database: ${slugOrId}`);
|
||||||
throw new NotFoundException(
|
throw new NotFoundException(
|
||||||
`Commercial property listing not found with slug: ${slugOrId}. ` +
|
`Commercial property listing not found with slug: ${slugOrId}. ` +
|
||||||
`The listing may have been deleted or the URL may be incorrect.`
|
`The listing may have been deleted or the URL may be incorrect.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.logger.debug(`Detected as UUID: ${slugOrId}`);
|
this.logger.debug(`Detected as UUID: ${slugOrId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.findCommercialPropertiesById(id, user);
|
return this.findCommercialPropertiesById(id, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find commercial property by slug
|
* Find commercial property by slug
|
||||||
*/
|
*/
|
||||||
async findCommercialBySlug(slug: string): Promise<CommercialPropertyListing | null> {
|
async findCommercialBySlug(slug: string): Promise<CommercialPropertyListing | null> {
|
||||||
const result = await this.conn
|
const result = await this.conn
|
||||||
.select()
|
.select()
|
||||||
.from(commercials_json)
|
.from(commercials_json)
|
||||||
.where(sql`${commercials_json.data}->>'slug' = ${slug}`)
|
.where(sql`${commercials_json.data}->>'slug' = ${slug}`)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
return { id: result[0].id, email: result[0].email, ...(result[0].data as CommercialPropertyListing) } as CommercialPropertyListing;
|
return { id: result[0].id, email: result[0].email, ...(result[0].data as CommercialPropertyListing) } as CommercialPropertyListing;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findCommercialPropertiesById(id: string, user: JwtUser): Promise<CommercialPropertyListing> {
|
async findCommercialPropertiesById(id: string, user: JwtUser): Promise<CommercialPropertyListing> {
|
||||||
const conditions = [];
|
const conditions = [];
|
||||||
if (user?.role !== 'admin') {
|
if (user?.role !== 'admin') {
|
||||||
conditions.push(or(eq(commercials_json.email, user?.email), sql`(${commercials_json.data}->>'draft')::boolean IS NOT TRUE`));
|
conditions.push(or(eq(commercials_json.email, user?.email), sql`(${commercials_json.data}->>'draft')::boolean IS NOT TRUE`));
|
||||||
}
|
}
|
||||||
conditions.push(eq(commercials_json.id, id));
|
conditions.push(eq(commercials_json.id, id));
|
||||||
const result = await this.conn
|
const result = await this.conn
|
||||||
.select()
|
.select()
|
||||||
.from(commercials_json)
|
.from(commercials_json)
|
||||||
.where(and(...conditions));
|
.where(and(...conditions));
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
return { id: result[0].id, email: result[0].email, ...(result[0].data as CommercialPropertyListing) } as CommercialPropertyListing;
|
return { id: result[0].id, email: result[0].email, ...(result[0].data as CommercialPropertyListing) } as CommercialPropertyListing;
|
||||||
} else {
|
} else {
|
||||||
throw new BadRequestException(`No entry available for ${id}`);
|
throw new BadRequestException(`No entry available for ${id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// #### Find by User EMail ########################################
|
// #### Find by User EMail ########################################
|
||||||
async findCommercialPropertiesByEmail(email: string, user: JwtUser): Promise<CommercialPropertyListing[]> {
|
async findCommercialPropertiesByEmail(email: string, user: JwtUser): Promise<CommercialPropertyListing[]> {
|
||||||
const conditions = [];
|
const conditions = [];
|
||||||
conditions.push(eq(commercials_json.email, email));
|
conditions.push(eq(commercials_json.email, email));
|
||||||
if (email !== user?.email && user?.role !== 'admin') {
|
if (email !== user?.email && user?.role !== 'admin') {
|
||||||
conditions.push(sql`(${commercials_json.data}->>'draft')::boolean IS NOT TRUE`);
|
conditions.push(sql`(${commercials_json.data}->>'draft')::boolean IS NOT TRUE`);
|
||||||
}
|
}
|
||||||
const listings = await this.conn
|
const listings = await this.conn
|
||||||
.select()
|
.select()
|
||||||
.from(commercials_json)
|
.from(commercials_json)
|
||||||
.where(and(...conditions));
|
.where(and(...conditions));
|
||||||
return listings.map(l => ({ id: l.id, email: l.email, ...(l.data as CommercialPropertyListing) }) as CommercialPropertyListing);
|
return listings.map(l => ({ id: l.id, email: l.email, ...(l.data as CommercialPropertyListing) }) as CommercialPropertyListing);
|
||||||
}
|
}
|
||||||
// #### Find Favorites ########################################
|
// #### Find Favorites ########################################
|
||||||
async findFavoriteListings(user: JwtUser): Promise<CommercialPropertyListing[]> {
|
async findFavoriteListings(user: JwtUser): Promise<CommercialPropertyListing[]> {
|
||||||
const userFavorites = await this.conn
|
const userFavorites = await this.conn
|
||||||
.select()
|
.select()
|
||||||
.from(commercials_json)
|
.from(commercials_json)
|
||||||
.where(sql`${commercials_json.data}->'favoritesForUser' ? ${user.email}`);
|
.where(sql`${commercials_json.data}->'favoritesForUser' ? ${user.email}`);
|
||||||
return userFavorites.map(l => ({ id: l.id, email: l.email, ...(l.data as CommercialPropertyListing) }) as CommercialPropertyListing);
|
return userFavorites.map(l => ({ id: l.id, email: l.email, ...(l.data as CommercialPropertyListing) }) as CommercialPropertyListing);
|
||||||
}
|
}
|
||||||
// #### Find by imagePath ########################################
|
// #### Find by imagePath ########################################
|
||||||
async findByImagePath(imagePath: string, serial: string): Promise<CommercialPropertyListing> {
|
async findByImagePath(imagePath: string, serial: string): Promise<CommercialPropertyListing> {
|
||||||
const result = await this.conn
|
const result = await this.conn
|
||||||
.select()
|
.select()
|
||||||
.from(commercials_json)
|
.from(commercials_json)
|
||||||
.where(and(sql`(${commercials_json.data}->>'imagePath') = ${imagePath}`, sql`(${commercials_json.data}->>'serialId')::integer = ${serial}`));
|
.where(and(sql`(${commercials_json.data}->>'imagePath') = ${imagePath}`, sql`(${commercials_json.data}->>'serialId')::integer = ${serial}`));
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
return { id: result[0].id, email: result[0].email, ...(result[0].data as CommercialPropertyListing) } as CommercialPropertyListing;
|
return { id: result[0].id, email: result[0].email, ...(result[0].data as CommercialPropertyListing) } as CommercialPropertyListing;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// #### CREATE ########################################
|
// #### CREATE ########################################
|
||||||
async createListing(data: CommercialPropertyListing): Promise<CommercialPropertyListing> {
|
async createListing(data: CommercialPropertyListing): Promise<CommercialPropertyListing> {
|
||||||
try {
|
try {
|
||||||
// Generate serialId based on timestamp + random number (temporary solution until sequence is created)
|
// Generate serialId based on timestamp + random number (temporary solution until sequence is created)
|
||||||
// This ensures uniqueness without requiring a database sequence
|
// This ensures uniqueness without requiring a database sequence
|
||||||
const serialId = Date.now() % 1000000 + Math.floor(Math.random() * 1000);
|
const serialId = Date.now() % 1000000 + Math.floor(Math.random() * 1000);
|
||||||
|
|
||||||
data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date();
|
data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date();
|
||||||
data.updated = new Date();
|
data.updated = new Date();
|
||||||
data.serialId = Number(serialId);
|
data.serialId = Number(serialId);
|
||||||
CommercialPropertyListingSchema.parse(data);
|
CommercialPropertyListingSchema.parse(data);
|
||||||
const { id, email, ...rest } = data;
|
const { id, email, ...rest } = data;
|
||||||
const convertedCommercialPropertyListing = { email, data: rest };
|
const convertedCommercialPropertyListing = { email, data: rest };
|
||||||
const [createdListing] = await this.conn.insert(commercials_json).values(convertedCommercialPropertyListing).returning();
|
const [createdListing] = await this.conn.insert(commercials_json).values(convertedCommercialPropertyListing).returning();
|
||||||
|
|
||||||
// Generate and update slug after creation (we need the ID first)
|
// Generate and update slug after creation (we need the ID first)
|
||||||
const slug = generateSlug(data.title, data.location, createdListing.id);
|
const slug = generateSlug(data.title, data.location, createdListing.id);
|
||||||
const listingWithSlug = { ...(createdListing.data as any), slug };
|
const listingWithSlug = { ...(createdListing.data as any), slug };
|
||||||
await this.conn.update(commercials_json).set({ data: listingWithSlug }).where(eq(commercials_json.id, createdListing.id));
|
await this.conn.update(commercials_json).set({ data: listingWithSlug }).where(eq(commercials_json.id, createdListing.id));
|
||||||
|
|
||||||
return { id: createdListing.id, email: createdListing.email, ...(createdListing.data as CommercialPropertyListing), slug } as any;
|
return { id: createdListing.id, email: createdListing.email, ...(createdListing.data as CommercialPropertyListing), slug } as any;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) {
|
||||||
const filteredErrors = error.errors
|
const filteredErrors = error.errors
|
||||||
.map(item => ({
|
.map(item => ({
|
||||||
...item,
|
...item,
|
||||||
field: item.path[0],
|
field: item.path[0],
|
||||||
}))
|
}))
|
||||||
.filter((item, index, self) => index === self.findIndex(t => t.path[0] === item.path[0]));
|
.filter((item, index, self) => index === self.findIndex(t => t.path[0] === item.path[0]));
|
||||||
throw new BadRequestException(filteredErrors);
|
throw new BadRequestException(filteredErrors);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// #### UPDATE CommercialProps ########################################
|
// #### UPDATE CommercialProps ########################################
|
||||||
async updateCommercialPropertyListing(id: string, data: CommercialPropertyListing, user: JwtUser): Promise<CommercialPropertyListing> {
|
async updateCommercialPropertyListing(id: string, data: CommercialPropertyListing, user: JwtUser): Promise<CommercialPropertyListing> {
|
||||||
try {
|
try {
|
||||||
const [existingListing] = await this.conn.select().from(commercials_json).where(eq(commercials_json.id, id));
|
const [existingListing] = await this.conn.select().from(commercials_json).where(eq(commercials_json.id, id));
|
||||||
|
|
||||||
if (!existingListing) {
|
if (!existingListing) {
|
||||||
throw new NotFoundException(`Business listing with id ${id} not found`);
|
throw new NotFoundException(`Business listing with id ${id} not found`);
|
||||||
}
|
}
|
||||||
data.updated = new Date();
|
data.updated = new Date();
|
||||||
data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date();
|
data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date();
|
||||||
if (existingListing.email === user?.email || !user) {
|
if (existingListing.email === user?.email || !user) {
|
||||||
data.favoritesForUser = (<CommercialPropertyListing>existingListing.data).favoritesForUser || [];
|
data.favoritesForUser = (<CommercialPropertyListing>existingListing.data).favoritesForUser || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regenerate slug if title or location changed
|
// Regenerate slug if title or location changed
|
||||||
const existingData = existingListing.data as CommercialPropertyListing;
|
const existingData = existingListing.data as CommercialPropertyListing;
|
||||||
let slug: string;
|
let slug: string;
|
||||||
if (data.title !== existingData.title || JSON.stringify(data.location) !== JSON.stringify(existingData.location)) {
|
if (data.title !== existingData.title || JSON.stringify(data.location) !== JSON.stringify(existingData.location)) {
|
||||||
slug = generateSlug(data.title, data.location, id);
|
slug = generateSlug(data.title, data.location, id);
|
||||||
} else {
|
} else {
|
||||||
// Keep existing slug
|
// Keep existing slug
|
||||||
slug = (existingData as any).slug || generateSlug(data.title, data.location, id);
|
slug = (existingData as any).slug || generateSlug(data.title, data.location, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add slug to data before validation
|
// Add slug to data before validation
|
||||||
const dataWithSlug = { ...data, slug };
|
const dataWithSlug = { ...data, slug };
|
||||||
CommercialPropertyListingSchema.parse(dataWithSlug);
|
CommercialPropertyListingSchema.parse(dataWithSlug);
|
||||||
const imageOrder = await this.fileService.getPropertyImages(dataWithSlug.imagePath, String(dataWithSlug.serialId));
|
const imageOrder = await this.fileService.getPropertyImages(dataWithSlug.imagePath, String(dataWithSlug.serialId));
|
||||||
const difference = imageOrder.filter(x => !dataWithSlug.imageOrder.includes(x)).concat(dataWithSlug.imageOrder.filter(x => !imageOrder.includes(x)));
|
const difference = imageOrder.filter(x => !dataWithSlug.imageOrder.includes(x)).concat(dataWithSlug.imageOrder.filter(x => !imageOrder.includes(x)));
|
||||||
if (difference.length > 0) {
|
if (difference.length > 0) {
|
||||||
this.logger.warn(`changes between image directory and imageOrder in listing ${dataWithSlug.serialId}: ${difference.join(',')}`);
|
this.logger.warn(`changes between image directory and imageOrder in listing ${dataWithSlug.serialId}: ${difference.join(',')}`);
|
||||||
dataWithSlug.imageOrder = imageOrder;
|
dataWithSlug.imageOrder = imageOrder;
|
||||||
}
|
}
|
||||||
const { id: _, email, ...rest } = dataWithSlug;
|
const { id: _, email, ...rest } = dataWithSlug;
|
||||||
const convertedCommercialPropertyListing = { email, data: rest };
|
const convertedCommercialPropertyListing = { email, data: rest };
|
||||||
const [updateListing] = await this.conn.update(commercials_json).set(convertedCommercialPropertyListing).where(eq(commercials_json.id, id)).returning();
|
const [updateListing] = await this.conn.update(commercials_json).set(convertedCommercialPropertyListing).where(eq(commercials_json.id, id)).returning();
|
||||||
return { id: updateListing.id, email: updateListing.email, ...(updateListing.data as CommercialPropertyListing) };
|
return { id: updateListing.id, email: updateListing.email, ...(updateListing.data as CommercialPropertyListing) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) {
|
||||||
const filteredErrors = error.errors
|
const filteredErrors = error.errors
|
||||||
.map(item => ({
|
.map(item => ({
|
||||||
...item,
|
...item,
|
||||||
field: item.path[0],
|
field: item.path[0],
|
||||||
}))
|
}))
|
||||||
.filter((item, index, self) => index === self.findIndex(t => t.path[0] === item.path[0]));
|
.filter((item, index, self) => index === self.findIndex(t => t.path[0] === item.path[0]));
|
||||||
throw new BadRequestException(filteredErrors);
|
throw new BadRequestException(filteredErrors);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ##############################################################
|
// ##############################################################
|
||||||
// Images for commercial Properties
|
// Images for commercial Properties
|
||||||
// ##############################################################
|
// ##############################################################
|
||||||
async deleteImage(imagePath: string, serial: string, name: string) {
|
async deleteImage(imagePath: string, serial: string, name: string) {
|
||||||
const listing = await this.findByImagePath(imagePath, serial);
|
const listing = await this.findByImagePath(imagePath, serial);
|
||||||
const index = listing.imageOrder.findIndex(im => im === name);
|
const index = listing.imageOrder.findIndex(im => im === name);
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
listing.imageOrder.splice(index, 1);
|
listing.imageOrder.splice(index, 1);
|
||||||
await this.updateCommercialPropertyListing(listing.id, listing, null);
|
await this.updateCommercialPropertyListing(listing.id, listing, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async addImage(imagePath: string, serial: string, imagename: string) {
|
async addImage(imagePath: string, serial: string, imagename: string) {
|
||||||
const listing = await this.findByImagePath(imagePath, serial);
|
const listing = await this.findByImagePath(imagePath, serial);
|
||||||
listing.imageOrder.push(imagename);
|
listing.imageOrder.push(imagename);
|
||||||
await this.updateCommercialPropertyListing(listing.id, listing, null);
|
await this.updateCommercialPropertyListing(listing.id, listing, null);
|
||||||
}
|
}
|
||||||
// #### DELETE ########################################
|
// #### DELETE ########################################
|
||||||
async deleteListing(id: string): Promise<void> {
|
async deleteListing(id: string): Promise<void> {
|
||||||
await this.conn.delete(commercials_json).where(eq(commercials_json.id, id));
|
await this.conn.delete(commercials_json).where(eq(commercials_json.id, id));
|
||||||
}
|
}
|
||||||
// #### ADD Favorite ######################################
|
// #### ADD Favorite ######################################
|
||||||
async addFavorite(id: string, user: JwtUser): Promise<void> {
|
async addFavorite(id: string, user: JwtUser): Promise<void> {
|
||||||
await this.conn
|
await this.conn
|
||||||
.update(commercials_json)
|
.update(commercials_json)
|
||||||
.set({
|
.set({
|
||||||
data: sql`jsonb_set(${commercials_json.data}, '{favoritesForUser}',
|
data: sql`jsonb_set(${commercials_json.data}, '{favoritesForUser}',
|
||||||
coalesce((${commercials_json.data}->'favoritesForUser')::jsonb, '[]'::jsonb) || to_jsonb(${user.email}::text))`,
|
coalesce((${commercials_json.data}->'favoritesForUser')::jsonb, '[]'::jsonb) || to_jsonb(${user.email}::text))`,
|
||||||
})
|
})
|
||||||
.where(eq(commercials_json.id, id));
|
.where(eq(commercials_json.id, id));
|
||||||
}
|
}
|
||||||
// #### DELETE Favorite ###################################
|
// #### DELETE Favorite ###################################
|
||||||
async deleteFavorite(id: string, user: JwtUser): Promise<void> {
|
async deleteFavorite(id: string, user: JwtUser): Promise<void> {
|
||||||
await this.conn
|
await this.conn
|
||||||
.update(commercials_json)
|
.update(commercials_json)
|
||||||
.set({
|
.set({
|
||||||
data: sql`jsonb_set(${commercials_json.data}, '{favoritesForUser}',
|
data: sql`jsonb_set(${commercials_json.data}, '{favoritesForUser}',
|
||||||
(SELECT coalesce(jsonb_agg(elem), '[]'::jsonb)
|
(SELECT coalesce(jsonb_agg(elem), '[]'::jsonb)
|
||||||
FROM jsonb_array_elements(coalesce(${commercials_json.data}->'favoritesForUser', '[]'::jsonb)) AS elem
|
FROM jsonb_array_elements(coalesce(${commercials_json.data}->'favoritesForUser', '[]'::jsonb)) AS elem
|
||||||
WHERE elem::text != to_jsonb(${user.email}::text)::text))`,
|
WHERE elem::text != to_jsonb(${user.email}::text)::text))`,
|
||||||
})
|
})
|
||||||
.where(eq(commercials_json.id, id));
|
.where(eq(commercials_json.id, id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { DrizzleModule } from '../drizzle/drizzle.module';
|
import { DrizzleModule } from '../drizzle/drizzle.module';
|
||||||
import { FileService } from '../file/file.service';
|
import { FileService } from '../file/file.service';
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service';
|
||||||
import { BrokerListingsController } from './broker-listings.controller';
|
import { BrokerListingsController } from './broker-listings.controller';
|
||||||
import { BusinessListingsController } from './business-listings.controller';
|
import { BusinessListingsController } from './business-listings.controller';
|
||||||
import { CommercialPropertyListingsController } from './commercial-property-listings.controller';
|
import { CommercialPropertyListingsController } from './commercial-property-listings.controller';
|
||||||
import { UserListingsController } from './user-listings.controller';
|
import { UserListingsController } from './user-listings.controller';
|
||||||
|
|
||||||
import { FirebaseAdminModule } from 'src/firebase-admin/firebase-admin.module';
|
import { FirebaseAdminModule } from 'src/firebase-admin/firebase-admin.module';
|
||||||
import { GeoModule } from '../geo/geo.module';
|
import { GeoModule } from '../geo/geo.module';
|
||||||
import { GeoService } from '../geo/geo.service';
|
import { GeoService } from '../geo/geo.service';
|
||||||
import { BusinessListingService } from './business-listing.service';
|
import { BusinessListingService } from './business-listing.service';
|
||||||
import { CommercialPropertyService } from './commercial-property.service';
|
import { CommercialPropertyService } from './commercial-property.service';
|
||||||
import { UnknownListingsController } from './unknown-listings.controller';
|
import { UnknownListingsController } from './unknown-listings.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DrizzleModule, AuthModule, GeoModule,FirebaseAdminModule],
|
imports: [DrizzleModule, AuthModule, GeoModule,FirebaseAdminModule],
|
||||||
controllers: [BusinessListingsController, CommercialPropertyListingsController, UnknownListingsController, BrokerListingsController, UserListingsController],
|
controllers: [BusinessListingsController, CommercialPropertyListingsController, UnknownListingsController, BrokerListingsController, UserListingsController],
|
||||||
providers: [BusinessListingService, CommercialPropertyService, FileService, UserService, BusinessListingService, CommercialPropertyService, GeoService],
|
providers: [BusinessListingService, CommercialPropertyService, FileService, UserService, BusinessListingService, CommercialPropertyService, GeoService],
|
||||||
exports: [BusinessListingService, CommercialPropertyService],
|
exports: [BusinessListingService, CommercialPropertyService],
|
||||||
})
|
})
|
||||||
export class ListingsModule {}
|
export class ListingsModule {}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,27 @@
|
||||||
import { LoggerService } from '@nestjs/common';
|
import { LoggerService } from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
|
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const server = express();
|
const server = express();
|
||||||
server.set('trust proxy', true);
|
server.set('trust proxy', true);
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
// const logger = app.get<Logger>(WINSTON_MODULE_NEST_PROVIDER);
|
// const logger = app.get<Logger>(WINSTON_MODULE_NEST_PROVIDER);
|
||||||
const logger = app.get<LoggerService>(WINSTON_MODULE_NEST_PROVIDER);
|
const logger = app.get<LoggerService>(WINSTON_MODULE_NEST_PROVIDER);
|
||||||
app.useLogger(logger);
|
app.useLogger(logger);
|
||||||
//app.use('/bizmatch/payment/webhook', bodyParser.raw({ type: 'application/json' }));
|
//app.use('/bizmatch/payment/webhook', bodyParser.raw({ type: 'application/json' }));
|
||||||
// Serve static files from pictures directory
|
// Serve static files from pictures directory
|
||||||
app.use('/pictures', express.static('pictures'));
|
app.use('/pictures', express.static('pictures'));
|
||||||
|
|
||||||
app.setGlobalPrefix('bizmatch');
|
app.setGlobalPrefix('bizmatch');
|
||||||
|
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: '*',
|
origin: '*',
|
||||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||||
allowedHeaders: 'Content-Type, Accept, Authorization, x-hide-loading',
|
allowedHeaders: 'Content-Type, Accept, Authorization, x-hide-loading',
|
||||||
});
|
});
|
||||||
await app.listen(process.env.PORT || 3001);
|
await app.listen(process.env.PORT || 3001);
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|
|
||||||
|
|
@ -1,393 +1,393 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export interface UserData {
|
export interface UserData {
|
||||||
id?: string;
|
id?: string;
|
||||||
firstname: string;
|
firstname: string;
|
||||||
lastname: string;
|
lastname: string;
|
||||||
email: string;
|
email: string;
|
||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
companyName?: string;
|
companyName?: string;
|
||||||
companyOverview?: string;
|
companyOverview?: string;
|
||||||
companyWebsite?: string;
|
companyWebsite?: string;
|
||||||
companyLocation?: string;
|
companyLocation?: string;
|
||||||
offeredServices?: string;
|
offeredServices?: string;
|
||||||
areasServed?: string[];
|
areasServed?: string[];
|
||||||
hasProfile?: boolean;
|
hasProfile?: boolean;
|
||||||
hasCompanyLogo?: boolean;
|
hasCompanyLogo?: boolean;
|
||||||
licensedIn?: string[];
|
licensedIn?: string[];
|
||||||
gender?: 'male' | 'female';
|
gender?: 'male' | 'female';
|
||||||
customerType?: 'buyer' | 'seller' | 'professional';
|
customerType?: 'buyer' | 'seller' | 'professional';
|
||||||
customerSubType?: 'broker' | 'cpa' | 'attorney' | 'titleCompany' | 'surveyor' | 'appraiser';
|
customerSubType?: 'broker' | 'cpa' | 'attorney' | 'titleCompany' | 'surveyor' | 'appraiser';
|
||||||
created?: Date;
|
created?: Date;
|
||||||
updated?: Date;
|
updated?: Date;
|
||||||
}
|
}
|
||||||
export type SortByOptions = 'priceAsc' | 'priceDesc' | 'creationDateFirst' | 'creationDateLast' | 'nameAsc' | 'nameDesc' | 'srAsc' | 'srDesc' | 'cfAsc' | 'cfDesc';
|
export type SortByOptions = 'priceAsc' | 'priceDesc' | 'creationDateFirst' | 'creationDateLast' | 'nameAsc' | 'nameDesc' | 'srAsc' | 'srDesc' | 'cfAsc' | 'cfDesc';
|
||||||
export type SortByTypes = 'professional' | 'listing' | 'business' | 'commercial';
|
export type SortByTypes = 'professional' | 'listing' | 'business' | 'commercial';
|
||||||
export type Gender = 'male' | 'female';
|
export type Gender = 'male' | 'female';
|
||||||
export type CustomerType = 'buyer' | 'seller' | 'professional';
|
export type CustomerType = 'buyer' | 'seller' | 'professional';
|
||||||
export type CustomerSubType = 'broker' | 'cpa' | 'attorney' | 'titleCompany' | 'surveyor' | 'appraiser';
|
export type CustomerSubType = 'broker' | 'cpa' | 'attorney' | 'titleCompany' | 'surveyor' | 'appraiser';
|
||||||
export type ListingsCategory = 'commercialProperty' | 'business';
|
export type ListingsCategory = 'commercialProperty' | 'business';
|
||||||
|
|
||||||
export const GenderEnum = z.enum(['male', 'female']);
|
export const GenderEnum = z.enum(['male', 'female']);
|
||||||
export const CustomerTypeEnum = z.enum(['buyer', 'seller', 'professional']);
|
export const CustomerTypeEnum = z.enum(['buyer', 'seller', 'professional']);
|
||||||
export const SubscriptionTypeEnum = z.enum(['free', 'professional', 'broker']);
|
export const SubscriptionTypeEnum = z.enum(['free', 'professional', 'broker']);
|
||||||
export const CustomerSubTypeEnum = z.enum(['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']);
|
export const CustomerSubTypeEnum = z.enum(['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']);
|
||||||
export const ListingsCategoryEnum = z.enum(['commercialProperty', 'business']);
|
export const ListingsCategoryEnum = z.enum(['commercialProperty', 'business']);
|
||||||
export const ShareCategoryEnum = z.enum(['commercialProperty', 'business', 'user']);
|
export const ShareCategoryEnum = z.enum(['commercialProperty', 'business', 'user']);
|
||||||
export const ZodEventTypeEnum = z.enum(['view', 'print', 'email', 'facebook', 'x', 'linkedin', 'contact', 'favorite', 'emailus', 'pricing']);
|
export const ZodEventTypeEnum = z.enum(['view', 'print', 'email', 'facebook', 'x', 'linkedin', 'contact', 'favorite', 'emailus', 'pricing']);
|
||||||
export type EventTypeEnum = z.infer<typeof ZodEventTypeEnum>;
|
export type EventTypeEnum = z.infer<typeof ZodEventTypeEnum>;
|
||||||
const PropertyTypeEnum = z.enum(['retail', 'land', 'industrial', 'office', 'mixedUse', 'multifamily', 'uncategorized']);
|
const PropertyTypeEnum = z.enum(['retail', 'land', 'industrial', 'office', 'mixedUse', 'multifamily', 'uncategorized']);
|
||||||
const TypeEnum = z.enum([
|
const TypeEnum = z.enum([
|
||||||
'automotive',
|
'automotive',
|
||||||
'industrialServices',
|
'industrialServices',
|
||||||
'foodAndRestaurant',
|
'foodAndRestaurant',
|
||||||
'realEstate',
|
'realEstate',
|
||||||
'retail',
|
'retail',
|
||||||
'oilfield',
|
'oilfield',
|
||||||
'service',
|
'service',
|
||||||
'advertising',
|
'advertising',
|
||||||
'agriculture',
|
'agriculture',
|
||||||
'franchise',
|
'franchise',
|
||||||
'professional',
|
'professional',
|
||||||
'manufacturing',
|
'manufacturing',
|
||||||
'uncategorized',
|
'uncategorized',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const USStates = z.enum([
|
const USStates = z.enum([
|
||||||
'AL',
|
'AL',
|
||||||
'AK',
|
'AK',
|
||||||
'AZ',
|
'AZ',
|
||||||
'AR',
|
'AR',
|
||||||
'CA',
|
'CA',
|
||||||
'CO',
|
'CO',
|
||||||
'CT',
|
'CT',
|
||||||
'DC',
|
'DC',
|
||||||
'DE',
|
'DE',
|
||||||
'FL',
|
'FL',
|
||||||
'GA',
|
'GA',
|
||||||
'HI',
|
'HI',
|
||||||
'ID',
|
'ID',
|
||||||
'IL',
|
'IL',
|
||||||
'IN',
|
'IN',
|
||||||
'IA',
|
'IA',
|
||||||
'KS',
|
'KS',
|
||||||
'KY',
|
'KY',
|
||||||
'LA',
|
'LA',
|
||||||
'ME',
|
'ME',
|
||||||
'MD',
|
'MD',
|
||||||
'MA',
|
'MA',
|
||||||
'MI',
|
'MI',
|
||||||
'MN',
|
'MN',
|
||||||
'MS',
|
'MS',
|
||||||
'MO',
|
'MO',
|
||||||
'MT',
|
'MT',
|
||||||
'NE',
|
'NE',
|
||||||
'NV',
|
'NV',
|
||||||
'NH',
|
'NH',
|
||||||
'NJ',
|
'NJ',
|
||||||
'NM',
|
'NM',
|
||||||
'NY',
|
'NY',
|
||||||
'NC',
|
'NC',
|
||||||
'ND',
|
'ND',
|
||||||
'OH',
|
'OH',
|
||||||
'OK',
|
'OK',
|
||||||
'OR',
|
'OR',
|
||||||
'PA',
|
'PA',
|
||||||
'RI',
|
'RI',
|
||||||
'SC',
|
'SC',
|
||||||
'SD',
|
'SD',
|
||||||
'TN',
|
'TN',
|
||||||
'TX',
|
'TX',
|
||||||
'UT',
|
'UT',
|
||||||
'VT',
|
'VT',
|
||||||
'VA',
|
'VA',
|
||||||
'WA',
|
'WA',
|
||||||
'WV',
|
'WV',
|
||||||
'WI',
|
'WI',
|
||||||
'WY',
|
'WY',
|
||||||
]);
|
]);
|
||||||
export const AreasServedSchema = z.object({
|
export const AreasServedSchema = z.object({
|
||||||
county: z.string().optional().nullable(),
|
county: z.string().optional().nullable(),
|
||||||
state: z
|
state: z
|
||||||
.string()
|
.string()
|
||||||
.nullable()
|
.nullable()
|
||||||
.refine(val => val !== null && val !== '', {
|
.refine(val => val !== null && val !== '', {
|
||||||
message: 'State is required',
|
message: 'State is required',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const LicensedInSchema = z.object({
|
export const LicensedInSchema = z.object({
|
||||||
state: z
|
state: z
|
||||||
.string()
|
.string()
|
||||||
.nullable()
|
.nullable()
|
||||||
.refine(val => val !== null && val !== '', {
|
.refine(val => val !== null && val !== '', {
|
||||||
message: 'State is required',
|
message: 'State is required',
|
||||||
}),
|
}),
|
||||||
registerNo: z.string().nonempty('License number is required'),
|
registerNo: z.string().nonempty('License number is required'),
|
||||||
});
|
});
|
||||||
export const GeoSchema = z
|
export const GeoSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().optional().nullable(),
|
name: z.string().optional().nullable(),
|
||||||
state: z.string().refine(val => USStates.safeParse(val).success, {
|
state: z.string().refine(val => USStates.safeParse(val).success, {
|
||||||
message: 'Invalid state. Must be a valid 2-letter US state code.',
|
message: 'Invalid state. Must be a valid 2-letter US state code.',
|
||||||
}),
|
}),
|
||||||
latitude: z.number().refine(
|
latitude: z.number().refine(
|
||||||
value => {
|
value => {
|
||||||
return value >= -90 && value <= 90;
|
return value >= -90 && value <= 90;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: 'Latitude muss zwischen -90 und 90 liegen',
|
message: 'Latitude muss zwischen -90 und 90 liegen',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
longitude: z.number().refine(
|
longitude: z.number().refine(
|
||||||
value => {
|
value => {
|
||||||
return value >= -180 && value <= 180;
|
return value >= -180 && value <= 180;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: 'Longitude muss zwischen -180 und 180 liegen',
|
message: 'Longitude muss zwischen -180 und 180 liegen',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
county: z.string().optional().nullable(),
|
county: z.string().optional().nullable(),
|
||||||
housenumber: z.string().optional().nullable(),
|
housenumber: z.string().optional().nullable(),
|
||||||
street: z.string().optional().nullable(),
|
street: z.string().optional().nullable(),
|
||||||
zipCode: z.number().optional().nullable(),
|
zipCode: z.number().optional().nullable(),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
if (!data.state) {
|
if (!data.state) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'You need to select at least a state',
|
message: 'You need to select at least a state',
|
||||||
path: ['name'],
|
path: ['name'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const phoneRegex = /^(\+1|1)?[-.\s]?\(?[2-9]\d{2}\)?[-.\s]?\d{3}[-.\s]?\d{4}$/;
|
const phoneRegex = /^(\+1|1)?[-.\s]?\(?[2-9]\d{2}\)?[-.\s]?\d{3}[-.\s]?\d{4}$/;
|
||||||
export const UserSchema = z
|
export const UserSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: z.string().uuid().optional().nullable(),
|
id: z.string().uuid().optional().nullable(),
|
||||||
firstname: z.string().min(3, { message: 'First name must contain at least 2 characters' }),
|
firstname: z.string().min(3, { message: 'First name must contain at least 2 characters' }),
|
||||||
lastname: z.string().min(3, { message: 'Last name must contain at least 2 characters' }),
|
lastname: z.string().min(3, { message: 'Last name must contain at least 2 characters' }),
|
||||||
email: z.string().email({ message: 'Invalid email address' }),
|
email: z.string().email({ message: 'Invalid email address' }),
|
||||||
phoneNumber: z.string().optional().nullable(),
|
phoneNumber: z.string().optional().nullable(),
|
||||||
description: z.string().optional().nullable(),
|
description: z.string().optional().nullable(),
|
||||||
companyName: z.string().optional().nullable(),
|
companyName: z.string().optional().nullable(),
|
||||||
companyOverview: z.string().optional().nullable(),
|
companyOverview: z.string().optional().nullable(),
|
||||||
companyWebsite: z.string().url({ message: 'Invalid URL format' }).optional().nullable(),
|
companyWebsite: z.string().url({ message: 'Invalid URL format' }).optional().nullable(),
|
||||||
location: GeoSchema.optional().nullable(),
|
location: GeoSchema.optional().nullable(),
|
||||||
offeredServices: z.string().optional().nullable(),
|
offeredServices: z.string().optional().nullable(),
|
||||||
areasServed: z.array(AreasServedSchema).optional().nullable(),
|
areasServed: z.array(AreasServedSchema).optional().nullable(),
|
||||||
hasProfile: z.boolean().optional().nullable(),
|
hasProfile: z.boolean().optional().nullable(),
|
||||||
hasCompanyLogo: z.boolean().optional().nullable(),
|
hasCompanyLogo: z.boolean().optional().nullable(),
|
||||||
licensedIn: z.array(LicensedInSchema).optional().nullable(),
|
licensedIn: z.array(LicensedInSchema).optional().nullable(),
|
||||||
gender: GenderEnum.optional().nullable(),
|
gender: GenderEnum.optional().nullable(),
|
||||||
customerType: CustomerTypeEnum,
|
customerType: CustomerTypeEnum,
|
||||||
customerSubType: CustomerSubTypeEnum.optional().nullable(),
|
customerSubType: CustomerSubTypeEnum.optional().nullable(),
|
||||||
created: z.date().optional().nullable(),
|
created: z.date().optional().nullable(),
|
||||||
updated: z.date().optional().nullable(),
|
updated: z.date().optional().nullable(),
|
||||||
subscriptionId: z.string().optional().nullable(),
|
subscriptionId: z.string().optional().nullable(),
|
||||||
subscriptionPlan: SubscriptionTypeEnum.optional().nullable(),
|
subscriptionPlan: SubscriptionTypeEnum.optional().nullable(),
|
||||||
favoritesForUser: z.array(z.string()),
|
favoritesForUser: z.array(z.string()),
|
||||||
showInDirectory: z.boolean(),
|
showInDirectory: z.boolean(),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.customerType === 'professional') {
|
if (data.customerType === 'professional') {
|
||||||
if (!data.customerSubType) {
|
if (!data.customerSubType) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'Customer subtype is required for professional customers',
|
message: 'Customer subtype is required for professional customers',
|
||||||
path: ['customerSubType'],
|
path: ['customerSubType'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!data.companyName || data.companyName.length < 6) {
|
if (!data.companyName || data.companyName.length < 6) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'Company Name must contain at least 6 characters for professional customers',
|
message: 'Company Name must contain at least 6 characters for professional customers',
|
||||||
path: ['companyName'],
|
path: ['companyName'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!data.phoneNumber || !phoneRegex.test(data.phoneNumber)) {
|
if (!data.phoneNumber || !phoneRegex.test(data.phoneNumber)) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'Phone number is required and must be in US format (XXX) XXX-XXXX for professional customers',
|
message: 'Phone number is required and must be in US format (XXX) XXX-XXXX for professional customers',
|
||||||
path: ['phoneNumber'],
|
path: ['phoneNumber'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.companyOverview || data.companyOverview.length < 10) {
|
if (!data.companyOverview || data.companyOverview.length < 10) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'Company overview must contain at least 10 characters for professional customers',
|
message: 'Company overview must contain at least 10 characters for professional customers',
|
||||||
path: ['companyOverview'],
|
path: ['companyOverview'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.description || data.description.length < 10) {
|
if (!data.description || data.description.length < 10) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'Description must contain at least 10 characters for professional customers',
|
message: 'Description must contain at least 10 characters for professional customers',
|
||||||
path: ['description'],
|
path: ['description'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.offeredServices || data.offeredServices.length < 10) {
|
if (!data.offeredServices || data.offeredServices.length < 10) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'Offered services must contain at least 10 characters for professional customers',
|
message: 'Offered services must contain at least 10 characters for professional customers',
|
||||||
path: ['offeredServices'],
|
path: ['offeredServices'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.location) {
|
if (!data.location) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'Company location is required for professional customers',
|
message: 'Company location is required for professional customers',
|
||||||
path: ['location'],
|
path: ['location'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.areasServed || data.areasServed.length < 1) {
|
if (!data.areasServed || data.areasServed.length < 1) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'At least one area served is required for professional customers',
|
message: 'At least one area served is required for professional customers',
|
||||||
path: ['areasServed'],
|
path: ['areasServed'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AreasServed = z.infer<typeof AreasServedSchema>;
|
export type AreasServed = z.infer<typeof AreasServedSchema>;
|
||||||
export type LicensedIn = z.infer<typeof LicensedInSchema>;
|
export type LicensedIn = z.infer<typeof LicensedInSchema>;
|
||||||
export type User = z.infer<typeof UserSchema>;
|
export type User = z.infer<typeof UserSchema>;
|
||||||
|
|
||||||
export const BusinessListingSchema = z
|
export const BusinessListingSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: z.string().uuid().optional().nullable(),
|
id: z.string().uuid().optional().nullable(),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
type: z.string().refine(val => TypeEnum.safeParse(val).success, {
|
type: z.string().refine(val => TypeEnum.safeParse(val).success, {
|
||||||
message: 'Invalid type. Must be one of: ' + TypeEnum.options.join(', '),
|
message: 'Invalid type. Must be one of: ' + TypeEnum.options.join(', '),
|
||||||
}),
|
}),
|
||||||
title: z.string().min(10),
|
title: z.string().min(10),
|
||||||
description: z.string().min(10),
|
description: z.string().min(10),
|
||||||
location: GeoSchema,
|
location: GeoSchema,
|
||||||
price: z.number().positive().optional().nullable(),
|
price: z.number().positive().optional().nullable(),
|
||||||
favoritesForUser: z.array(z.string()),
|
favoritesForUser: z.array(z.string()),
|
||||||
draft: z.boolean(),
|
draft: z.boolean(),
|
||||||
listingsCategory: ListingsCategoryEnum,
|
listingsCategory: ListingsCategoryEnum,
|
||||||
realEstateIncluded: z.boolean().optional().nullable(),
|
realEstateIncluded: z.boolean().optional().nullable(),
|
||||||
leasedLocation: z.boolean().optional().nullable(),
|
leasedLocation: z.boolean().optional().nullable(),
|
||||||
franchiseResale: z.boolean().optional().nullable(),
|
franchiseResale: z.boolean().optional().nullable(),
|
||||||
salesRevenue: z.number().positive().nullable(),
|
salesRevenue: z.number().positive().nullable(),
|
||||||
cashFlow: z.number().optional().nullable(),
|
cashFlow: z.number().optional().nullable(),
|
||||||
ffe: z.number().optional().nullable(),
|
ffe: z.number().optional().nullable(),
|
||||||
inventory: z.number().optional().nullable(),
|
inventory: z.number().optional().nullable(),
|
||||||
supportAndTraining: z.string().min(5).optional().nullable(),
|
supportAndTraining: z.string().min(5).optional().nullable(),
|
||||||
employees: z.number().int().positive().max(100000).optional().nullable(),
|
employees: z.number().int().positive().max(100000).optional().nullable(),
|
||||||
established: z.number().int().min(1).max(250).optional().nullable(),
|
established: z.number().int().min(1).max(250).optional().nullable(),
|
||||||
internalListingNumber: z.number().int().positive().optional().nullable(),
|
internalListingNumber: z.number().int().positive().optional().nullable(),
|
||||||
reasonForSale: z.string().min(5).optional().nullable(),
|
reasonForSale: z.string().min(5).optional().nullable(),
|
||||||
brokerLicencing: z.string().optional().nullable(),
|
brokerLicencing: z.string().optional().nullable(),
|
||||||
internals: z.string().min(5).optional().nullable(),
|
internals: z.string().min(5).optional().nullable(),
|
||||||
imageName: z.string().optional().nullable(),
|
imageName: z.string().optional().nullable(),
|
||||||
slug: z.string().optional().nullable(),
|
slug: z.string().optional().nullable(),
|
||||||
created: z.date(),
|
created: z.date(),
|
||||||
updated: z.date(),
|
updated: z.date(),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.price && data.price > 1000000000) {
|
if (data.price && data.price > 1000000000) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'Price must less than or equal $1,000,000,000',
|
message: 'Price must less than or equal $1,000,000,000',
|
||||||
path: ['price'],
|
path: ['price'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (data.salesRevenue && data.salesRevenue > 100000000) {
|
if (data.salesRevenue && data.salesRevenue > 100000000) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'SalesRevenue must less than or equal $100,000,000',
|
message: 'SalesRevenue must less than or equal $100,000,000',
|
||||||
path: ['salesRevenue'],
|
path: ['salesRevenue'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (data.cashFlow && data.cashFlow > 100000000) {
|
if (data.cashFlow && data.cashFlow > 100000000) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'CashFlow must less than or equal $100,000,000',
|
message: 'CashFlow must less than or equal $100,000,000',
|
||||||
path: ['cashFlow'],
|
path: ['cashFlow'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
export type BusinessListing = z.infer<typeof BusinessListingSchema>;
|
export type BusinessListing = z.infer<typeof BusinessListingSchema>;
|
||||||
|
|
||||||
export const CommercialPropertyListingSchema = z
|
export const CommercialPropertyListingSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: z.string().uuid().optional().nullable(),
|
id: z.string().uuid().optional().nullable(),
|
||||||
serialId: z.number().int().positive().optional().nullable(),
|
serialId: z.number().int().positive().optional().nullable(),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
type: z.string().refine(val => PropertyTypeEnum.safeParse(val).success, {
|
type: z.string().refine(val => PropertyTypeEnum.safeParse(val).success, {
|
||||||
message: 'Invalid type. Must be one of: ' + PropertyTypeEnum.options.join(', '),
|
message: 'Invalid type. Must be one of: ' + PropertyTypeEnum.options.join(', '),
|
||||||
}),
|
}),
|
||||||
title: z.string().min(10),
|
title: z.string().min(10),
|
||||||
description: z.string().min(10),
|
description: z.string().min(10),
|
||||||
location: GeoSchema,
|
location: GeoSchema,
|
||||||
price: z.number().positive().optional().nullable(),
|
price: z.number().positive().optional().nullable(),
|
||||||
favoritesForUser: z.array(z.string()),
|
favoritesForUser: z.array(z.string()),
|
||||||
listingsCategory: ListingsCategoryEnum,
|
listingsCategory: ListingsCategoryEnum,
|
||||||
internalListingNumber: z.number().int().positive().optional().nullable(),
|
internalListingNumber: z.number().int().positive().optional().nullable(),
|
||||||
draft: z.boolean(),
|
draft: z.boolean(),
|
||||||
imageOrder: z.array(z.string()),
|
imageOrder: z.array(z.string()),
|
||||||
imagePath: z.string().nullable().optional(),
|
imagePath: z.string().nullable().optional(),
|
||||||
slug: z.string().optional().nullable(),
|
slug: z.string().optional().nullable(),
|
||||||
created: z.date(),
|
created: z.date(),
|
||||||
updated: z.date(),
|
updated: z.date(),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.price && data.price > 1000000000) {
|
if (data.price && data.price > 1000000000) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: 'Price must less than or equal $1,000,000,000',
|
message: 'Price must less than or equal $1,000,000,000',
|
||||||
path: ['price'],
|
path: ['price'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CommercialPropertyListing = z.infer<typeof CommercialPropertyListingSchema>;
|
export type CommercialPropertyListing = z.infer<typeof CommercialPropertyListingSchema>;
|
||||||
|
|
||||||
export const SenderSchema = z.object({
|
export const SenderSchema = z.object({
|
||||||
name: z.string().min(6, { message: 'Name must be at least 6 characters long' }),
|
name: z.string().min(6, { message: 'Name must be at least 6 characters long' }),
|
||||||
email: z.string().email({ message: 'Invalid email address' }),
|
email: z.string().email({ message: 'Invalid email address' }),
|
||||||
phoneNumber: z.string().regex(/^(\+1|1)?[-.\s]?\(?[2-9]\d{2}\)?[-.\s]?\d{3}[-.\s]?\d{4}$/, {
|
phoneNumber: z.string().regex(/^(\+1|1)?[-.\s]?\(?[2-9]\d{2}\)?[-.\s]?\d{3}[-.\s]?\d{4}$/, {
|
||||||
message: 'Invalid US phone number format',
|
message: 'Invalid US phone number format',
|
||||||
}),
|
}),
|
||||||
state: z.string().refine(val => USStates.safeParse(val).success, {
|
state: z.string().refine(val => USStates.safeParse(val).success, {
|
||||||
message: 'Invalid state. Must be a valid 2-letter US state code.',
|
message: 'Invalid state. Must be a valid 2-letter US state code.',
|
||||||
}),
|
}),
|
||||||
comments: z.string().min(10, { message: 'Comments must be at least 10 characters long' }),
|
comments: z.string().min(10, { message: 'Comments must be at least 10 characters long' }),
|
||||||
});
|
});
|
||||||
export type Sender = z.infer<typeof SenderSchema>;
|
export type Sender = z.infer<typeof SenderSchema>;
|
||||||
export const ShareByEMailSchema = z.object({
|
export const ShareByEMailSchema = z.object({
|
||||||
yourName: z.string().min(6, { message: 'Name must be at least 6 characters long' }),
|
yourName: z.string().min(6, { message: 'Name must be at least 6 characters long' }),
|
||||||
recipientEmail: z.string().email({ message: 'Invalid email address' }),
|
recipientEmail: z.string().email({ message: 'Invalid email address' }),
|
||||||
yourEmail: z.string().email({ message: 'Invalid email address' }),
|
yourEmail: z.string().email({ message: 'Invalid email address' }),
|
||||||
listingTitle: z.string().optional().nullable(),
|
listingTitle: z.string().optional().nullable(),
|
||||||
url: z.string().url({ message: 'Invalid URL format' }).optional().nullable(),
|
url: z.string().url({ message: 'Invalid URL format' }).optional().nullable(),
|
||||||
id: z.string().optional().nullable(),
|
id: z.string().optional().nullable(),
|
||||||
type: ShareCategoryEnum,
|
type: ShareCategoryEnum,
|
||||||
});
|
});
|
||||||
export type ShareByEMail = z.infer<typeof ShareByEMailSchema>;
|
export type ShareByEMail = z.infer<typeof ShareByEMailSchema>;
|
||||||
|
|
||||||
export const ListingEventSchema = z.object({
|
export const ListingEventSchema = z.object({
|
||||||
id: z.string().uuid(), // UUID für das Event
|
id: z.string().uuid(), // UUID für das Event
|
||||||
listingId: z.string().uuid().optional().nullable(), // UUID für das Listing
|
listingId: z.string().uuid().optional().nullable(), // UUID für das Listing
|
||||||
email: z.string().email().optional().nullable(), // EMail des den Benutzer, optional, wenn kein Benutzer eingeloggt ist
|
email: z.string().email().optional().nullable(), // EMail des den Benutzer, optional, wenn kein Benutzer eingeloggt ist
|
||||||
eventType: ZodEventTypeEnum, // Die Event-Typen
|
eventType: ZodEventTypeEnum, // Die Event-Typen
|
||||||
eventTimestamp: z.string().datetime().or(z.date()), // Der Zeitstempel des Events, kann ein String im ISO-Format oder ein Date-Objekt sein
|
eventTimestamp: z.string().datetime().or(z.date()), // Der Zeitstempel des Events, kann ein String im ISO-Format oder ein Date-Objekt sein
|
||||||
userIp: z.string().max(45).optional().nullable(), // IP-Adresse des Benutzers, optional
|
userIp: z.string().max(45).optional().nullable(), // IP-Adresse des Benutzers, optional
|
||||||
userAgent: z.string().max(255).optional().nullable(), // User-Agent des Benutzers, optional
|
userAgent: z.string().max(255).optional().nullable(), // User-Agent des Benutzers, optional
|
||||||
locationCountry: z.string().max(100).optional().nullable(), // Land, optional
|
locationCountry: z.string().max(100).optional().nullable(), // Land, optional
|
||||||
locationCity: z.string().max(100).optional().nullable(), // Stadt, optional
|
locationCity: z.string().max(100).optional().nullable(), // Stadt, optional
|
||||||
locationLat: z.string().max(20).optional().nullable(), // Latitude, als String
|
locationLat: z.string().max(20).optional().nullable(), // Latitude, als String
|
||||||
locationLng: z.string().max(20).optional().nullable(), // Longitude, als String
|
locationLng: z.string().max(20).optional().nullable(), // Longitude, als String
|
||||||
referrer: z.string().max(255).optional().nullable(), // Referrer URL, optional
|
referrer: z.string().max(255).optional().nullable(), // Referrer URL, optional
|
||||||
additionalData: z.record(z.string(), z.any()).optional().nullable(), // JSON für zusätzliche Daten, z.B. soziale Medien, optional
|
additionalData: z.record(z.string(), z.any()).optional().nullable(), // JSON für zusätzliche Daten, z.B. soziale Medien, optional
|
||||||
});
|
});
|
||||||
export type ListingEvent = z.infer<typeof ListingEventSchema>;
|
export type ListingEvent = z.infer<typeof ListingEventSchema>;
|
||||||
|
|
|
||||||
|
|
@ -1,430 +1,430 @@
|
||||||
import { BusinessListing, CommercialPropertyListing, Sender, SortByOptions, SortByTypes, User } from './db.model';
|
import { BusinessListing, CommercialPropertyListing, Sender, SortByOptions, SortByTypes, User } from './db.model';
|
||||||
import { State } from './server.model';
|
import { State } from './server.model';
|
||||||
|
|
||||||
export interface StatesResult {
|
export interface StatesResult {
|
||||||
state: string;
|
state: string;
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KeyValue {
|
export interface KeyValue {
|
||||||
name: string;
|
name: string;
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
export interface KeyValueAsSortBy {
|
export interface KeyValueAsSortBy {
|
||||||
name: string;
|
name: string;
|
||||||
value: SortByOptions;
|
value: SortByOptions;
|
||||||
type?: SortByTypes;
|
type?: SortByTypes;
|
||||||
selectName?: string;
|
selectName?: string;
|
||||||
}
|
}
|
||||||
export interface KeyValueRatio {
|
export interface KeyValueRatio {
|
||||||
label: string;
|
label: string;
|
||||||
value: number;
|
value: number;
|
||||||
}
|
}
|
||||||
export interface KeyValueStyle {
|
export interface KeyValueStyle {
|
||||||
name: string;
|
name: string;
|
||||||
value: string;
|
value: string;
|
||||||
oldValue?: string;
|
oldValue?: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
textColorClass: string;
|
textColorClass: string;
|
||||||
}
|
}
|
||||||
export type SelectOption<T = number> = {
|
export type SelectOption<T = number> = {
|
||||||
value: T;
|
value: T;
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
export type ImageType = {
|
export type ImageType = {
|
||||||
name: 'propertyPicture' | 'companyLogo' | 'profile';
|
name: 'propertyPicture' | 'companyLogo' | 'profile';
|
||||||
upload: string;
|
upload: string;
|
||||||
delete: string;
|
delete: string;
|
||||||
};
|
};
|
||||||
export type ListingCategory = {
|
export type ListingCategory = {
|
||||||
name: 'business' | 'commercialProperty';
|
name: 'business' | 'commercialProperty';
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ListingType = BusinessListing | CommercialPropertyListing;
|
export type ListingType = BusinessListing | CommercialPropertyListing;
|
||||||
|
|
||||||
export type ResponseBusinessListingArray = {
|
export type ResponseBusinessListingArray = {
|
||||||
results: BusinessListing[];
|
results: BusinessListing[];
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
};
|
};
|
||||||
export type ResponseBusinessListing = {
|
export type ResponseBusinessListing = {
|
||||||
data: BusinessListing;
|
data: BusinessListing;
|
||||||
};
|
};
|
||||||
export type ResponseCommercialPropertyListingArray = {
|
export type ResponseCommercialPropertyListingArray = {
|
||||||
results: CommercialPropertyListing[];
|
results: CommercialPropertyListing[];
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
};
|
};
|
||||||
export type ResponseCommercialPropertyListing = {
|
export type ResponseCommercialPropertyListing = {
|
||||||
data: CommercialPropertyListing;
|
data: CommercialPropertyListing;
|
||||||
};
|
};
|
||||||
export type ResponseUsersArray = {
|
export type ResponseUsersArray = {
|
||||||
results: User[];
|
results: User[];
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
};
|
};
|
||||||
export interface ListCriteria {
|
export interface ListCriteria {
|
||||||
start: number;
|
start: number;
|
||||||
length: number;
|
length: number;
|
||||||
page: number;
|
page: number;
|
||||||
types: string[];
|
types: string[];
|
||||||
state: string;
|
state: string;
|
||||||
city: GeoResult;
|
city: GeoResult;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
searchType: 'exact' | 'radius';
|
searchType: 'exact' | 'radius';
|
||||||
// radius: '5' | '20' | '50' | '100' | '200' | '300' | '400' | '500';
|
// radius: '5' | '20' | '50' | '100' | '200' | '300' | '400' | '500';
|
||||||
radius: number;
|
radius: number;
|
||||||
criteriaType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
|
criteriaType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
|
||||||
sortBy?: SortByOptions;
|
sortBy?: SortByOptions;
|
||||||
}
|
}
|
||||||
export interface BusinessListingCriteria extends ListCriteria {
|
export interface BusinessListingCriteria extends ListCriteria {
|
||||||
minPrice: number;
|
minPrice: number;
|
||||||
maxPrice: number;
|
maxPrice: number;
|
||||||
minRevenue: number;
|
minRevenue: number;
|
||||||
maxRevenue: number;
|
maxRevenue: number;
|
||||||
minCashFlow: number;
|
minCashFlow: number;
|
||||||
maxCashFlow: number;
|
maxCashFlow: number;
|
||||||
minNumberEmployees: number;
|
minNumberEmployees: number;
|
||||||
maxNumberEmployees: number;
|
maxNumberEmployees: number;
|
||||||
establishedMin: number;
|
establishedMin: number;
|
||||||
realEstateChecked: boolean;
|
realEstateChecked: boolean;
|
||||||
leasedLocation: boolean;
|
leasedLocation: boolean;
|
||||||
franchiseResale: boolean;
|
franchiseResale: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
brokerName: string;
|
brokerName: string;
|
||||||
email: string;
|
email: string;
|
||||||
criteriaType: 'businessListings';
|
criteriaType: 'businessListings';
|
||||||
}
|
}
|
||||||
export interface CommercialPropertyListingCriteria extends ListCriteria {
|
export interface CommercialPropertyListingCriteria extends ListCriteria {
|
||||||
minPrice: number;
|
minPrice: number;
|
||||||
maxPrice: number;
|
maxPrice: number;
|
||||||
title: string;
|
title: string;
|
||||||
brokerName: string;
|
brokerName: string;
|
||||||
criteriaType: 'commercialPropertyListings';
|
criteriaType: 'commercialPropertyListings';
|
||||||
}
|
}
|
||||||
export interface UserListingCriteria extends ListCriteria {
|
export interface UserListingCriteria extends ListCriteria {
|
||||||
brokerName: string;
|
brokerName: string;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
counties: string[];
|
counties: string[];
|
||||||
criteriaType: 'brokerListings';
|
criteriaType: 'brokerListings';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KeycloakUser {
|
export interface KeycloakUser {
|
||||||
id: string;
|
id: string;
|
||||||
createdTimestamp?: number;
|
createdTimestamp?: number;
|
||||||
username?: string;
|
username?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
totp?: boolean;
|
totp?: boolean;
|
||||||
emailVerified?: boolean;
|
emailVerified?: boolean;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
email: string;
|
email: string;
|
||||||
disableableCredentialTypes?: any[];
|
disableableCredentialTypes?: any[];
|
||||||
requiredActions?: any[];
|
requiredActions?: any[];
|
||||||
notBefore?: number;
|
notBefore?: number;
|
||||||
access?: Access;
|
access?: Access;
|
||||||
attributes?: Attributes;
|
attributes?: Attributes;
|
||||||
}
|
}
|
||||||
export interface JwtUser {
|
export interface JwtUser {
|
||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
uid: string;
|
uid: string;
|
||||||
}
|
}
|
||||||
interface Attributes {
|
interface Attributes {
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
priceID: any;
|
priceID: any;
|
||||||
}
|
}
|
||||||
export interface Access {
|
export interface Access {
|
||||||
manageGroupMembership: boolean;
|
manageGroupMembership: boolean;
|
||||||
view: boolean;
|
view: boolean;
|
||||||
mapRoles: boolean;
|
mapRoles: boolean;
|
||||||
impersonate: boolean;
|
impersonate: boolean;
|
||||||
manage: boolean;
|
manage: boolean;
|
||||||
}
|
}
|
||||||
export interface Subscription {
|
export interface Subscription {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
level: string;
|
level: string;
|
||||||
start: Date;
|
start: Date;
|
||||||
modified: Date;
|
modified: Date;
|
||||||
end: Date;
|
end: Date;
|
||||||
status: string;
|
status: string;
|
||||||
invoices: Array<Invoice>;
|
invoices: Array<Invoice>;
|
||||||
}
|
}
|
||||||
export interface Invoice {
|
export interface Invoice {
|
||||||
id: string;
|
id: string;
|
||||||
date: Date;
|
date: Date;
|
||||||
price: number;
|
price: number;
|
||||||
}
|
}
|
||||||
export interface JwtToken {
|
export interface JwtToken {
|
||||||
exp: number;
|
exp: number;
|
||||||
iat: number;
|
iat: number;
|
||||||
auth_time: number;
|
auth_time: number;
|
||||||
jti: string;
|
jti: string;
|
||||||
iss: string;
|
iss: string;
|
||||||
aud: string;
|
aud: string;
|
||||||
sub: string;
|
sub: string;
|
||||||
typ: string;
|
typ: string;
|
||||||
azp: string;
|
azp: string;
|
||||||
nonce: string;
|
nonce: string;
|
||||||
session_state: string;
|
session_state: string;
|
||||||
acr: string;
|
acr: string;
|
||||||
realm_access: Realmaccess;
|
realm_access: Realmaccess;
|
||||||
resource_access: Resourceaccess;
|
resource_access: Resourceaccess;
|
||||||
scope: string;
|
scope: string;
|
||||||
sid: string;
|
sid: string;
|
||||||
email_verified: boolean;
|
email_verified: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
preferred_username: string;
|
preferred_username: string;
|
||||||
given_name: string;
|
given_name: string;
|
||||||
family_name: string;
|
family_name: string;
|
||||||
email: string;
|
email: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
price_id: string;
|
price_id: string;
|
||||||
}
|
}
|
||||||
export interface JwtPayload {
|
export interface JwtPayload {
|
||||||
sub: string;
|
sub: string;
|
||||||
preferred_username: string;
|
preferred_username: string;
|
||||||
realm_access?: {
|
realm_access?: {
|
||||||
roles?: string[];
|
roles?: string[];
|
||||||
};
|
};
|
||||||
[key: string]: any; // für andere optionale Felder im JWT-Payload
|
[key: string]: any; // für andere optionale Felder im JWT-Payload
|
||||||
}
|
}
|
||||||
interface Resourceaccess {
|
interface Resourceaccess {
|
||||||
account: Realmaccess;
|
account: Realmaccess;
|
||||||
}
|
}
|
||||||
interface Realmaccess {
|
interface Realmaccess {
|
||||||
roles: string[];
|
roles: string[];
|
||||||
}
|
}
|
||||||
export interface PageEvent {
|
export interface PageEvent {
|
||||||
first: number;
|
first: number;
|
||||||
rows: number;
|
rows: number;
|
||||||
page: number;
|
page: number;
|
||||||
pageCount: number;
|
pageCount: number;
|
||||||
}
|
}
|
||||||
export interface AutoCompleteCompleteEvent {
|
export interface AutoCompleteCompleteEvent {
|
||||||
originalEvent: Event;
|
originalEvent: Event;
|
||||||
query: string;
|
query: string;
|
||||||
}
|
}
|
||||||
export interface MailInfo {
|
export interface MailInfo {
|
||||||
sender: Sender;
|
sender: Sender;
|
||||||
email: string;
|
email: string;
|
||||||
url: string;
|
url: string;
|
||||||
listing?: BusinessListing;
|
listing?: BusinessListing;
|
||||||
}
|
}
|
||||||
// export interface Sender {
|
// export interface Sender {
|
||||||
// name?: string;
|
// name?: string;
|
||||||
// email?: string;
|
// email?: string;
|
||||||
// phoneNumber?: string;
|
// phoneNumber?: string;
|
||||||
// state?: string;
|
// state?: string;
|
||||||
// comments?: string;
|
// comments?: string;
|
||||||
// }
|
// }
|
||||||
export interface ImageProperty {
|
export interface ImageProperty {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
export interface ErrorResponse {
|
export interface ErrorResponse {
|
||||||
fields?: FieldError[];
|
fields?: FieldError[];
|
||||||
general?: string[];
|
general?: string[];
|
||||||
}
|
}
|
||||||
export interface FieldError {
|
export interface FieldError {
|
||||||
fieldname: string;
|
fieldname: string;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
export interface UploadParams {
|
export interface UploadParams {
|
||||||
type: 'uploadPropertyPicture' | 'uploadCompanyLogo' | 'uploadProfile';
|
type: 'uploadPropertyPicture' | 'uploadCompanyLogo' | 'uploadProfile';
|
||||||
imagePath: string;
|
imagePath: string;
|
||||||
serialId?: number;
|
serialId?: number;
|
||||||
}
|
}
|
||||||
export interface GeoResult {
|
export interface GeoResult {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
street?: string;
|
street?: string;
|
||||||
housenumber?: string;
|
housenumber?: string;
|
||||||
county?: string;
|
county?: string;
|
||||||
zipCode?: number;
|
zipCode?: number;
|
||||||
state: string;
|
state: string;
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
}
|
}
|
||||||
interface CityResult {
|
interface CityResult {
|
||||||
id: number;
|
id: number;
|
||||||
type: 'city';
|
type: 'city';
|
||||||
content: GeoResult;
|
content: GeoResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StateResult {
|
interface StateResult {
|
||||||
id: number;
|
id: number;
|
||||||
type: 'state';
|
type: 'state';
|
||||||
content: State;
|
content: State;
|
||||||
}
|
}
|
||||||
export type CityAndStateResult = CityResult | StateResult;
|
export type CityAndStateResult = CityResult | StateResult;
|
||||||
export interface CountyResult {
|
export interface CountyResult {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
state: string;
|
state: string;
|
||||||
state_code: string;
|
state_code: string;
|
||||||
}
|
}
|
||||||
export interface LogMessage {
|
export interface LogMessage {
|
||||||
severity: 'error' | 'info';
|
severity: 'error' | 'info';
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
export interface ModalResult {
|
export interface ModalResult {
|
||||||
accepted: boolean;
|
accepted: boolean;
|
||||||
criteria?: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
|
criteria?: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
|
||||||
}
|
}
|
||||||
export interface Checkout {
|
export interface Checkout {
|
||||||
priceId: string;
|
priceId: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
export type UserRole = 'admin' | 'pro' | 'guest' | null;
|
export type UserRole = 'admin' | 'pro' | 'guest' | null;
|
||||||
export interface FirebaseUserInfo {
|
export interface FirebaseUserInfo {
|
||||||
uid: string;
|
uid: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
photoURL: string | null;
|
photoURL: string | null;
|
||||||
phoneNumber: string | null;
|
phoneNumber: string | null;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
emailVerified: boolean;
|
emailVerified: boolean;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
creationTime?: string;
|
creationTime?: string;
|
||||||
lastSignInTime?: string;
|
lastSignInTime?: string;
|
||||||
customClaims?: Record<string, any>;
|
customClaims?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsersResponse {
|
export interface UsersResponse {
|
||||||
users: FirebaseUserInfo[];
|
users: FirebaseUserInfo[];
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
pageToken?: string;
|
pageToken?: string;
|
||||||
}
|
}
|
||||||
export function isEmpty(value: any): boolean {
|
export function isEmpty(value: any): boolean {
|
||||||
// Check for undefined or null
|
// Check for undefined or null
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for empty string or string with only whitespace
|
// Check for empty string or string with only whitespace
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
return value.trim().length === 0;
|
return value.trim().length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for number and NaN
|
// Check for number and NaN
|
||||||
if (typeof value === 'number') {
|
if (typeof value === 'number') {
|
||||||
return isNaN(value);
|
return isNaN(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it's not a string or number, it's not considered empty by this function
|
// If it's not a string or number, it's not considered empty by this function
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
export function emailToDirName(email: string): string {
|
export function emailToDirName(email: string): string {
|
||||||
if (email === undefined || email === null) {
|
if (email === undefined || email === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// Entferne ungültige Zeichen und ersetze sie durch Unterstriche
|
// Entferne ungültige Zeichen und ersetze sie durch Unterstriche
|
||||||
const sanitizedEmail = email.replace(/[^a-zA-Z0-9_-]/g, '_');
|
const sanitizedEmail = email.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
|
|
||||||
// Entferne führende und nachfolgende Unterstriche
|
// Entferne führende und nachfolgende Unterstriche
|
||||||
const trimmedEmail = sanitizedEmail.replace(/^_+|_+$/g, '');
|
const trimmedEmail = sanitizedEmail.replace(/^_+|_+$/g, '');
|
||||||
|
|
||||||
// Ersetze mehrfache aufeinanderfolgende Unterstriche durch einen einzelnen Unterstrich
|
// Ersetze mehrfache aufeinanderfolgende Unterstriche durch einen einzelnen Unterstrich
|
||||||
const normalizedEmail = trimmedEmail.replace(/_+/g, '_');
|
const normalizedEmail = trimmedEmail.replace(/_+/g, '_');
|
||||||
|
|
||||||
return normalizedEmail;
|
return normalizedEmail;
|
||||||
}
|
}
|
||||||
export const LISTINGS_PER_PAGE = 12;
|
export const LISTINGS_PER_PAGE = 12;
|
||||||
export interface ValidationMessage {
|
export interface ValidationMessage {
|
||||||
field: string;
|
field: string;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
export function createDefaultUser(email: string, firstname: string, lastname: string, subscriptionPlan: 'professional' | 'broker'): User {
|
export function createDefaultUser(email: string, firstname: string, lastname: string, subscriptionPlan: 'professional' | 'broker'): User {
|
||||||
return {
|
return {
|
||||||
id: undefined,
|
id: undefined,
|
||||||
email,
|
email,
|
||||||
firstname,
|
firstname,
|
||||||
lastname,
|
lastname,
|
||||||
phoneNumber: null,
|
phoneNumber: null,
|
||||||
description: null,
|
description: null,
|
||||||
companyName: null,
|
companyName: null,
|
||||||
companyOverview: null,
|
companyOverview: null,
|
||||||
companyWebsite: null,
|
companyWebsite: null,
|
||||||
location: null,
|
location: null,
|
||||||
offeredServices: null,
|
offeredServices: null,
|
||||||
areasServed: [],
|
areasServed: [],
|
||||||
hasProfile: false,
|
hasProfile: false,
|
||||||
hasCompanyLogo: false,
|
hasCompanyLogo: false,
|
||||||
licensedIn: [],
|
licensedIn: [],
|
||||||
gender: null,
|
gender: null,
|
||||||
customerType: 'buyer',
|
customerType: 'buyer',
|
||||||
customerSubType: null,
|
customerSubType: null,
|
||||||
created: new Date(),
|
created: new Date(),
|
||||||
updated: new Date(),
|
updated: new Date(),
|
||||||
subscriptionId: null,
|
subscriptionId: null,
|
||||||
subscriptionPlan: subscriptionPlan,
|
subscriptionPlan: subscriptionPlan,
|
||||||
favoritesForUser: [],
|
favoritesForUser: [],
|
||||||
showInDirectory: false,
|
showInDirectory: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export function createDefaultCommercialPropertyListing(): CommercialPropertyListing {
|
export function createDefaultCommercialPropertyListing(): CommercialPropertyListing {
|
||||||
return {
|
return {
|
||||||
id: undefined,
|
id: undefined,
|
||||||
serialId: undefined,
|
serialId: undefined,
|
||||||
email: null,
|
email: null,
|
||||||
type: null,
|
type: null,
|
||||||
title: null,
|
title: null,
|
||||||
description: null,
|
description: null,
|
||||||
location: null,
|
location: null,
|
||||||
price: null,
|
price: null,
|
||||||
favoritesForUser: [],
|
favoritesForUser: [],
|
||||||
draft: false,
|
draft: false,
|
||||||
imageOrder: [],
|
imageOrder: [],
|
||||||
imagePath: null,
|
imagePath: null,
|
||||||
created: null,
|
created: null,
|
||||||
updated: null,
|
updated: null,
|
||||||
listingsCategory: 'commercialProperty',
|
listingsCategory: 'commercialProperty',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export function createDefaultBusinessListing(): BusinessListing {
|
export function createDefaultBusinessListing(): BusinessListing {
|
||||||
return {
|
return {
|
||||||
id: undefined,
|
id: undefined,
|
||||||
email: null,
|
email: null,
|
||||||
type: null,
|
type: null,
|
||||||
title: null,
|
title: null,
|
||||||
description: null,
|
description: null,
|
||||||
location: null,
|
location: null,
|
||||||
price: null,
|
price: null,
|
||||||
favoritesForUser: [],
|
favoritesForUser: [],
|
||||||
draft: false,
|
draft: false,
|
||||||
realEstateIncluded: false,
|
realEstateIncluded: false,
|
||||||
leasedLocation: false,
|
leasedLocation: false,
|
||||||
franchiseResale: false,
|
franchiseResale: false,
|
||||||
salesRevenue: null,
|
salesRevenue: null,
|
||||||
cashFlow: null,
|
cashFlow: null,
|
||||||
supportAndTraining: null,
|
supportAndTraining: null,
|
||||||
employees: null,
|
employees: null,
|
||||||
established: null,
|
established: null,
|
||||||
internalListingNumber: null,
|
internalListingNumber: null,
|
||||||
reasonForSale: null,
|
reasonForSale: null,
|
||||||
brokerLicencing: null,
|
brokerLicencing: null,
|
||||||
internals: null,
|
internals: null,
|
||||||
created: null,
|
created: null,
|
||||||
updated: null,
|
updated: null,
|
||||||
listingsCategory: 'business',
|
listingsCategory: 'business',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export type IpInfo = {
|
export type IpInfo = {
|
||||||
ip: string;
|
ip: string;
|
||||||
city: string;
|
city: string;
|
||||||
region: string;
|
region: string;
|
||||||
country: string;
|
country: string;
|
||||||
loc: string; // Coordinates in "latitude,longitude" format
|
loc: string; // Coordinates in "latitude,longitude" format
|
||||||
org: string;
|
org: string;
|
||||||
postal: string;
|
postal: string;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
};
|
};
|
||||||
export interface CombinedUser {
|
export interface CombinedUser {
|
||||||
keycloakUser?: KeycloakUser;
|
keycloakUser?: KeycloakUser;
|
||||||
appUser?: User;
|
appUser?: User;
|
||||||
}
|
}
|
||||||
export interface RealIpInfo {
|
export interface RealIpInfo {
|
||||||
ip: string;
|
ip: string;
|
||||||
countryCode?: string;
|
countryCode?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,62 +1,62 @@
|
||||||
import { Controller, Get, Header, Param, ParseIntPipe } from '@nestjs/common';
|
import { Controller, Get, Header, Param, ParseIntPipe } from '@nestjs/common';
|
||||||
import { SitemapService } from './sitemap.service';
|
import { SitemapService } from './sitemap.service';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class SitemapController {
|
export class SitemapController {
|
||||||
constructor(private readonly sitemapService: SitemapService) { }
|
constructor(private readonly sitemapService: SitemapService) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main sitemap index - lists all sitemap files
|
* Main sitemap index - lists all sitemap files
|
||||||
* Route: /sitemap.xml
|
* Route: /sitemap.xml
|
||||||
*/
|
*/
|
||||||
@Get('sitemap.xml')
|
@Get('sitemap.xml')
|
||||||
@Header('Content-Type', 'application/xml')
|
@Header('Content-Type', 'application/xml')
|
||||||
@Header('Cache-Control', 'public, max-age=3600')
|
@Header('Cache-Control', 'public, max-age=3600')
|
||||||
async getSitemapIndex(): Promise<string> {
|
async getSitemapIndex(): Promise<string> {
|
||||||
return await this.sitemapService.generateSitemapIndex();
|
return await this.sitemapService.generateSitemapIndex();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Static pages sitemap
|
* Static pages sitemap
|
||||||
* Route: /sitemap/static.xml
|
* Route: /sitemap/static.xml
|
||||||
*/
|
*/
|
||||||
@Get('sitemap/static.xml')
|
@Get('sitemap/static.xml')
|
||||||
@Header('Content-Type', 'application/xml')
|
@Header('Content-Type', 'application/xml')
|
||||||
@Header('Cache-Control', 'public, max-age=3600')
|
@Header('Cache-Control', 'public, max-age=3600')
|
||||||
async getStaticSitemap(): Promise<string> {
|
async getStaticSitemap(): Promise<string> {
|
||||||
return await this.sitemapService.generateStaticSitemap();
|
return await this.sitemapService.generateStaticSitemap();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Business listings sitemap (paginated)
|
* Business listings sitemap (paginated)
|
||||||
* Route: /sitemap/business-1.xml, /sitemap/business-2.xml, etc.
|
* Route: /sitemap/business-1.xml, /sitemap/business-2.xml, etc.
|
||||||
*/
|
*/
|
||||||
@Get('sitemap/business-:page.xml')
|
@Get('sitemap/business-:page.xml')
|
||||||
@Header('Content-Type', 'application/xml')
|
@Header('Content-Type', 'application/xml')
|
||||||
@Header('Cache-Control', 'public, max-age=3600')
|
@Header('Cache-Control', 'public, max-age=3600')
|
||||||
async getBusinessSitemap(@Param('page', ParseIntPipe) page: number): Promise<string> {
|
async getBusinessSitemap(@Param('page', ParseIntPipe) page: number): Promise<string> {
|
||||||
return await this.sitemapService.generateBusinessSitemap(page);
|
return await this.sitemapService.generateBusinessSitemap(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Commercial property sitemap (paginated)
|
* Commercial property sitemap (paginated)
|
||||||
* Route: /sitemap/commercial-1.xml, /sitemap/commercial-2.xml, etc.
|
* Route: /sitemap/commercial-1.xml, /sitemap/commercial-2.xml, etc.
|
||||||
*/
|
*/
|
||||||
@Get('sitemap/commercial-:page.xml')
|
@Get('sitemap/commercial-:page.xml')
|
||||||
@Header('Content-Type', 'application/xml')
|
@Header('Content-Type', 'application/xml')
|
||||||
@Header('Cache-Control', 'public, max-age=3600')
|
@Header('Cache-Control', 'public, max-age=3600')
|
||||||
async getCommercialSitemap(@Param('page', ParseIntPipe) page: number): Promise<string> {
|
async getCommercialSitemap(@Param('page', ParseIntPipe) page: number): Promise<string> {
|
||||||
return await this.sitemapService.generateCommercialSitemap(page);
|
return await this.sitemapService.generateCommercialSitemap(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Broker profiles sitemap (paginated)
|
* Broker profiles sitemap (paginated)
|
||||||
* Route: /sitemap/brokers-1.xml, /sitemap/brokers-2.xml, etc.
|
* Route: /sitemap/brokers-1.xml, /sitemap/brokers-2.xml, etc.
|
||||||
*/
|
*/
|
||||||
@Get('sitemap/brokers-:page.xml')
|
@Get('sitemap/brokers-:page.xml')
|
||||||
@Header('Content-Type', 'application/xml')
|
@Header('Content-Type', 'application/xml')
|
||||||
@Header('Cache-Control', 'public, max-age=3600')
|
@Header('Cache-Control', 'public, max-age=3600')
|
||||||
async getBrokerSitemap(@Param('page', ParseIntPipe) page: number): Promise<string> {
|
async getBrokerSitemap(@Param('page', ParseIntPipe) page: number): Promise<string> {
|
||||||
return await this.sitemapService.generateBrokerSitemap(page);
|
return await this.sitemapService.generateBrokerSitemap(page);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { SitemapController } from './sitemap.controller';
|
import { SitemapController } from './sitemap.controller';
|
||||||
import { SitemapService } from './sitemap.service';
|
import { SitemapService } from './sitemap.service';
|
||||||
import { DrizzleModule } from '../drizzle/drizzle.module';
|
import { DrizzleModule } from '../drizzle/drizzle.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DrizzleModule],
|
imports: [DrizzleModule],
|
||||||
controllers: [SitemapController],
|
controllers: [SitemapController],
|
||||||
providers: [SitemapService],
|
providers: [SitemapService],
|
||||||
exports: [SitemapService],
|
exports: [SitemapService],
|
||||||
})
|
})
|
||||||
export class SitemapModule {}
|
export class SitemapModule {}
|
||||||
|
|
|
||||||
|
|
@ -1,362 +1,362 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { eq, sql } from 'drizzle-orm';
|
import { eq, sql } from 'drizzle-orm';
|
||||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||||
import * as schema from '../drizzle/schema';
|
import * as schema from '../drizzle/schema';
|
||||||
import { PG_CONNECTION } from '../drizzle/schema';
|
import { PG_CONNECTION } from '../drizzle/schema';
|
||||||
|
|
||||||
interface SitemapUrl {
|
interface SitemapUrl {
|
||||||
loc: string;
|
loc: string;
|
||||||
lastmod?: string;
|
lastmod?: string;
|
||||||
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
||||||
priority?: number;
|
priority?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SitemapIndexEntry {
|
interface SitemapIndexEntry {
|
||||||
loc: string;
|
loc: string;
|
||||||
lastmod?: string;
|
lastmod?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SitemapService {
|
export class SitemapService {
|
||||||
private readonly baseUrl = 'https://biz-match.com';
|
private readonly baseUrl = 'https://biz-match.com';
|
||||||
private readonly URLS_PER_SITEMAP = 10000; // Google best practice
|
private readonly URLS_PER_SITEMAP = 10000; // Google best practice
|
||||||
|
|
||||||
constructor(@Inject(PG_CONNECTION) private readonly db: NodePgDatabase<typeof schema>) { }
|
constructor(@Inject(PG_CONNECTION) private readonly db: NodePgDatabase<typeof schema>) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate sitemap index (main sitemap.xml)
|
* Generate sitemap index (main sitemap.xml)
|
||||||
* Lists all sitemap files: static, business-1, business-2, commercial-1, etc.
|
* Lists all sitemap files: static, business-1, business-2, commercial-1, etc.
|
||||||
*/
|
*/
|
||||||
async generateSitemapIndex(): Promise<string> {
|
async generateSitemapIndex(): Promise<string> {
|
||||||
const sitemaps: SitemapIndexEntry[] = [];
|
const sitemaps: SitemapIndexEntry[] = [];
|
||||||
|
|
||||||
// Add static pages sitemap
|
// Add static pages sitemap
|
||||||
sitemaps.push({
|
sitemaps.push({
|
||||||
loc: `${this.baseUrl}/bizmatch/sitemap/static.xml`,
|
loc: `${this.baseUrl}/bizmatch/sitemap/static.xml`,
|
||||||
lastmod: this.formatDate(new Date()),
|
lastmod: this.formatDate(new Date()),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Count business listings
|
// Count business listings
|
||||||
const businessCount = await this.getBusinessListingsCount();
|
const businessCount = await this.getBusinessListingsCount();
|
||||||
const businessPages = Math.ceil(businessCount / this.URLS_PER_SITEMAP) || 1;
|
const businessPages = Math.ceil(businessCount / this.URLS_PER_SITEMAP) || 1;
|
||||||
for (let page = 1; page <= businessPages; page++) {
|
for (let page = 1; page <= businessPages; page++) {
|
||||||
sitemaps.push({
|
sitemaps.push({
|
||||||
loc: `${this.baseUrl}/bizmatch/sitemap/business-${page}.xml`,
|
loc: `${this.baseUrl}/bizmatch/sitemap/business-${page}.xml`,
|
||||||
lastmod: this.formatDate(new Date()),
|
lastmod: this.formatDate(new Date()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count commercial property listings
|
// Count commercial property listings
|
||||||
const commercialCount = await this.getCommercialPropertiesCount();
|
const commercialCount = await this.getCommercialPropertiesCount();
|
||||||
const commercialPages = Math.ceil(commercialCount / this.URLS_PER_SITEMAP) || 1;
|
const commercialPages = Math.ceil(commercialCount / this.URLS_PER_SITEMAP) || 1;
|
||||||
for (let page = 1; page <= commercialPages; page++) {
|
for (let page = 1; page <= commercialPages; page++) {
|
||||||
sitemaps.push({
|
sitemaps.push({
|
||||||
loc: `${this.baseUrl}/bizmatch/sitemap/commercial-${page}.xml`,
|
loc: `${this.baseUrl}/bizmatch/sitemap/commercial-${page}.xml`,
|
||||||
lastmod: this.formatDate(new Date()),
|
lastmod: this.formatDate(new Date()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count broker profiles
|
// Count broker profiles
|
||||||
const brokerCount = await this.getBrokerProfilesCount();
|
const brokerCount = await this.getBrokerProfilesCount();
|
||||||
const brokerPages = Math.ceil(brokerCount / this.URLS_PER_SITEMAP) || 1;
|
const brokerPages = Math.ceil(brokerCount / this.URLS_PER_SITEMAP) || 1;
|
||||||
for (let page = 1; page <= brokerPages; page++) {
|
for (let page = 1; page <= brokerPages; page++) {
|
||||||
sitemaps.push({
|
sitemaps.push({
|
||||||
loc: `${this.baseUrl}/bizmatch/sitemap/brokers-${page}.xml`,
|
loc: `${this.baseUrl}/bizmatch/sitemap/brokers-${page}.xml`,
|
||||||
lastmod: this.formatDate(new Date()),
|
lastmod: this.formatDate(new Date()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.buildXmlSitemapIndex(sitemaps);
|
return this.buildXmlSitemapIndex(sitemaps);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate static pages sitemap
|
* Generate static pages sitemap
|
||||||
*/
|
*/
|
||||||
async generateStaticSitemap(): Promise<string> {
|
async generateStaticSitemap(): Promise<string> {
|
||||||
const urls = this.getStaticPageUrls();
|
const urls = this.getStaticPageUrls();
|
||||||
return this.buildXmlSitemap(urls);
|
return this.buildXmlSitemap(urls);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate business listings sitemap (paginated)
|
* Generate business listings sitemap (paginated)
|
||||||
*/
|
*/
|
||||||
async generateBusinessSitemap(page: number): Promise<string> {
|
async generateBusinessSitemap(page: number): Promise<string> {
|
||||||
const offset = (page - 1) * this.URLS_PER_SITEMAP;
|
const offset = (page - 1) * this.URLS_PER_SITEMAP;
|
||||||
const urls = await this.getBusinessListingUrls(offset, this.URLS_PER_SITEMAP);
|
const urls = await this.getBusinessListingUrls(offset, this.URLS_PER_SITEMAP);
|
||||||
return this.buildXmlSitemap(urls);
|
return this.buildXmlSitemap(urls);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate commercial property sitemap (paginated)
|
* Generate commercial property sitemap (paginated)
|
||||||
*/
|
*/
|
||||||
async generateCommercialSitemap(page: number): Promise<string> {
|
async generateCommercialSitemap(page: number): Promise<string> {
|
||||||
const offset = (page - 1) * this.URLS_PER_SITEMAP;
|
const offset = (page - 1) * this.URLS_PER_SITEMAP;
|
||||||
const urls = await this.getCommercialPropertyUrls(offset, this.URLS_PER_SITEMAP);
|
const urls = await this.getCommercialPropertyUrls(offset, this.URLS_PER_SITEMAP);
|
||||||
return this.buildXmlSitemap(urls);
|
return this.buildXmlSitemap(urls);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build XML sitemap index
|
* Build XML sitemap index
|
||||||
*/
|
*/
|
||||||
private buildXmlSitemapIndex(sitemaps: SitemapIndexEntry[]): string {
|
private buildXmlSitemapIndex(sitemaps: SitemapIndexEntry[]): string {
|
||||||
const sitemapElements = sitemaps
|
const sitemapElements = sitemaps
|
||||||
.map(sitemap => {
|
.map(sitemap => {
|
||||||
let element = ` <sitemap>\n <loc>${sitemap.loc}</loc>`;
|
let element = ` <sitemap>\n <loc>${sitemap.loc}</loc>`;
|
||||||
if (sitemap.lastmod) {
|
if (sitemap.lastmod) {
|
||||||
element += `\n <lastmod>${sitemap.lastmod}</lastmod>`;
|
element += `\n <lastmod>${sitemap.lastmod}</lastmod>`;
|
||||||
}
|
}
|
||||||
element += '\n </sitemap>';
|
element += '\n </sitemap>';
|
||||||
return element;
|
return element;
|
||||||
})
|
})
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
${sitemapElements}
|
${sitemapElements}
|
||||||
</sitemapindex>`;
|
</sitemapindex>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build XML sitemap string
|
* Build XML sitemap string
|
||||||
*/
|
*/
|
||||||
private buildXmlSitemap(urls: SitemapUrl[]): string {
|
private buildXmlSitemap(urls: SitemapUrl[]): string {
|
||||||
const urlElements = urls.map(url => this.buildUrlElement(url)).join('\n ');
|
const urlElements = urls.map(url => this.buildUrlElement(url)).join('\n ');
|
||||||
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
${urlElements}
|
${urlElements}
|
||||||
</urlset>`;
|
</urlset>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build single URL element
|
* Build single URL element
|
||||||
*/
|
*/
|
||||||
private buildUrlElement(url: SitemapUrl): string {
|
private buildUrlElement(url: SitemapUrl): string {
|
||||||
let element = `<url>\n <loc>${url.loc}</loc>`;
|
let element = `<url>\n <loc>${url.loc}</loc>`;
|
||||||
|
|
||||||
if (url.lastmod) {
|
if (url.lastmod) {
|
||||||
element += `\n <lastmod>${url.lastmod}</lastmod>`;
|
element += `\n <lastmod>${url.lastmod}</lastmod>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.changefreq) {
|
if (url.changefreq) {
|
||||||
element += `\n <changefreq>${url.changefreq}</changefreq>`;
|
element += `\n <changefreq>${url.changefreq}</changefreq>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.priority !== undefined) {
|
if (url.priority !== undefined) {
|
||||||
element += `\n <priority>${url.priority.toFixed(1)}</priority>`;
|
element += `\n <priority>${url.priority.toFixed(1)}</priority>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
element += '\n </url>';
|
element += '\n </url>';
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get static page URLs
|
* Get static page URLs
|
||||||
*/
|
*/
|
||||||
private getStaticPageUrls(): SitemapUrl[] {
|
private getStaticPageUrls(): SitemapUrl[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
loc: `${this.baseUrl}/`,
|
loc: `${this.baseUrl}/`,
|
||||||
changefreq: 'daily',
|
changefreq: 'daily',
|
||||||
priority: 1.0,
|
priority: 1.0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
loc: `${this.baseUrl}/home`,
|
loc: `${this.baseUrl}/home`,
|
||||||
changefreq: 'daily',
|
changefreq: 'daily',
|
||||||
priority: 1.0,
|
priority: 1.0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
loc: `${this.baseUrl}/businessListings`,
|
loc: `${this.baseUrl}/businessListings`,
|
||||||
changefreq: 'daily',
|
changefreq: 'daily',
|
||||||
priority: 0.9,
|
priority: 0.9,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
loc: `${this.baseUrl}/commercialPropertyListings`,
|
loc: `${this.baseUrl}/commercialPropertyListings`,
|
||||||
changefreq: 'daily',
|
changefreq: 'daily',
|
||||||
priority: 0.9,
|
priority: 0.9,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
loc: `${this.baseUrl}/brokerListings`,
|
loc: `${this.baseUrl}/brokerListings`,
|
||||||
changefreq: 'daily',
|
changefreq: 'daily',
|
||||||
priority: 0.8,
|
priority: 0.8,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
loc: `${this.baseUrl}/terms-of-use`,
|
loc: `${this.baseUrl}/terms-of-use`,
|
||||||
changefreq: 'monthly',
|
changefreq: 'monthly',
|
||||||
priority: 0.5,
|
priority: 0.5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
loc: `${this.baseUrl}/privacy-statement`,
|
loc: `${this.baseUrl}/privacy-statement`,
|
||||||
changefreq: 'monthly',
|
changefreq: 'monthly',
|
||||||
priority: 0.5,
|
priority: 0.5,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Count business listings (non-draft)
|
* Count business listings (non-draft)
|
||||||
*/
|
*/
|
||||||
private async getBusinessListingsCount(): Promise<number> {
|
private async getBusinessListingsCount(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const result = await this.db
|
const result = await this.db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(schema.businesses_json)
|
.from(schema.businesses_json)
|
||||||
.where(sql`(${schema.businesses_json.data}->>'draft')::boolean IS NOT TRUE`);
|
.where(sql`(${schema.businesses_json.data}->>'draft')::boolean IS NOT TRUE`);
|
||||||
|
|
||||||
return Number(result[0]?.count || 0);
|
return Number(result[0]?.count || 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error counting business listings:', error);
|
console.error('Error counting business listings:', error);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Count commercial properties (non-draft)
|
* Count commercial properties (non-draft)
|
||||||
*/
|
*/
|
||||||
private async getCommercialPropertiesCount(): Promise<number> {
|
private async getCommercialPropertiesCount(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const result = await this.db
|
const result = await this.db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(schema.commercials_json)
|
.from(schema.commercials_json)
|
||||||
.where(sql`(${schema.commercials_json.data}->>'draft')::boolean IS NOT TRUE`);
|
.where(sql`(${schema.commercials_json.data}->>'draft')::boolean IS NOT TRUE`);
|
||||||
|
|
||||||
return Number(result[0]?.count || 0);
|
return Number(result[0]?.count || 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error counting commercial properties:', error);
|
console.error('Error counting commercial properties:', error);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get business listing URLs from database (paginated, slug-based)
|
* Get business listing URLs from database (paginated, slug-based)
|
||||||
*/
|
*/
|
||||||
private async getBusinessListingUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
|
private async getBusinessListingUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
|
||||||
try {
|
try {
|
||||||
const listings = await this.db
|
const listings = await this.db
|
||||||
.select({
|
.select({
|
||||||
id: schema.businesses_json.id,
|
id: schema.businesses_json.id,
|
||||||
slug: sql<string>`${schema.businesses_json.data}->>'slug'`,
|
slug: sql<string>`${schema.businesses_json.data}->>'slug'`,
|
||||||
updated: sql<Date>`(${schema.businesses_json.data}->>'updated')::timestamptz`,
|
updated: sql<Date>`(${schema.businesses_json.data}->>'updated')::timestamptz`,
|
||||||
created: sql<Date>`(${schema.businesses_json.data}->>'created')::timestamptz`,
|
created: sql<Date>`(${schema.businesses_json.data}->>'created')::timestamptz`,
|
||||||
})
|
})
|
||||||
.from(schema.businesses_json)
|
.from(schema.businesses_json)
|
||||||
.where(sql`(${schema.businesses_json.data}->>'draft')::boolean IS NOT TRUE`)
|
.where(sql`(${schema.businesses_json.data}->>'draft')::boolean IS NOT TRUE`)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset);
|
.offset(offset);
|
||||||
|
|
||||||
return listings.map(listing => {
|
return listings.map(listing => {
|
||||||
const urlSlug = listing.slug || listing.id;
|
const urlSlug = listing.slug || listing.id;
|
||||||
return {
|
return {
|
||||||
loc: `${this.baseUrl}/business/${urlSlug}`,
|
loc: `${this.baseUrl}/business/${urlSlug}`,
|
||||||
lastmod: this.formatDate(listing.updated || listing.created),
|
lastmod: this.formatDate(listing.updated || listing.created),
|
||||||
changefreq: 'weekly' as const,
|
changefreq: 'weekly' as const,
|
||||||
priority: 0.8,
|
priority: 0.8,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching business listings for sitemap:', error);
|
console.error('Error fetching business listings for sitemap:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get commercial property URLs from database (paginated, slug-based)
|
* Get commercial property URLs from database (paginated, slug-based)
|
||||||
*/
|
*/
|
||||||
private async getCommercialPropertyUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
|
private async getCommercialPropertyUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
|
||||||
try {
|
try {
|
||||||
const properties = await this.db
|
const properties = await this.db
|
||||||
.select({
|
.select({
|
||||||
id: schema.commercials_json.id,
|
id: schema.commercials_json.id,
|
||||||
slug: sql<string>`${schema.commercials_json.data}->>'slug'`,
|
slug: sql<string>`${schema.commercials_json.data}->>'slug'`,
|
||||||
updated: sql<Date>`(${schema.commercials_json.data}->>'updated')::timestamptz`,
|
updated: sql<Date>`(${schema.commercials_json.data}->>'updated')::timestamptz`,
|
||||||
created: sql<Date>`(${schema.commercials_json.data}->>'created')::timestamptz`,
|
created: sql<Date>`(${schema.commercials_json.data}->>'created')::timestamptz`,
|
||||||
})
|
})
|
||||||
.from(schema.commercials_json)
|
.from(schema.commercials_json)
|
||||||
.where(sql`(${schema.commercials_json.data}->>'draft')::boolean IS NOT TRUE`)
|
.where(sql`(${schema.commercials_json.data}->>'draft')::boolean IS NOT TRUE`)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset);
|
.offset(offset);
|
||||||
|
|
||||||
return properties.map(property => {
|
return properties.map(property => {
|
||||||
const urlSlug = property.slug || property.id;
|
const urlSlug = property.slug || property.id;
|
||||||
return {
|
return {
|
||||||
loc: `${this.baseUrl}/commercial-property/${urlSlug}`,
|
loc: `${this.baseUrl}/commercial-property/${urlSlug}`,
|
||||||
lastmod: this.formatDate(property.updated || property.created),
|
lastmod: this.formatDate(property.updated || property.created),
|
||||||
changefreq: 'weekly' as const,
|
changefreq: 'weekly' as const,
|
||||||
priority: 0.8,
|
priority: 0.8,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching commercial properties for sitemap:', error);
|
console.error('Error fetching commercial properties for sitemap:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format date to ISO 8601 format (YYYY-MM-DD)
|
* Format date to ISO 8601 format (YYYY-MM-DD)
|
||||||
*/
|
*/
|
||||||
private formatDate(date: Date | string): string {
|
private formatDate(date: Date | string): string {
|
||||||
if (!date) return new Date().toISOString().split('T')[0];
|
if (!date) return new Date().toISOString().split('T')[0];
|
||||||
const d = typeof date === 'string' ? new Date(date) : date;
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
return d.toISOString().split('T')[0];
|
return d.toISOString().split('T')[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate broker profiles sitemap (paginated)
|
* Generate broker profiles sitemap (paginated)
|
||||||
*/
|
*/
|
||||||
async generateBrokerSitemap(page: number): Promise<string> {
|
async generateBrokerSitemap(page: number): Promise<string> {
|
||||||
const offset = (page - 1) * this.URLS_PER_SITEMAP;
|
const offset = (page - 1) * this.URLS_PER_SITEMAP;
|
||||||
const urls = await this.getBrokerProfileUrls(offset, this.URLS_PER_SITEMAP);
|
const urls = await this.getBrokerProfileUrls(offset, this.URLS_PER_SITEMAP);
|
||||||
return this.buildXmlSitemap(urls);
|
return this.buildXmlSitemap(urls);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Count broker profiles (professionals with showInDirectory=true)
|
* Count broker profiles (professionals with showInDirectory=true)
|
||||||
*/
|
*/
|
||||||
private async getBrokerProfilesCount(): Promise<number> {
|
private async getBrokerProfilesCount(): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const result = await this.db
|
const result = await this.db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(schema.users_json)
|
.from(schema.users_json)
|
||||||
.where(sql`
|
.where(sql`
|
||||||
(${schema.users_json.data}->>'customerType') = 'professional'
|
(${schema.users_json.data}->>'customerType') = 'professional'
|
||||||
AND (${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE
|
AND (${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE
|
||||||
`);
|
`);
|
||||||
|
|
||||||
return Number(result[0]?.count || 0);
|
return Number(result[0]?.count || 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error counting broker profiles:', error);
|
console.error('Error counting broker profiles:', error);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get broker profile URLs from database (paginated)
|
* Get broker profile URLs from database (paginated)
|
||||||
*/
|
*/
|
||||||
private async getBrokerProfileUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
|
private async getBrokerProfileUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
|
||||||
try {
|
try {
|
||||||
const brokers = await this.db
|
const brokers = await this.db
|
||||||
.select({
|
.select({
|
||||||
email: schema.users_json.email,
|
email: schema.users_json.email,
|
||||||
updated: sql<Date>`(${schema.users_json.data}->>'updated')::timestamptz`,
|
updated: sql<Date>`(${schema.users_json.data}->>'updated')::timestamptz`,
|
||||||
created: sql<Date>`(${schema.users_json.data}->>'created')::timestamptz`,
|
created: sql<Date>`(${schema.users_json.data}->>'created')::timestamptz`,
|
||||||
})
|
})
|
||||||
.from(schema.users_json)
|
.from(schema.users_json)
|
||||||
.where(sql`
|
.where(sql`
|
||||||
(${schema.users_json.data}->>'customerType') = 'professional'
|
(${schema.users_json.data}->>'customerType') = 'professional'
|
||||||
AND (${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE
|
AND (${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE
|
||||||
`)
|
`)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset);
|
.offset(offset);
|
||||||
|
|
||||||
return brokers.map(broker => ({
|
return brokers.map(broker => ({
|
||||||
loc: `${this.baseUrl}/details-user/${encodeURIComponent(broker.email)}`,
|
loc: `${this.baseUrl}/details-user/${encodeURIComponent(broker.email)}`,
|
||||||
lastmod: this.formatDate(broker.updated || broker.created),
|
lastmod: this.formatDate(broker.updated || broker.created),
|
||||||
changefreq: 'weekly' as const,
|
changefreq: 'weekly' as const,
|
||||||
priority: 0.7,
|
priority: 0.7,
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching broker profiles for sitemap:', error);
|
console.error('Error fetching broker profiles for sitemap:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,195 +1,195 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { and, asc, count, desc, eq, inArray, or, SQL, sql } from 'drizzle-orm';
|
import { and, asc, count, desc, eq, inArray, or, SQL, sql } from 'drizzle-orm';
|
||||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres/driver';
|
import { NodePgDatabase } from 'drizzle-orm/node-postgres/driver';
|
||||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||||
import { Logger } from 'winston';
|
import { Logger } from 'winston';
|
||||||
import * as schema from '../drizzle/schema';
|
import * as schema from '../drizzle/schema';
|
||||||
import { customerSubTypeEnum, PG_CONNECTION } from '../drizzle/schema';
|
import { customerSubTypeEnum, PG_CONNECTION } from '../drizzle/schema';
|
||||||
import { FileService } from '../file/file.service';
|
import { FileService } from '../file/file.service';
|
||||||
import { GeoService } from '../geo/geo.service';
|
import { GeoService } from '../geo/geo.service';
|
||||||
import { User, UserSchema } from '../models/db.model';
|
import { User, UserSchema } from '../models/db.model';
|
||||||
import { createDefaultUser, emailToDirName, JwtUser, UserListingCriteria } from '../models/main.model';
|
import { createDefaultUser, emailToDirName, JwtUser, UserListingCriteria } from '../models/main.model';
|
||||||
import { getDistanceQuery, splitName } from '../utils';
|
import { getDistanceQuery, splitName } from '../utils';
|
||||||
|
|
||||||
type CustomerSubType = (typeof customerSubTypeEnum.enumValues)[number];
|
type CustomerSubType = (typeof customerSubTypeEnum.enumValues)[number];
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
||||||
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
|
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
|
||||||
private fileService: FileService,
|
private fileService: FileService,
|
||||||
private geoService: GeoService,
|
private geoService: GeoService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
private getWhereConditions(criteria: UserListingCriteria): SQL[] {
|
private getWhereConditions(criteria: UserListingCriteria): SQL[] {
|
||||||
const whereConditions: SQL[] = [];
|
const whereConditions: SQL[] = [];
|
||||||
whereConditions.push(sql`(${schema.users_json.data}->>'customerType') = 'professional'`);
|
whereConditions.push(sql`(${schema.users_json.data}->>'customerType') = 'professional'`);
|
||||||
|
|
||||||
if (criteria.city && criteria.searchType === 'exact') {
|
if (criteria.city && criteria.searchType === 'exact') {
|
||||||
whereConditions.push(sql`(${schema.users_json.data}->'location'->>'name') ILIKE ${criteria.city.name}`);
|
whereConditions.push(sql`(${schema.users_json.data}->'location'->>'name') ILIKE ${criteria.city.name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) {
|
if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) {
|
||||||
const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name);
|
const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name);
|
||||||
const distanceQuery = getDistanceQuery(schema.users_json, cityGeo.latitude, cityGeo.longitude);
|
const distanceQuery = getDistanceQuery(schema.users_json, cityGeo.latitude, cityGeo.longitude);
|
||||||
whereConditions.push(sql`${distanceQuery} <= ${criteria.radius}`);
|
whereConditions.push(sql`${distanceQuery} <= ${criteria.radius}`);
|
||||||
}
|
}
|
||||||
if (criteria.types && criteria.types.length > 0) {
|
if (criteria.types && criteria.types.length > 0) {
|
||||||
// whereConditions.push(inArray(schema.users.customerSubType, criteria.types));
|
// whereConditions.push(inArray(schema.users.customerSubType, criteria.types));
|
||||||
whereConditions.push(inArray(sql`${schema.users_json.data}->>'customerSubType'`, criteria.types as CustomerSubType[]));
|
whereConditions.push(inArray(sql`${schema.users_json.data}->>'customerSubType'`, criteria.types as CustomerSubType[]));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.brokerName) {
|
if (criteria.brokerName) {
|
||||||
const { firstname, lastname } = splitName(criteria.brokerName);
|
const { firstname, lastname } = splitName(criteria.brokerName);
|
||||||
whereConditions.push(sql`(${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`}`);
|
whereConditions.push(sql`(${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.companyName) {
|
if (criteria.companyName) {
|
||||||
whereConditions.push(sql`(${schema.users_json.data}->>'companyName') ILIKE ${`%${criteria.companyName}%`}`);
|
whereConditions.push(sql`(${schema.users_json.data}->>'companyName') ILIKE ${`%${criteria.companyName}%`}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.counties && criteria.counties.length > 0) {
|
if (criteria.counties && criteria.counties.length > 0) {
|
||||||
whereConditions.push(or(...criteria.counties.map(county => sql`(${schema.users_json.data}->'location'->>'county') ILIKE ${`%${county}%`}`)));
|
whereConditions.push(or(...criteria.counties.map(county => sql`(${schema.users_json.data}->'location'->>'county') ILIKE ${`%${county}%`}`)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.state) {
|
if (criteria.state) {
|
||||||
whereConditions.push(sql`(${schema.users_json.data}->'location'->>'state') = ${criteria.state}`);
|
whereConditions.push(sql`(${schema.users_json.data}->'location'->>'state') = ${criteria.state}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
//never show user which denied
|
//never show user which denied
|
||||||
whereConditions.push(sql`(${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE`);
|
whereConditions.push(sql`(${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE`);
|
||||||
|
|
||||||
return whereConditions;
|
return whereConditions;
|
||||||
}
|
}
|
||||||
async searchUserListings(criteria: UserListingCriteria): Promise<{ results: User[]; totalCount: number }> {
|
async searchUserListings(criteria: UserListingCriteria): Promise<{ results: User[]; totalCount: number }> {
|
||||||
const start = criteria.start ? criteria.start : 0;
|
const start = criteria.start ? criteria.start : 0;
|
||||||
const length = criteria.length ? criteria.length : 12;
|
const length = criteria.length ? criteria.length : 12;
|
||||||
const query = this.conn.select().from(schema.users_json);
|
const query = this.conn.select().from(schema.users_json);
|
||||||
const whereConditions = this.getWhereConditions(criteria);
|
const whereConditions = this.getWhereConditions(criteria);
|
||||||
|
|
||||||
if (whereConditions.length > 0) {
|
if (whereConditions.length > 0) {
|
||||||
const whereClause = and(...whereConditions);
|
const whereClause = and(...whereConditions);
|
||||||
query.where(whereClause);
|
query.where(whereClause);
|
||||||
}
|
}
|
||||||
// Sortierung
|
// Sortierung
|
||||||
switch (criteria.sortBy) {
|
switch (criteria.sortBy) {
|
||||||
case 'nameAsc':
|
case 'nameAsc':
|
||||||
query.orderBy(asc(sql`${schema.users_json.data}->>'lastname'`));
|
query.orderBy(asc(sql`${schema.users_json.data}->>'lastname'`));
|
||||||
break;
|
break;
|
||||||
case 'nameDesc':
|
case 'nameDesc':
|
||||||
query.orderBy(desc(sql`${schema.users_json.data}->>'lastname'`));
|
query.orderBy(desc(sql`${schema.users_json.data}->>'lastname'`));
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// Keine spezifische Sortierung, Standardverhalten kann hier eingefügt werden
|
// Keine spezifische Sortierung, Standardverhalten kann hier eingefügt werden
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Paginierung
|
// Paginierung
|
||||||
query.limit(length).offset(start);
|
query.limit(length).offset(start);
|
||||||
|
|
||||||
const data = await query;
|
const data = await query;
|
||||||
const results = data.map(u => ({ id: u.id, email: u.email, ...(u.data as User) }) as User);
|
const results = data.map(u => ({ id: u.id, email: u.email, ...(u.data as User) }) as User);
|
||||||
const totalCount = await this.getUserListingsCount(criteria);
|
const totalCount = await this.getUserListingsCount(criteria);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
results,
|
results,
|
||||||
totalCount,
|
totalCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
async getUserListingsCount(criteria: UserListingCriteria): Promise<number> {
|
async getUserListingsCount(criteria: UserListingCriteria): Promise<number> {
|
||||||
const countQuery = this.conn.select({ value: count() }).from(schema.users_json);
|
const countQuery = this.conn.select({ value: count() }).from(schema.users_json);
|
||||||
const whereConditions = this.getWhereConditions(criteria);
|
const whereConditions = this.getWhereConditions(criteria);
|
||||||
|
|
||||||
if (whereConditions.length > 0) {
|
if (whereConditions.length > 0) {
|
||||||
const whereClause = and(...whereConditions);
|
const whereClause = and(...whereConditions);
|
||||||
countQuery.where(whereClause);
|
countQuery.where(whereClause);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [{ value: totalCount }] = await countQuery;
|
const [{ value: totalCount }] = await countQuery;
|
||||||
return totalCount;
|
return totalCount;
|
||||||
}
|
}
|
||||||
async getUserByMail(email: string, jwtuser?: JwtUser) {
|
async getUserByMail(email: string, jwtuser?: JwtUser) {
|
||||||
const users = await this.conn.select().from(schema.users_json).where(eq(schema.users_json.email, email));
|
const users = await this.conn.select().from(schema.users_json).where(eq(schema.users_json.email, email));
|
||||||
if (users.length === 0) {
|
if (users.length === 0) {
|
||||||
const user: User = { id: undefined, customerType: 'professional', ...createDefaultUser(email, '', '', null) };
|
const user: User = { id: undefined, customerType: 'professional', ...createDefaultUser(email, '', '', null) };
|
||||||
const u = await this.saveUser(user, false);
|
const u = await this.saveUser(user, false);
|
||||||
return u;
|
return u;
|
||||||
} else {
|
} else {
|
||||||
const user = { id: users[0].id, email: users[0].email, ...(users[0].data as User) } as User;
|
const user = { id: users[0].id, email: users[0].email, ...(users[0].data as User) } as User;
|
||||||
user.hasCompanyLogo = this.fileService.hasCompanyLogo(emailToDirName(user.email));
|
user.hasCompanyLogo = this.fileService.hasCompanyLogo(emailToDirName(user.email));
|
||||||
user.hasProfile = this.fileService.hasProfile(emailToDirName(user.email));
|
user.hasProfile = this.fileService.hasProfile(emailToDirName(user.email));
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async getUserById(id: string) {
|
async getUserById(id: string) {
|
||||||
const users = await this.conn.select().from(schema.users_json).where(eq(schema.users_json.id, id));
|
const users = await this.conn.select().from(schema.users_json).where(eq(schema.users_json.id, id));
|
||||||
|
|
||||||
const user = { id: users[0].id, email: users[0].email, ...(users[0].data as User) } as User;
|
const user = { id: users[0].id, email: users[0].email, ...(users[0].data as User) } as User;
|
||||||
user.hasCompanyLogo = this.fileService.hasCompanyLogo(emailToDirName(user.email));
|
user.hasCompanyLogo = this.fileService.hasCompanyLogo(emailToDirName(user.email));
|
||||||
user.hasProfile = this.fileService.hasProfile(emailToDirName(user.email));
|
user.hasProfile = this.fileService.hasProfile(emailToDirName(user.email));
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
async getAllUser() {
|
async getAllUser() {
|
||||||
const users = await this.conn.select().from(schema.users_json);
|
const users = await this.conn.select().from(schema.users_json);
|
||||||
return users.map(u => ({ id: u.id, email: u.email, ...(u.data as User) }) as User);
|
return users.map(u => ({ id: u.id, email: u.email, ...(u.data as User) }) as User);
|
||||||
}
|
}
|
||||||
async saveUser(user: User, processValidation = true): Promise<User> {
|
async saveUser(user: User, processValidation = true): Promise<User> {
|
||||||
try {
|
try {
|
||||||
user.updated = new Date();
|
user.updated = new Date();
|
||||||
if (user.id) {
|
if (user.id) {
|
||||||
user.created = new Date(user.created);
|
user.created = new Date(user.created);
|
||||||
} else {
|
} else {
|
||||||
user.created = new Date();
|
user.created = new Date();
|
||||||
}
|
}
|
||||||
let validatedUser = user;
|
let validatedUser = user;
|
||||||
if (processValidation) {
|
if (processValidation) {
|
||||||
validatedUser = UserSchema.parse(user);
|
validatedUser = UserSchema.parse(user);
|
||||||
}
|
}
|
||||||
//const drizzleUser = convertUserToDrizzleUser(validatedUser);
|
//const drizzleUser = convertUserToDrizzleUser(validatedUser);
|
||||||
const { id: _, ...rest } = validatedUser;
|
const { id: _, ...rest } = validatedUser;
|
||||||
const drizzleUser = { email: user.email, data: rest };
|
const drizzleUser = { email: user.email, data: rest };
|
||||||
if (user.id) {
|
if (user.id) {
|
||||||
const [updateUser] = await this.conn.update(schema.users_json).set(drizzleUser).where(eq(schema.users_json.id, user.id)).returning();
|
const [updateUser] = await this.conn.update(schema.users_json).set(drizzleUser).where(eq(schema.users_json.id, user.id)).returning();
|
||||||
return { id: updateUser.id, email: updateUser.email, ...(updateUser.data as User) } as User;
|
return { id: updateUser.id, email: updateUser.email, ...(updateUser.data as User) } as User;
|
||||||
} else {
|
} else {
|
||||||
const [newUser] = await this.conn.insert(schema.users_json).values(drizzleUser).returning();
|
const [newUser] = await this.conn.insert(schema.users_json).values(drizzleUser).returning();
|
||||||
return { id: newUser.id, email: newUser.email, ...(newUser.data as User) } as User;
|
return { id: newUser.id, email: newUser.email, ...(newUser.data as User) } as User;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async addFavorite(id: string, user: JwtUser): Promise<void> {
|
async addFavorite(id: string, user: JwtUser): Promise<void> {
|
||||||
const existingUser = await this.getUserById(id);
|
const existingUser = await this.getUserById(id);
|
||||||
if (!existingUser) return;
|
if (!existingUser) return;
|
||||||
|
|
||||||
const favorites = existingUser.favoritesForUser || [];
|
const favorites = existingUser.favoritesForUser || [];
|
||||||
if (!favorites.includes(user.email)) {
|
if (!favorites.includes(user.email)) {
|
||||||
existingUser.favoritesForUser = [...favorites, user.email];
|
existingUser.favoritesForUser = [...favorites, user.email];
|
||||||
const { id: _, ...rest } = existingUser;
|
const { id: _, ...rest } = existingUser;
|
||||||
const drizzleUser = { email: existingUser.email, data: rest };
|
const drizzleUser = { email: existingUser.email, data: rest };
|
||||||
await this.conn.update(schema.users_json).set(drizzleUser).where(eq(schema.users_json.id, id));
|
await this.conn.update(schema.users_json).set(drizzleUser).where(eq(schema.users_json.id, id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteFavorite(id: string, user: JwtUser): Promise<void> {
|
async deleteFavorite(id: string, user: JwtUser): Promise<void> {
|
||||||
const existingUser = await this.getUserById(id);
|
const existingUser = await this.getUserById(id);
|
||||||
if (!existingUser) return;
|
if (!existingUser) return;
|
||||||
|
|
||||||
const favorites = existingUser.favoritesForUser || [];
|
const favorites = existingUser.favoritesForUser || [];
|
||||||
if (favorites.includes(user.email)) {
|
if (favorites.includes(user.email)) {
|
||||||
existingUser.favoritesForUser = favorites.filter(email => email !== user.email);
|
existingUser.favoritesForUser = favorites.filter(email => email !== user.email);
|
||||||
const { id: _, ...rest } = existingUser;
|
const { id: _, ...rest } = existingUser;
|
||||||
const drizzleUser = { email: existingUser.email, data: rest };
|
const drizzleUser = { email: existingUser.email, data: rest };
|
||||||
await this.conn.update(schema.users_json).set(drizzleUser).where(eq(schema.users_json.id, id));
|
await this.conn.update(schema.users_json).set(drizzleUser).where(eq(schema.users_json.id, id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFavoriteUsers(user: JwtUser): Promise<User[]> {
|
async getFavoriteUsers(user: JwtUser): Promise<User[]> {
|
||||||
const data = await this.conn
|
const data = await this.conn
|
||||||
.select()
|
.select()
|
||||||
.from(schema.users_json)
|
.from(schema.users_json)
|
||||||
.where(sql`${schema.users_json.data}->'favoritesForUser' ? ${user.email}`);
|
.where(sql`${schema.users_json.data}->'favoritesForUser' ? ${user.email}`);
|
||||||
return data.map(u => ({ id: u.id, email: u.email, ...(u.data as User) }) as User);
|
return data.map(u => ({ id: u.id, email: u.email, ...(u.data as User) }) as User);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,183 +1,183 @@
|
||||||
/**
|
/**
|
||||||
* Utility functions for generating and parsing SEO-friendly URL slugs
|
* Utility functions for generating and parsing SEO-friendly URL slugs
|
||||||
*
|
*
|
||||||
* Slug format: {title}-{location}-{short-id}
|
* Slug format: {title}-{location}-{short-id}
|
||||||
* Example: italian-restaurant-austin-tx-a3f7b2c1
|
* Example: italian-restaurant-austin-tx-a3f7b2c1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a SEO-friendly URL slug from listing data
|
* Generate a SEO-friendly URL slug from listing data
|
||||||
*
|
*
|
||||||
* @param title - The listing title (e.g., "Italian Restaurant")
|
* @param title - The listing title (e.g., "Italian Restaurant")
|
||||||
* @param location - Location object with name, county, and state
|
* @param location - Location object with name, county, and state
|
||||||
* @param id - The listing UUID
|
* @param id - The listing UUID
|
||||||
* @returns SEO-friendly slug (e.g., "italian-restaurant-austin-tx-a3f7b2c1")
|
* @returns SEO-friendly slug (e.g., "italian-restaurant-austin-tx-a3f7b2c1")
|
||||||
*/
|
*/
|
||||||
export function generateSlug(title: string, location: any, id: string): string {
|
export function generateSlug(title: string, location: any, id: string): string {
|
||||||
if (!title || !id) {
|
if (!title || !id) {
|
||||||
throw new Error('Title and ID are required to generate a slug');
|
throw new Error('Title and ID are required to generate a slug');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean and slugify the title
|
// Clean and slugify the title
|
||||||
const titleSlug = title
|
const titleSlug = title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/[^\w\s-]/g, '') // Remove special characters
|
.replace(/[^\w\s-]/g, '') // Remove special characters
|
||||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||||
.replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
|
.replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
|
||||||
.substring(0, 50); // Limit title to 50 characters
|
.substring(0, 50); // Limit title to 50 characters
|
||||||
|
|
||||||
// Get location string
|
// Get location string
|
||||||
let locationSlug = '';
|
let locationSlug = '';
|
||||||
if (location) {
|
if (location) {
|
||||||
const locationName = location.name || location.county || '';
|
const locationName = location.name || location.county || '';
|
||||||
const state = location.state || '';
|
const state = location.state || '';
|
||||||
|
|
||||||
if (locationName) {
|
if (locationName) {
|
||||||
locationSlug = locationName
|
locationSlug = locationName
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/[^\w\s-]/g, '')
|
.replace(/[^\w\s-]/g, '')
|
||||||
.replace(/\s+/g, '-')
|
.replace(/\s+/g, '-')
|
||||||
.replace(/-+/g, '-');
|
.replace(/-+/g, '-');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state) {
|
if (state) {
|
||||||
locationSlug = locationSlug
|
locationSlug = locationSlug
|
||||||
? `${locationSlug}-${state.toLowerCase()}`
|
? `${locationSlug}-${state.toLowerCase()}`
|
||||||
: state.toLowerCase();
|
: state.toLowerCase();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get first 8 characters of UUID for uniqueness
|
// Get first 8 characters of UUID for uniqueness
|
||||||
const shortId = id.substring(0, 8);
|
const shortId = id.substring(0, 8);
|
||||||
|
|
||||||
// Combine parts: title-location-id
|
// Combine parts: title-location-id
|
||||||
const parts = [titleSlug, locationSlug, shortId].filter(Boolean);
|
const parts = [titleSlug, locationSlug, shortId].filter(Boolean);
|
||||||
const slug = parts.join('-');
|
const slug = parts.join('-');
|
||||||
|
|
||||||
// Final cleanup
|
// Final cleanup
|
||||||
return slug
|
return slug
|
||||||
.replace(/-+/g, '-') // Remove duplicate hyphens
|
.replace(/-+/g, '-') // Remove duplicate hyphens
|
||||||
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
|
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the UUID from a slug
|
* Extract the UUID from a slug
|
||||||
* The UUID is always the last segment (8 characters)
|
* The UUID is always the last segment (8 characters)
|
||||||
*
|
*
|
||||||
* @param slug - The URL slug (e.g., "italian-restaurant-austin-tx-a3f7b2c1")
|
* @param slug - The URL slug (e.g., "italian-restaurant-austin-tx-a3f7b2c1")
|
||||||
* @returns The short ID (e.g., "a3f7b2c1")
|
* @returns The short ID (e.g., "a3f7b2c1")
|
||||||
*/
|
*/
|
||||||
export function extractShortIdFromSlug(slug: string): string {
|
export function extractShortIdFromSlug(slug: string): string {
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
throw new Error('Slug is required');
|
throw new Error('Slug is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts = slug.split('-');
|
const parts = slug.split('-');
|
||||||
return parts[parts.length - 1];
|
return parts[parts.length - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate if a string looks like a valid slug
|
* Validate if a string looks like a valid slug
|
||||||
*
|
*
|
||||||
* @param slug - The string to validate
|
* @param slug - The string to validate
|
||||||
* @returns true if the string looks like a valid slug
|
* @returns true if the string looks like a valid slug
|
||||||
*/
|
*/
|
||||||
export function isValidSlug(slug: string): boolean {
|
export function isValidSlug(slug: string): boolean {
|
||||||
if (!slug || typeof slug !== 'string') {
|
if (!slug || typeof slug !== 'string') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if slug contains only lowercase letters, numbers, and hyphens
|
// Check if slug contains only lowercase letters, numbers, and hyphens
|
||||||
const slugPattern = /^[a-z0-9-]+$/;
|
const slugPattern = /^[a-z0-9-]+$/;
|
||||||
if (!slugPattern.test(slug)) {
|
if (!slugPattern.test(slug)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if slug has a reasonable length (at least 10 chars for short-id + some content)
|
// Check if slug has a reasonable length (at least 10 chars for short-id + some content)
|
||||||
if (slug.length < 10) {
|
if (slug.length < 10) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if last segment looks like a UUID prefix (8 chars of alphanumeric)
|
// Check if last segment looks like a UUID prefix (8 chars of alphanumeric)
|
||||||
const parts = slug.split('-');
|
const parts = slug.split('-');
|
||||||
const lastPart = parts[parts.length - 1];
|
const lastPart = parts[parts.length - 1];
|
||||||
return lastPart.length === 8 && /^[a-z0-9]{8}$/.test(lastPart);
|
return lastPart.length === 8 && /^[a-z0-9]{8}$/.test(lastPart);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a parameter is a slug (vs a UUID)
|
* Check if a parameter is a slug (vs a UUID)
|
||||||
*
|
*
|
||||||
* @param param - The URL parameter
|
* @param param - The URL parameter
|
||||||
* @returns true if it's a slug, false if it's likely a UUID
|
* @returns true if it's a slug, false if it's likely a UUID
|
||||||
*/
|
*/
|
||||||
export function isSlug(param: string): boolean {
|
export function isSlug(param: string): boolean {
|
||||||
if (!param) {
|
if (!param) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// UUIDs have a specific format with hyphens at specific positions
|
// UUIDs have a specific format with hyphens at specific positions
|
||||||
// e.g., "a3f7b2c1-4d5e-6789-abcd-1234567890ef"
|
// e.g., "a3f7b2c1-4d5e-6789-abcd-1234567890ef"
|
||||||
const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
||||||
|
|
||||||
if (uuidPattern.test(param)) {
|
if (uuidPattern.test(param)) {
|
||||||
return false; // It's a UUID
|
return false; // It's a UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it contains at least 3 parts (e.g., title-state-id or title-city-state-id) and looks like our slug format, it's probably a slug
|
// If it contains at least 3 parts (e.g., title-state-id or title-city-state-id) and looks like our slug format, it's probably a slug
|
||||||
return param.split('-').length >= 3 && isValidSlug(param);
|
return param.split('-').length >= 3 && isValidSlug(param);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regenerate slug from updated listing data
|
* Regenerate slug from updated listing data
|
||||||
* Useful when title or location changes
|
* Useful when title or location changes
|
||||||
*
|
*
|
||||||
* @param title - Updated title
|
* @param title - Updated title
|
||||||
* @param location - Updated location
|
* @param location - Updated location
|
||||||
* @param existingSlug - The current slug (to preserve short-id)
|
* @param existingSlug - The current slug (to preserve short-id)
|
||||||
* @returns New slug with same short-id
|
* @returns New slug with same short-id
|
||||||
*/
|
*/
|
||||||
export function regenerateSlug(title: string, location: any, existingSlug: string): string {
|
export function regenerateSlug(title: string, location: any, existingSlug: string): string {
|
||||||
if (!existingSlug) {
|
if (!existingSlug) {
|
||||||
throw new Error('Existing slug is required to regenerate');
|
throw new Error('Existing slug is required to regenerate');
|
||||||
}
|
}
|
||||||
|
|
||||||
const shortId = extractShortIdFromSlug(existingSlug);
|
const shortId = extractShortIdFromSlug(existingSlug);
|
||||||
|
|
||||||
// Reconstruct full UUID from short-id (not possible, so we use full existing slug's ID)
|
// Reconstruct full UUID from short-id (not possible, so we use full existing slug's ID)
|
||||||
// In practice, you'd need the full UUID from the database
|
// In practice, you'd need the full UUID from the database
|
||||||
// For now, we'll construct a new slug with the short-id
|
// For now, we'll construct a new slug with the short-id
|
||||||
const titleSlug = title
|
const titleSlug = title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/[^\w\s-]/g, '')
|
.replace(/[^\w\s-]/g, '')
|
||||||
.replace(/\s+/g, '-')
|
.replace(/\s+/g, '-')
|
||||||
.replace(/-+/g, '-')
|
.replace(/-+/g, '-')
|
||||||
.substring(0, 50);
|
.substring(0, 50);
|
||||||
|
|
||||||
let locationSlug = '';
|
let locationSlug = '';
|
||||||
if (location) {
|
if (location) {
|
||||||
const locationName = location.name || location.county || '';
|
const locationName = location.name || location.county || '';
|
||||||
const state = location.state || '';
|
const state = location.state || '';
|
||||||
|
|
||||||
if (locationName) {
|
if (locationName) {
|
||||||
locationSlug = locationName
|
locationSlug = locationName
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/[^\w\s-]/g, '')
|
.replace(/[^\w\s-]/g, '')
|
||||||
.replace(/\s+/g, '-')
|
.replace(/\s+/g, '-')
|
||||||
.replace(/-+/g, '-');
|
.replace(/-+/g, '-');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state) {
|
if (state) {
|
||||||
locationSlug = locationSlug
|
locationSlug = locationSlug
|
||||||
? `${locationSlug}-${state.toLowerCase()}`
|
? `${locationSlug}-${state.toLowerCase()}`
|
||||||
: state.toLowerCase();
|
: state.toLowerCase();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts = [titleSlug, locationSlug, shortId].filter(Boolean);
|
const parts = [titleSlug, locationSlug, shortId].filter(Boolean);
|
||||||
return parts.join('-').replace(/-+/g, '-').replace(/^-|-$/g, '').toLowerCase();
|
return parts.join('-').replace(/-+/g, '-').replace(/^-|-$/g, '').toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,30 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strictNullChecks": false,
|
"strictNullChecks": false,
|
||||||
"noImplicitAny": false,
|
"noImplicitAny": false,
|
||||||
"strictBindCallApply": false,
|
"strictBindCallApply": false,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"noFallthroughCasesInSwitch": false,
|
"noFallthroughCasesInSwitch": false,
|
||||||
"esModuleInterop": true
|
"esModuleInterop": true
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
"dist",
|
"dist",
|
||||||
"src/scripts/seed-database.ts",
|
"src/scripts/seed-database.ts",
|
||||||
"src/scripts/create-test-user.ts",
|
"src/scripts/create-test-user.ts",
|
||||||
"src/sitemap"
|
"src/sitemap"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,275 +1,275 @@
|
||||||
# BizMatch SSR - Schritt-für-Schritt-Anleitung
|
# BizMatch SSR - Schritt-für-Schritt-Anleitung
|
||||||
|
|
||||||
## Problem: SSR startet nicht auf neuem Laptop?
|
## 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.
|
Diese Anleitung hilft Ihnen, BizMatch mit Server-Side Rendering (SSR) auf einem neuen Rechner zum Laufen zu bringen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Voraussetzungen prüfen
|
## Voraussetzungen prüfen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Node.js Version prüfen (mind. v18 erforderlich)
|
# Node.js Version prüfen (mind. v18 erforderlich)
|
||||||
node --version
|
node --version
|
||||||
|
|
||||||
# npm Version prüfen
|
# npm Version prüfen
|
||||||
npm --version
|
npm --version
|
||||||
|
|
||||||
# Falls Node.js fehlt oder veraltet ist:
|
# Falls Node.js fehlt oder veraltet ist:
|
||||||
# https://nodejs.org/ → LTS Version herunterladen
|
# https://nodejs.org/ → LTS Version herunterladen
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Schritt 1: Repository klonen (falls noch nicht geschehen)
|
## Schritt 1: Repository klonen (falls noch nicht geschehen)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://gitea.bizmatch.net/aknuth/bizmatch-project.git
|
git clone https://gitea.bizmatch.net/aknuth/bizmatch-project.git
|
||||||
cd bizmatch-project/bizmatch
|
cd bizmatch-project/bizmatch
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Schritt 2: Dependencies installieren
|
## Schritt 2: Dependencies installieren
|
||||||
|
|
||||||
**WICHTIG:** Dieser Schritt ist essentiell und wird oft vergessen!
|
**WICHTIG:** Dieser Schritt ist essentiell und wird oft vergessen!
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/bizmatch-project/bizmatch
|
cd ~/bizmatch-project/bizmatch
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Tipp:** Bei Problemen versuchen Sie: `rm -rf node_modules package-lock.json && npm install`
|
> **Tipp:** Bei Problemen versuchen Sie: `rm -rf node_modules package-lock.json && npm install`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚠️ WICHTIG: Erstes Setup auf neuem Laptop
|
## ⚠️ 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!**
|
**Wenn Sie das Projekt zum ersten Mal auf einem neuen Rechner klonen, müssen Sie ZUERST einen Build erstellen!**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/bizmatch-project/bizmatch
|
cd ~/bizmatch-project/bizmatch
|
||||||
|
|
||||||
# 1. Dependencies installieren
|
# 1. Dependencies installieren
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# 2. Build erstellen (erstellt dist/bizmatch/server/index.server.html)
|
# 2. Build erstellen (erstellt dist/bizmatch/server/index.server.html)
|
||||||
npm run build:ssr
|
npm run build:ssr
|
||||||
```
|
```
|
||||||
|
|
||||||
**Warum?**
|
**Warum?**
|
||||||
- Die `dist/` Folder werden NICHT ins Git eingecheckt (`.gitignore`)
|
- Die `dist/` Folder werden NICHT ins Git eingecheckt (`.gitignore`)
|
||||||
- Die Datei `dist/bizmatch/server/index.server.html` fehlt nach `git clone`
|
- 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"
|
- Ohne Build → `npm run serve:ssr` crasht mit "Cannot find index.server.html"
|
||||||
|
|
||||||
**Nach dem ersten Build** können Sie dann Development-Befehle nutzen.
|
**Nach dem ersten Build** können Sie dann Development-Befehle nutzen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Schritt 3: Umgebung wählen
|
## Schritt 3: Umgebung wählen
|
||||||
|
|
||||||
### Option A: Entwicklung (OHNE SSR)
|
### Option A: Entwicklung (OHNE SSR)
|
||||||
|
|
||||||
Schnellster Weg für lokale Entwicklung:
|
Schnellster Weg für lokale Entwicklung:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
- Öffnet automatisch: http://localhost:4200
|
- Öffnet automatisch: http://localhost:4200
|
||||||
- Hot-Reload aktiv (Code-Änderungen werden sofort sichtbar)
|
- Hot-Reload aktiv (Code-Änderungen werden sofort sichtbar)
|
||||||
- **Kein SSR** (schneller für Entwicklung)
|
- **Kein SSR** (schneller für Entwicklung)
|
||||||
|
|
||||||
### Option B: Development mit SSR
|
### Option B: Development mit SSR
|
||||||
|
|
||||||
Für SSR-Testing während der Entwicklung:
|
Für SSR-Testing während der Entwicklung:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev:ssr
|
npm run dev:ssr
|
||||||
```
|
```
|
||||||
|
|
||||||
- Öffnet: http://localhost:4200
|
- Öffnet: http://localhost:4200
|
||||||
- Hot-Reload aktiv
|
- Hot-Reload aktiv
|
||||||
- **SSR aktiv** (simuliert Production)
|
- **SSR aktiv** (simuliert Production)
|
||||||
- Nutzt DOM-Polyfills via `ssr-dom-preload.mjs`
|
- Nutzt DOM-Polyfills via `ssr-dom-preload.mjs`
|
||||||
|
|
||||||
### Option C: Production Build mit SSR
|
### Option C: Production Build mit SSR
|
||||||
|
|
||||||
Für finalen Production-Test:
|
Für finalen Production-Test:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Build erstellen
|
# 1. Build erstellen
|
||||||
npm run build:ssr
|
npm run build:ssr
|
||||||
|
|
||||||
# 2. Server starten
|
# 2. Server starten
|
||||||
npm run serve:ssr
|
npm run serve:ssr
|
||||||
```
|
```
|
||||||
|
|
||||||
- Server läuft auf: http://localhost:4200
|
- Server läuft auf: http://localhost:4200
|
||||||
- **Vollständiges SSR** (wie in Production)
|
- **Vollständiges SSR** (wie in Production)
|
||||||
- Kein Hot-Reload (für Änderungen erneut builden)
|
- Kein Hot-Reload (für Änderungen erneut builden)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Schritt 4: Testen
|
## Schritt 4: Testen
|
||||||
|
|
||||||
Öffnen Sie http://localhost:4200 im Browser.
|
Öffnen Sie http://localhost:4200 im Browser.
|
||||||
|
|
||||||
### SSR funktioniert, wenn:
|
### SSR funktioniert, wenn:
|
||||||
|
|
||||||
1. **Seitenquelltext ansehen** (Rechtsklick → "Seitenquelltext anzeigen"):
|
1. **Seitenquelltext ansehen** (Rechtsklick → "Seitenquelltext anzeigen"):
|
||||||
- HTML-Inhalt ist bereits vorhanden (nicht nur `<app-root></app-root>`)
|
- HTML-Inhalt ist bereits vorhanden (nicht nur `<app-root></app-root>`)
|
||||||
- Meta-Tags sind sichtbar
|
- Meta-Tags sind sichtbar
|
||||||
|
|
||||||
2. **JavaScript deaktivieren** (Chrome DevTools → Settings → Disable JavaScript):
|
2. **JavaScript deaktivieren** (Chrome DevTools → Settings → Disable JavaScript):
|
||||||
- Seite zeigt Inhalt an (wenn auch nicht interaktiv)
|
- Seite zeigt Inhalt an (wenn auch nicht interaktiv)
|
||||||
|
|
||||||
3. **Network-Tab** (Chrome DevTools → Network → Doc):
|
3. **Network-Tab** (Chrome DevTools → Network → Doc):
|
||||||
- HTML-Response enthält bereits gerenderten Content
|
- HTML-Response enthält bereits gerenderten Content
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Häufige Probleme und Lösungen
|
## Häufige Probleme und Lösungen
|
||||||
|
|
||||||
### Problem 1: `npm: command not found`
|
### Problem 1: `npm: command not found`
|
||||||
|
|
||||||
**Lösung:** Node.js installieren
|
**Lösung:** Node.js installieren
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Ubuntu/Debian
|
# Ubuntu/Debian
|
||||||
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
|
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
|
||||||
sudo apt-get install -y nodejs
|
sudo apt-get install -y nodejs
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
brew install node
|
brew install node
|
||||||
|
|
||||||
# Windows
|
# Windows
|
||||||
# https://nodejs.org/ → Installer herunterladen
|
# https://nodejs.org/ → Installer herunterladen
|
||||||
```
|
```
|
||||||
|
|
||||||
### Problem 2: `Cannot find module '@angular/ssr'`
|
### Problem 2: `Cannot find module '@angular/ssr'`
|
||||||
|
|
||||||
**Lösung:** Dependencies neu installieren
|
**Lösung:** Dependencies neu installieren
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rm -rf node_modules package-lock.json
|
rm -rf node_modules package-lock.json
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### Problem 3: `Error: EADDRINUSE: address already in use :::4200`
|
### Problem 3: `Error: EADDRINUSE: address already in use :::4200`
|
||||||
|
|
||||||
**Lösung:** Port ist bereits belegt
|
**Lösung:** Port ist bereits belegt
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Prozess finden und beenden
|
# Prozess finden und beenden
|
||||||
lsof -i :4200
|
lsof -i :4200
|
||||||
kill -9 <PID>
|
kill -9 <PID>
|
||||||
|
|
||||||
# Oder anderen Port nutzen
|
# Oder anderen Port nutzen
|
||||||
PORT=4300 npm run serve:ssr
|
PORT=4300 npm run serve:ssr
|
||||||
```
|
```
|
||||||
|
|
||||||
### Problem 4: `Error loading @angular/platform-server` oder "Cannot find index.server.html"
|
### Problem 4: `Error loading @angular/platform-server` oder "Cannot find index.server.html"
|
||||||
|
|
||||||
**Lösung:** Build fehlt oder ist veraltet
|
**Lösung:** Build fehlt oder ist veraltet
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# dist-Ordner löschen und neu builden
|
# dist-Ordner löschen und neu builden
|
||||||
rm -rf dist
|
rm -rf dist
|
||||||
npm run build:ssr
|
npm run build:ssr
|
||||||
|
|
||||||
# Dann starten
|
# Dann starten
|
||||||
npm run serve:ssr
|
npm run serve:ssr
|
||||||
```
|
```
|
||||||
|
|
||||||
**Häufiger Fehler auf neuem Laptop:**
|
**Häufiger Fehler auf neuem Laptop:**
|
||||||
- Nach `git pull` fehlt der `dist/` Ordner komplett
|
- Nach `git pull` fehlt der `dist/` Ordner komplett
|
||||||
- `index.server.html` wird beim Build erstellt, nicht ins Git eingecheckt
|
- `index.server.html` wird beim Build erstellt, nicht ins Git eingecheckt
|
||||||
- **Lösung:** Immer erst `npm run build:ssr` ausführen!
|
- **Lösung:** Immer erst `npm run build:ssr` ausführen!
|
||||||
|
|
||||||
### Problem 5: "Seite lädt nicht" oder "White Screen"
|
### Problem 5: "Seite lädt nicht" oder "White Screen"
|
||||||
|
|
||||||
**Lösung:**
|
**Lösung:**
|
||||||
|
|
||||||
1. Browser-Cache leeren (Strg+Shift+R / Cmd+Shift+R)
|
1. Browser-Cache leeren (Strg+Shift+R / Cmd+Shift+R)
|
||||||
2. DevTools öffnen → Console-Tab → Fehler prüfen
|
2. DevTools öffnen → Console-Tab → Fehler prüfen
|
||||||
3. Sicherstellen, dass Backend läuft (falls API-Calls)
|
3. Sicherstellen, dass Backend läuft (falls API-Calls)
|
||||||
|
|
||||||
### Problem 6: "Module not found: Error: Can't resolve 'window'"
|
### Problem 6: "Module not found: Error: Can't resolve 'window'"
|
||||||
|
|
||||||
**Lösung:** Browser-spezifischer Code wird im SSR-Build verwendet
|
**Lösung:** Browser-spezifischer Code wird im SSR-Build verwendet
|
||||||
|
|
||||||
- Prüfen Sie `ssr-dom-polyfill.ts` - DOM-Mocks sollten vorhanden sein
|
- Prüfen Sie `ssr-dom-polyfill.ts` - DOM-Mocks sollten vorhanden sein
|
||||||
- Code mit `isPlatformBrowser()` schützen:
|
- Code mit `isPlatformBrowser()` schützen:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { isPlatformBrowser } from '@angular/common';
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
import { PLATFORM_ID } from '@angular/core';
|
import { PLATFORM_ID } from '@angular/core';
|
||||||
|
|
||||||
constructor(@Inject(PLATFORM_ID) private platformId: Object) {}
|
constructor(@Inject(PLATFORM_ID) private platformId: Object) {}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
if (isPlatformBrowser(this.platformId)) {
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
// Nur im Browser ausführen
|
// Nur im Browser ausführen
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Production Deployment mit PM2
|
## Production Deployment mit PM2
|
||||||
|
|
||||||
Für dauerhaften Betrieb (Server-Umgebung):
|
Für dauerhaften Betrieb (Server-Umgebung):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# PM2 global installieren
|
# PM2 global installieren
|
||||||
npm install -g pm2
|
npm install -g pm2
|
||||||
|
|
||||||
# Production Build
|
# Production Build
|
||||||
npm run build:ssr
|
npm run build:ssr
|
||||||
|
|
||||||
# Server mit PM2 starten
|
# Server mit PM2 starten
|
||||||
pm2 start dist/bizmatch/server/server.mjs --name "bizmatch"
|
pm2 start dist/bizmatch/server/server.mjs --name "bizmatch"
|
||||||
|
|
||||||
# Auto-Start bei Server-Neustart
|
# Auto-Start bei Server-Neustart
|
||||||
pm2 startup
|
pm2 startup
|
||||||
pm2 save
|
pm2 save
|
||||||
|
|
||||||
# Logs anzeigen
|
# Logs anzeigen
|
||||||
pm2 logs bizmatch
|
pm2 logs bizmatch
|
||||||
|
|
||||||
# Server neustarten nach Updates
|
# Server neustarten nach Updates
|
||||||
npm run build:ssr && pm2 restart bizmatch
|
npm run build:ssr && pm2 restart bizmatch
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Unterschiede der Befehle
|
## Unterschiede der Befehle
|
||||||
|
|
||||||
| Befehl | SSR | Hot-Reload | Verwendung |
|
| Befehl | SSR | Hot-Reload | Verwendung |
|
||||||
|--------|-----|-----------|------------|
|
|--------|-----|-----------|------------|
|
||||||
| `npm start` | ❌ | ✅ | Entwicklung (schnell) |
|
| `npm start` | ❌ | ✅ | Entwicklung (schnell) |
|
||||||
| `npm run dev:ssr` | ✅ | ✅ | Entwicklung mit SSR |
|
| `npm run dev:ssr` | ✅ | ✅ | Entwicklung mit SSR |
|
||||||
| `npm run build:ssr` | ✅ Build | ❌ | Production Build erstellen |
|
| `npm run build:ssr` | ✅ Build | ❌ | Production Build erstellen |
|
||||||
| `npm run serve:ssr` | ✅ | ❌ | Production Server starten |
|
| `npm run serve:ssr` | ✅ | ❌ | Production Server starten |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Nächste Schritte
|
## Nächste Schritte
|
||||||
|
|
||||||
1. Für normale Entwicklung: **`npm start`** verwenden
|
1. Für normale Entwicklung: **`npm start`** verwenden
|
||||||
2. Vor Production-Deployment: **`npm run build:ssr`** testen
|
2. Vor Production-Deployment: **`npm run build:ssr`** testen
|
||||||
3. SSR-Funktionalität prüfen (siehe "Schritt 4: Testen")
|
3. SSR-Funktionalität prüfen (siehe "Schritt 4: Testen")
|
||||||
4. Bei Problemen: Logs prüfen und obige Lösungen durchgehen
|
4. Bei Problemen: Logs prüfen und obige Lösungen durchgehen
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
Bei weiteren Problemen:
|
Bei weiteren Problemen:
|
||||||
|
|
||||||
1. **Logs prüfen:** `npm run serve:ssr` zeigt Fehler in der Konsole
|
1. **Logs prüfen:** `npm run serve:ssr` zeigt Fehler in der Konsole
|
||||||
2. **Browser DevTools:** Console + Network Tab
|
2. **Browser DevTools:** Console + Network Tab
|
||||||
3. **Build-Output:** `npm run build:ssr` zeigt Build-Fehler
|
3. **Build-Output:** `npm run build:ssr` zeigt Build-Fehler
|
||||||
4. **Node-Version:** `node --version` (sollte ≥ v18 sein)
|
4. **Node-Version:** `node --version` (sollte ≥ v18 sein)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,162 +1,162 @@
|
||||||
{
|
{
|
||||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"newProjectRoot": "projects",
|
"newProjectRoot": "projects",
|
||||||
"projects": {
|
"projects": {
|
||||||
"bizmatch": {
|
"bizmatch": {
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
"schematics": {
|
"schematics": {
|
||||||
"@schematics/angular:component": {
|
"@schematics/angular:component": {
|
||||||
"style": "scss",
|
"style": "scss",
|
||||||
"skipTests": true
|
"skipTests": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "",
|
"root": "",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"prefix": "app",
|
"prefix": "app",
|
||||||
"architect": {
|
"architect": {
|
||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular-devkit/build-angular:application",
|
"builder": "@angular-devkit/build-angular:application",
|
||||||
"options": {
|
"options": {
|
||||||
"outputPath": "dist/bizmatch",
|
"outputPath": "dist/bizmatch",
|
||||||
"index": "src/index.html",
|
"index": "src/index.html",
|
||||||
"browser": "src/main.ts",
|
"browser": "src/main.ts",
|
||||||
"server": "src/main.server.ts",
|
"server": "src/main.server.ts",
|
||||||
"prerender": false,
|
"prerender": false,
|
||||||
"ssr": {
|
"ssr": {
|
||||||
"entry": "server.ts"
|
"entry": "server.ts"
|
||||||
},
|
},
|
||||||
"allowedCommonJsDependencies": [
|
"allowedCommonJsDependencies": [
|
||||||
"quill-delta",
|
"quill-delta",
|
||||||
"leaflet",
|
"leaflet",
|
||||||
"dayjs",
|
"dayjs",
|
||||||
"qs"
|
"qs"
|
||||||
],
|
],
|
||||||
"polyfills": [
|
"polyfills": [
|
||||||
"zone.js"
|
"zone.js"
|
||||||
],
|
],
|
||||||
"tsConfig": "tsconfig.app.json",
|
"tsConfig": "tsconfig.app.json",
|
||||||
"inlineStyleLanguage": "scss",
|
"inlineStyleLanguage": "scss",
|
||||||
"assets": [
|
"assets": [
|
||||||
{
|
{
|
||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
"input": "public"
|
"input": "public"
|
||||||
},
|
},
|
||||||
"src/favicon.ico",
|
"src/favicon.ico",
|
||||||
"src/assets",
|
"src/assets",
|
||||||
"src/robots.txt",
|
"src/robots.txt",
|
||||||
{
|
{
|
||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
"input": "node_modules/leaflet/dist/images",
|
"input": "node_modules/leaflet/dist/images",
|
||||||
"output": "assets/leaflet/"
|
"output": "assets/leaflet/"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.scss",
|
"src/styles.scss",
|
||||||
"src/styles/lazy-load.css",
|
"src/styles/lazy-load.css",
|
||||||
"node_modules/quill/dist/quill.snow.css",
|
"node_modules/quill/dist/quill.snow.css",
|
||||||
"node_modules/leaflet/dist/leaflet.css",
|
"node_modules/leaflet/dist/leaflet.css",
|
||||||
"node_modules/ngx-sharebuttons/themes/default.scss"
|
"node_modules/ngx-sharebuttons/themes/default.scss"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "500kb",
|
"maximumWarning": "500kb",
|
||||||
"maximumError": "2mb"
|
"maximumError": "2mb"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "2kb",
|
"maximumWarning": "2kb",
|
||||||
"maximumError": "4kb"
|
"maximumError": "4kb"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all"
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"optimization": false,
|
"optimization": false,
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"ssr": false
|
"ssr": false
|
||||||
},
|
},
|
||||||
"dev": {
|
"dev": {
|
||||||
"fileReplacements": [
|
"fileReplacements": [
|
||||||
{
|
{
|
||||||
"replace": "src/environments/environment.ts",
|
"replace": "src/environments/environment.ts",
|
||||||
"with": "src/environments/environment.dev.ts"
|
"with": "src/environments/environment.dev.ts"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"optimization": false,
|
"optimization": false,
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
},
|
},
|
||||||
"prod": {
|
"prod": {
|
||||||
"fileReplacements": [
|
"fileReplacements": [
|
||||||
{
|
{
|
||||||
"replace": "src/environments/environment.ts",
|
"replace": "src/environments/environment.ts",
|
||||||
"with": "src/environments/environment.prod.ts"
|
"with": "src/environments/environment.prod.ts"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"optimization": true,
|
"optimization": true,
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultConfiguration": "production"
|
"defaultConfiguration": "production"
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular-devkit/build-angular:dev-server",
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"buildTarget": "bizmatch:build:production"
|
"buildTarget": "bizmatch:build:production"
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"buildTarget": "bizmatch:build:development"
|
"buildTarget": "bizmatch:build:development"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultConfiguration": "development",
|
"defaultConfiguration": "development",
|
||||||
"options": {
|
"options": {
|
||||||
"proxyConfig": "proxy.conf.json"
|
"proxyConfig": "proxy.conf.json"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extract-i18n": {
|
"extract-i18n": {
|
||||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||||
"options": {
|
"options": {
|
||||||
"buildTarget": "bizmatch:build"
|
"buildTarget": "bizmatch:build"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"builder": "@angular-devkit/build-angular:karma",
|
"builder": "@angular-devkit/build-angular:karma",
|
||||||
"options": {
|
"options": {
|
||||||
"polyfills": [
|
"polyfills": [
|
||||||
"zone.js",
|
"zone.js",
|
||||||
"zone.js/testing"
|
"zone.js/testing"
|
||||||
],
|
],
|
||||||
"tsConfig": "tsconfig.spec.json",
|
"tsConfig": "tsconfig.spec.json",
|
||||||
"inlineStyleLanguage": "scss",
|
"inlineStyleLanguage": "scss",
|
||||||
"assets": [
|
"assets": [
|
||||||
"src/assets",
|
"src/assets",
|
||||||
"cropped-Favicon-32x32.png",
|
"cropped-Favicon-32x32.png",
|
||||||
"cropped-Favicon-180x180.png",
|
"cropped-Favicon-180x180.png",
|
||||||
"cropped-Favicon-191x192.png",
|
"cropped-Favicon-191x192.png",
|
||||||
{
|
{
|
||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
"input": "./node_modules/leaflet/dist/images",
|
"input": "./node_modules/leaflet/dist/images",
|
||||||
"output": "assets/"
|
"output": "assets/"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.scss"
|
"src/styles.scss"
|
||||||
],
|
],
|
||||||
"scripts": []
|
"scripts": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cli": {
|
"cli": {
|
||||||
"analytics": false
|
"analytics": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,86 +1,86 @@
|
||||||
{
|
{
|
||||||
"name": "bizmatch",
|
"name": "bizmatch",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve --host 0.0.0.0 & http-server ../bizmatch-server",
|
"start": "ng serve --host 0.0.0.0 & http-server ../bizmatch-server",
|
||||||
"prebuild": "node version.js",
|
"prebuild": "node version.js",
|
||||||
"build": "node version.js && ng build",
|
"build": "node version.js && ng build",
|
||||||
"build.dev": "node version.js && ng build --configuration dev --output-hashing=all",
|
"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.prod": "node version.js && ng build --configuration prod --output-hashing=all",
|
||||||
"build:ssr": "node version.js && ng build --configuration prod",
|
"build:ssr": "node version.js && ng build --configuration prod",
|
||||||
"build:ssr:dev": "node version.js && ng build --configuration dev",
|
"build:ssr:dev": "node version.js && ng build --configuration dev",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test",
|
"test": "ng test",
|
||||||
"serve:ssr": "node dist/bizmatch/server/server.mjs",
|
"serve:ssr": "node dist/bizmatch/server/server.mjs",
|
||||||
"serve:ssr:bizmatch": "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"
|
"dev:ssr": "NODE_OPTIONS='--import ./ssr-dom-preload.mjs' ng serve"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^19.2.16",
|
"@angular/animations": "^19.2.16",
|
||||||
"@angular/cdk": "^19.1.5",
|
"@angular/cdk": "^19.1.5",
|
||||||
"@angular/common": "^19.2.16",
|
"@angular/common": "^19.2.16",
|
||||||
"@angular/compiler": "^19.2.16",
|
"@angular/compiler": "^19.2.16",
|
||||||
"@angular/core": "^19.2.16",
|
"@angular/core": "^19.2.16",
|
||||||
"@angular/fire": "^19.2.0",
|
"@angular/fire": "^19.2.0",
|
||||||
"@angular/forms": "^19.2.16",
|
"@angular/forms": "^19.2.16",
|
||||||
"@angular/platform-browser": "^19.2.16",
|
"@angular/platform-browser": "^19.2.16",
|
||||||
"@angular/platform-browser-dynamic": "^19.2.16",
|
"@angular/platform-browser-dynamic": "^19.2.16",
|
||||||
"@angular/platform-server": "^19.2.16",
|
"@angular/platform-server": "^19.2.16",
|
||||||
"@angular/router": "^19.2.16",
|
"@angular/router": "^19.2.16",
|
||||||
"@angular/ssr": "^19.2.16",
|
"@angular/ssr": "^19.2.16",
|
||||||
"@bluehalo/ngx-leaflet": "^19.0.0",
|
"@bluehalo/ngx-leaflet": "^19.0.0",
|
||||||
"@fortawesome/angular-fontawesome": "^1.0.0",
|
"@fortawesome/angular-fontawesome": "^1.0.0",
|
||||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||||
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||||
"@ng-select/ng-select": "^14.9.0",
|
"@ng-select/ng-select": "^14.9.0",
|
||||||
"@ngneat/until-destroy": "^10.0.0",
|
"@ngneat/until-destroy": "^10.0.0",
|
||||||
"@types/cropperjs": "^1.3.0",
|
"@types/cropperjs": "^1.3.0",
|
||||||
"@types/leaflet": "^1.9.12",
|
"@types/leaflet": "^1.9.12",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"browser-bunyan": "^1.8.0",
|
"browser-bunyan": "^1.8.0",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"flowbite": "^2.4.1",
|
"flowbite": "^2.4.1",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"memoize-one": "^6.0.0",
|
"memoize-one": "^6.0.0",
|
||||||
"ng-gallery": "^11.0.0",
|
"ng-gallery": "^11.0.0",
|
||||||
"ngx-currency": "^19.0.0",
|
"ngx-currency": "^19.0.0",
|
||||||
"ngx-image-cropper": "^8.0.0",
|
"ngx-image-cropper": "^8.0.0",
|
||||||
"ngx-mask": "^18.0.0",
|
"ngx-mask": "^18.0.0",
|
||||||
"ngx-quill": "^27.1.2",
|
"ngx-quill": "^27.1.2",
|
||||||
"ngx-sharebuttons": "^15.0.3",
|
"ngx-sharebuttons": "^15.0.3",
|
||||||
"on-change": "^5.0.1",
|
"on-change": "^5.0.1",
|
||||||
"posthog-js": "^1.259.0",
|
"posthog-js": "^1.259.0",
|
||||||
"quill": "2.0.2",
|
"quill": "2.0.2",
|
||||||
"rxjs": "~7.8.1",
|
"rxjs": "~7.8.1",
|
||||||
"tslib": "^2.6.3",
|
"tslib": "^2.6.3",
|
||||||
"urlcat": "^3.1.0",
|
"urlcat": "^3.1.0",
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^10.0.0",
|
||||||
"zone.js": "~0.15.0",
|
"zone.js": "~0.15.0",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^19.2.16",
|
"@angular-devkit/build-angular": "^19.2.16",
|
||||||
"@angular/cli": "^19.2.16",
|
"@angular/cli": "^19.2.16",
|
||||||
"@angular/compiler-cli": "^19.2.16",
|
"@angular/compiler-cli": "^19.2.16",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jasmine": "~5.1.4",
|
"@types/jasmine": "~5.1.4",
|
||||||
"@types/node": "^20.14.9",
|
"@types/node": "^20.14.9",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"http-server": "^14.1.1",
|
"http-server": "^14.1.1",
|
||||||
"jasmine-core": "~5.1.2",
|
"jasmine-core": "~5.1.2",
|
||||||
"karma": "~6.4.2",
|
"karma": "~6.4.2",
|
||||||
"karma-chrome-launcher": "~3.2.0",
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
"karma-coverage": "~2.2.1",
|
"karma-coverage": "~2.2.1",
|
||||||
"karma-jasmine": "~5.1.0",
|
"karma-jasmine": "~5.1.0",
|
||||||
"karma-jasmine-html-reporter": "~2.1.0",
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
"postcss": "^8.4.39",
|
"postcss": "^8.4.39",
|
||||||
"tailwindcss": "^3.4.4",
|
"tailwindcss": "^3.4.4",
|
||||||
"typescript": "~5.7.2"
|
"typescript": "~5.7.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,28 +1,28 @@
|
||||||
{
|
{
|
||||||
"/bizmatch": {
|
"/bizmatch": {
|
||||||
"target": "http://localhost:3001",
|
"target": "http://localhost:3001",
|
||||||
"secure": false,
|
"secure": false,
|
||||||
"changeOrigin": true,
|
"changeOrigin": true,
|
||||||
"logLevel": "debug"
|
"logLevel": "debug"
|
||||||
},
|
},
|
||||||
"/pictures": {
|
"/pictures": {
|
||||||
"target": "http://localhost:8081",
|
"target": "http://localhost:8081",
|
||||||
"secure": false
|
"secure": false
|
||||||
},
|
},
|
||||||
"/ipify": {
|
"/ipify": {
|
||||||
"target": "https://api.ipify.org",
|
"target": "https://api.ipify.org",
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"changeOrigin": true,
|
"changeOrigin": true,
|
||||||
"pathRewrite": {
|
"pathRewrite": {
|
||||||
"^/ipify": ""
|
"^/ipify": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/ipinfo": {
|
"/ipinfo": {
|
||||||
"target": "https://ipinfo.io",
|
"target": "https://ipinfo.io",
|
||||||
"secure": true,
|
"secure": true,
|
||||||
"changeOrigin": true,
|
"changeOrigin": true,
|
||||||
"pathRewrite": {
|
"pathRewrite": {
|
||||||
"^/ipinfo": ""
|
"^/ipinfo": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,82 +1,82 @@
|
||||||
// IMPORTANT: DOM polyfill must be imported FIRST, before any browser-dependent libraries
|
// IMPORTANT: DOM polyfill must be imported FIRST, before any browser-dependent libraries
|
||||||
import './src/ssr-dom-polyfill';
|
import './src/ssr-dom-polyfill';
|
||||||
|
|
||||||
import { APP_BASE_HREF } from '@angular/common';
|
import { APP_BASE_HREF } from '@angular/common';
|
||||||
import { AngularNodeAppEngine, createNodeRequestHandler, writeResponseToNodeResponse } from '@angular/ssr/node';
|
import { AngularNodeAppEngine, createNodeRequestHandler, writeResponseToNodeResponse } from '@angular/ssr/node';
|
||||||
import { ɵsetAngularAppEngineManifest as setAngularAppEngineManifest } from '@angular/ssr';
|
import { ɵsetAngularAppEngineManifest as setAngularAppEngineManifest } from '@angular/ssr';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
|
||||||
// The Express app is exported so that it can be used by serverless Functions.
|
// The Express app is exported so that it can be used by serverless Functions.
|
||||||
export async function app(): Promise<express.Express> {
|
export async function app(): Promise<express.Express> {
|
||||||
const server = express();
|
const server = express();
|
||||||
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
|
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
|
||||||
const browserDistFolder = resolve(serverDistFolder, '../browser');
|
const browserDistFolder = resolve(serverDistFolder, '../browser');
|
||||||
const indexHtml = join(serverDistFolder, 'index.server.html');
|
const indexHtml = join(serverDistFolder, 'index.server.html');
|
||||||
|
|
||||||
// Explicitly load and set the Angular app engine manifest
|
// Explicitly load and set the Angular app engine manifest
|
||||||
// This is required for environments where the manifest is not auto-loaded
|
// This is required for environments where the manifest is not auto-loaded
|
||||||
const manifestPath = join(serverDistFolder, 'angular-app-engine-manifest.mjs');
|
const manifestPath = join(serverDistFolder, 'angular-app-engine-manifest.mjs');
|
||||||
const manifest = await import(manifestPath);
|
const manifest = await import(manifestPath);
|
||||||
setAngularAppEngineManifest(manifest.default);
|
setAngularAppEngineManifest(manifest.default);
|
||||||
|
|
||||||
const angularApp = new AngularNodeAppEngine();
|
const angularApp = new AngularNodeAppEngine();
|
||||||
|
|
||||||
server.set('view engine', 'html');
|
server.set('view engine', 'html');
|
||||||
server.set('views', browserDistFolder);
|
server.set('views', browserDistFolder);
|
||||||
|
|
||||||
// Example Express Rest API endpoints
|
// Example Express Rest API endpoints
|
||||||
// server.get('/api/**', (req, res) => { });
|
// server.get('/api/**', (req, res) => { });
|
||||||
// Serve static files from /browser
|
// Serve static files from /browser
|
||||||
server.get('*.*', express.static(browserDistFolder, {
|
server.get('*.*', express.static(browserDistFolder, {
|
||||||
maxAge: '1y'
|
maxAge: '1y'
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// All regular routes use the Angular engine
|
// All regular routes use the Angular engine
|
||||||
server.get('*', async (req, res, next) => {
|
server.get('*', async (req, res, next) => {
|
||||||
console.log(`[SSR] Handling request: ${req.method} ${req.url}`);
|
console.log(`[SSR] Handling request: ${req.method} ${req.url}`);
|
||||||
try {
|
try {
|
||||||
const response = await angularApp.handle(req);
|
const response = await angularApp.handle(req);
|
||||||
if (response) {
|
if (response) {
|
||||||
console.log(`[SSR] Response received for ${req.url}, status: ${response.status}`);
|
console.log(`[SSR] Response received for ${req.url}, status: ${response.status}`);
|
||||||
writeResponseToNodeResponse(response, res);
|
writeResponseToNodeResponse(response, res);
|
||||||
} else {
|
} else {
|
||||||
console.log(`[SSR] No response for ${req.url} - Angular engine returned null`);
|
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:
|
console.log(`[SSR] This usually means the route couldn't be rendered. Check for:
|
||||||
1. Browser API usage in components
|
1. Browser API usage in components
|
||||||
2. Missing platform checks
|
2. Missing platform checks
|
||||||
3. Errors during component initialization`);
|
3. Errors during component initialization`);
|
||||||
res.sendStatus(404);
|
res.sendStatus(404);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[SSR] Error handling ${req.url}:`, err);
|
console.error(`[SSR] Error handling ${req.url}:`, err);
|
||||||
console.error(`[SSR] Stack trace:`, err.stack);
|
console.error(`[SSR] Stack trace:`, err.stack);
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global error handlers for debugging
|
// Global error handlers for debugging
|
||||||
process.on('unhandledRejection', (reason, promise) => {
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
console.error('[SSR] Unhandled Rejection at:', promise, 'reason:', reason);
|
console.error('[SSR] Unhandled Rejection at:', promise, 'reason:', reason);
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on('uncaughtException', (error) => {
|
process.on('uncaughtException', (error) => {
|
||||||
console.error('[SSR] Uncaught Exception:', error);
|
console.error('[SSR] Uncaught Exception:', error);
|
||||||
console.error('[SSR] Stack:', error.stack);
|
console.error('[SSR] Stack:', error.stack);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function run(): Promise<void> {
|
async function run(): Promise<void> {
|
||||||
const port = process.env['PORT'] || 4200;
|
const port = process.env['PORT'] || 4200;
|
||||||
|
|
||||||
// Start up the Node server
|
// Start up the Node server
|
||||||
const server = await app();
|
const server = await app();
|
||||||
server.listen(port, () => {
|
server.listen(port, () => {
|
||||||
console.log(`Node Express server listening on http://localhost:${port}`);
|
console.log(`Node Express server listening on http://localhost:${port}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
run();
|
run();
|
||||||
|
|
|
||||||
|
|
@ -1,85 +1,85 @@
|
||||||
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
||||||
import { AfterViewInit, Component, HostListener, PLATFORM_ID, inject } from '@angular/core';
|
import { AfterViewInit, Component, HostListener, PLATFORM_ID, inject } from '@angular/core';
|
||||||
import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/router';
|
import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/router';
|
||||||
import { initFlowbite } from 'flowbite';
|
import { initFlowbite } from 'flowbite';
|
||||||
import { filter } from 'rxjs/operators';
|
import { filter } from 'rxjs/operators';
|
||||||
import build from '../build';
|
import build from '../build';
|
||||||
import { ConfirmationComponent } from './components/confirmation/confirmation.component';
|
import { ConfirmationComponent } from './components/confirmation/confirmation.component';
|
||||||
import { ConfirmationService } from './components/confirmation/confirmation.service';
|
import { ConfirmationService } from './components/confirmation/confirmation.service';
|
||||||
import { EMailComponent } from './components/email/email.component';
|
import { EMailComponent } from './components/email/email.component';
|
||||||
import { FooterComponent } from './components/footer/footer.component';
|
import { FooterComponent } from './components/footer/footer.component';
|
||||||
import { HeaderComponent } from './components/header/header.component';
|
import { HeaderComponent } from './components/header/header.component';
|
||||||
import { MessageContainerComponent } from './components/message/message-container.component';
|
import { MessageContainerComponent } from './components/message/message-container.component';
|
||||||
import { SearchModalCommercialComponent } from './components/search-modal/search-modal-commercial.component';
|
import { SearchModalCommercialComponent } from './components/search-modal/search-modal-commercial.component';
|
||||||
import { SearchModalComponent } from './components/search-modal/search-modal.component';
|
import { SearchModalComponent } from './components/search-modal/search-modal.component';
|
||||||
import { AuditService } from './services/audit.service';
|
import { AuditService } from './services/audit.service';
|
||||||
import { GeoService } from './services/geo.service';
|
import { GeoService } from './services/geo.service';
|
||||||
import { LoadingService } from './services/loading.service';
|
import { LoadingService } from './services/loading.service';
|
||||||
import { UserService } from './services/user.service';
|
import { UserService } from './services/user.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterOutlet, HeaderComponent, FooterComponent, MessageContainerComponent, SearchModalComponent, SearchModalCommercialComponent, ConfirmationComponent, EMailComponent],
|
imports: [CommonModule, RouterOutlet, HeaderComponent, FooterComponent, MessageContainerComponent, SearchModalComponent, SearchModalCommercialComponent, ConfirmationComponent, EMailComponent],
|
||||||
providers: [],
|
providers: [],
|
||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
styleUrl: './app.component.scss',
|
styleUrl: './app.component.scss',
|
||||||
})
|
})
|
||||||
export class AppComponent implements AfterViewInit {
|
export class AppComponent implements AfterViewInit {
|
||||||
build = build;
|
build = build;
|
||||||
title = 'bizmatch';
|
title = 'bizmatch';
|
||||||
actualRoute = '';
|
actualRoute = '';
|
||||||
private platformId = inject(PLATFORM_ID);
|
private platformId = inject(PLATFORM_ID);
|
||||||
private isBrowser = isPlatformBrowser(this.platformId);
|
private isBrowser = isPlatformBrowser(this.platformId);
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
public loadingService: LoadingService,
|
public loadingService: LoadingService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private activatedRoute: ActivatedRoute,
|
private activatedRoute: ActivatedRoute,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
private confirmationService: ConfirmationService,
|
private confirmationService: ConfirmationService,
|
||||||
private auditService: AuditService,
|
private auditService: AuditService,
|
||||||
private geoService: GeoService,
|
private geoService: GeoService,
|
||||||
) {
|
) {
|
||||||
this.router.events.pipe(filter(event => event instanceof NavigationEnd)).subscribe(() => {
|
this.router.events.pipe(filter(event => event instanceof NavigationEnd)).subscribe(() => {
|
||||||
let currentRoute = this.activatedRoute.root;
|
let currentRoute = this.activatedRoute.root;
|
||||||
while (currentRoute.children[0] !== undefined) {
|
while (currentRoute.children[0] !== undefined) {
|
||||||
currentRoute = currentRoute.children[0];
|
currentRoute = currentRoute.children[0];
|
||||||
}
|
}
|
||||||
// Hier haben Sie Zugriff auf den aktuellen Route-Pfad
|
// Hier haben Sie Zugriff auf den aktuellen Route-Pfad
|
||||||
this.actualRoute = currentRoute.snapshot.url[0].path;
|
this.actualRoute = currentRoute.snapshot.url[0].path;
|
||||||
|
|
||||||
// Re-initialize Flowbite after navigation to ensure all components are ready
|
// Re-initialize Flowbite after navigation to ensure all components are ready
|
||||||
if (this.isBrowser) {
|
if (this.isBrowser) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
initFlowbite();
|
initFlowbite();
|
||||||
}, 50);
|
}, 50);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
// Navigation tracking moved from constructor
|
// Navigation tracking moved from constructor
|
||||||
}
|
}
|
||||||
|
|
||||||
ngAfterViewInit() {
|
ngAfterViewInit() {
|
||||||
// Initialize Flowbite for dropdowns, modals, and other interactive components
|
// Initialize Flowbite for dropdowns, modals, and other interactive components
|
||||||
// Note: Drawers work automatically with data-drawer-target attributes
|
// Note: Drawers work automatically with data-drawer-target attributes
|
||||||
if (this.isBrowser) {
|
if (this.isBrowser) {
|
||||||
initFlowbite();
|
initFlowbite();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('window:keydown', ['$event'])
|
@HostListener('window:keydown', ['$event'])
|
||||||
handleKeyboardEvent(event: KeyboardEvent) {
|
handleKeyboardEvent(event: KeyboardEvent) {
|
||||||
if (event.shiftKey && event.ctrlKey && event.key === 'V') {
|
if (event.shiftKey && event.ctrlKey && event.key === 'V') {
|
||||||
this.showVersionDialog();
|
this.showVersionDialog();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
showVersionDialog() {
|
showVersionDialog() {
|
||||||
this.confirmationService.showConfirmation({ message: `App Version: ${this.build.timestamp}`, buttons: 'none' });
|
this.confirmationService.showConfirmation({ message: `App Version: ${this.build.timestamp}`, buttons: 'none' });
|
||||||
}
|
}
|
||||||
isFilterRoute(): boolean {
|
isFilterRoute(): boolean {
|
||||||
const filterRoutes = ['/businessListings', '/commercialPropertyListings', '/brokerListings'];
|
const filterRoutes = ['/businessListings', '/commercialPropertyListings', '/brokerListings'];
|
||||||
return filterRoutes.includes(this.actualRoute);
|
return filterRoutes.includes(this.actualRoute);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
|
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
|
||||||
import { provideServerRendering } from '@angular/platform-server';
|
import { provideServerRendering } from '@angular/platform-server';
|
||||||
import { provideServerRouting } from '@angular/ssr';
|
import { provideServerRouting } from '@angular/ssr';
|
||||||
import { appConfig } from './app.config';
|
import { appConfig } from './app.config';
|
||||||
import { serverRoutes } from './app.routes.server';
|
import { serverRoutes } from './app.routes.server';
|
||||||
|
|
||||||
const serverConfig: ApplicationConfig = {
|
const serverConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideServerRendering(),
|
provideServerRendering(),
|
||||||
provideServerRouting(serverRoutes)
|
provideServerRouting(serverRoutes)
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
export const config = mergeApplicationConfig(appConfig, serverConfig);
|
export const config = mergeApplicationConfig(appConfig, serverConfig);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,102 +1,102 @@
|
||||||
import { IMAGE_CONFIG, isPlatformBrowser } from '@angular/common';
|
import { IMAGE_CONFIG, isPlatformBrowser } from '@angular/common';
|
||||||
import { APP_INITIALIZER, ApplicationConfig, ErrorHandler, PLATFORM_ID, inject } from '@angular/core';
|
import { APP_INITIALIZER, ApplicationConfig, ErrorHandler, PLATFORM_ID, inject } from '@angular/core';
|
||||||
import { provideClientHydration } from '@angular/platform-browser';
|
import { provideClientHydration } from '@angular/platform-browser';
|
||||||
import { provideRouter, withEnabledBlockingInitialNavigation, withInMemoryScrolling } from '@angular/router';
|
import { provideRouter, withEnabledBlockingInitialNavigation, withInMemoryScrolling } from '@angular/router';
|
||||||
|
|
||||||
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||||
import { initializeApp, provideFirebaseApp } from '@angular/fire/app';
|
import { initializeApp, provideFirebaseApp } from '@angular/fire/app';
|
||||||
import { getAuth, provideAuth } from '@angular/fire/auth';
|
import { getAuth, provideAuth } from '@angular/fire/auth';
|
||||||
import { provideAnimations } from '@angular/platform-browser/animations';
|
import { provideAnimations } from '@angular/platform-browser/animations';
|
||||||
import { GALLERY_CONFIG, GalleryConfig } from 'ng-gallery';
|
import { GALLERY_CONFIG, GalleryConfig } from 'ng-gallery';
|
||||||
import { provideQuillConfig } from 'ngx-quill';
|
import { provideQuillConfig } from 'ngx-quill';
|
||||||
import { provideShareButtonsOptions, SharerMethods, withConfig } from 'ngx-sharebuttons';
|
import { provideShareButtonsOptions, SharerMethods, withConfig } from 'ngx-sharebuttons';
|
||||||
import { shareIcons } from 'ngx-sharebuttons/icons';
|
import { shareIcons } from 'ngx-sharebuttons/icons';
|
||||||
import { environment } from '../environments/environment';
|
import { environment } from '../environments/environment';
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { AuthInterceptor } from './interceptors/auth.interceptor';
|
import { AuthInterceptor } from './interceptors/auth.interceptor';
|
||||||
import { LoadingInterceptor } from './interceptors/loading.interceptor';
|
import { LoadingInterceptor } from './interceptors/loading.interceptor';
|
||||||
import { TimeoutInterceptor } from './interceptors/timeout.interceptor';
|
import { TimeoutInterceptor } from './interceptors/timeout.interceptor';
|
||||||
import { GlobalErrorHandler } from './services/globalErrorHandler';
|
import { GlobalErrorHandler } from './services/globalErrorHandler';
|
||||||
import { POSTHOG_INIT_PROVIDER } from './services/posthog.factory';
|
import { POSTHOG_INIT_PROVIDER } from './services/posthog.factory';
|
||||||
import { SelectOptionsService } from './services/select-options.service';
|
import { SelectOptionsService } from './services/select-options.service';
|
||||||
import { createLogger } from './utils/utils';
|
import { createLogger } from './utils/utils';
|
||||||
|
|
||||||
const logger = createLogger('ApplicationConfig');
|
const logger = createLogger('ApplicationConfig');
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
// Temporarily disabled for SSR debugging
|
// Temporarily disabled for SSR debugging
|
||||||
// provideClientHydration(),
|
// provideClientHydration(),
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
{
|
{
|
||||||
provide: APP_INITIALIZER,
|
provide: APP_INITIALIZER,
|
||||||
useFactory: initServices,
|
useFactory: initServices,
|
||||||
multi: true,
|
multi: true,
|
||||||
deps: [SelectOptionsService],
|
deps: [SelectOptionsService],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: HTTP_INTERCEPTORS,
|
provide: HTTP_INTERCEPTORS,
|
||||||
useClass: LoadingInterceptor,
|
useClass: LoadingInterceptor,
|
||||||
multi: true,
|
multi: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: HTTP_INTERCEPTORS,
|
provide: HTTP_INTERCEPTORS,
|
||||||
useClass: TimeoutInterceptor,
|
useClass: TimeoutInterceptor,
|
||||||
multi: true,
|
multi: true,
|
||||||
},
|
},
|
||||||
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
|
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
|
||||||
{
|
{
|
||||||
provide: 'TIMEOUT_DURATION',
|
provide: 'TIMEOUT_DURATION',
|
||||||
useValue: 5000, // Standard-Timeout von 5 Sekunden
|
useValue: 5000, // Standard-Timeout von 5 Sekunden
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: GALLERY_CONFIG,
|
provide: GALLERY_CONFIG,
|
||||||
useValue: {
|
useValue: {
|
||||||
autoHeight: true,
|
autoHeight: true,
|
||||||
imageSize: 'cover',
|
imageSize: 'cover',
|
||||||
} as GalleryConfig,
|
} as GalleryConfig,
|
||||||
},
|
},
|
||||||
{ provide: ErrorHandler, useClass: GlobalErrorHandler }, // Registriere den globalen ErrorHandler
|
{ provide: ErrorHandler, useClass: GlobalErrorHandler }, // Registriere den globalen ErrorHandler
|
||||||
{
|
{
|
||||||
provide: IMAGE_CONFIG,
|
provide: IMAGE_CONFIG,
|
||||||
useValue: {
|
useValue: {
|
||||||
disableImageSizeWarning: true,
|
disableImageSizeWarning: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
provideShareButtonsOptions(
|
provideShareButtonsOptions(
|
||||||
shareIcons(),
|
shareIcons(),
|
||||||
withConfig({
|
withConfig({
|
||||||
debug: true,
|
debug: true,
|
||||||
sharerMethod: SharerMethods.Anchor,
|
sharerMethod: SharerMethods.Anchor,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
provideRouter(
|
provideRouter(
|
||||||
routes,
|
routes,
|
||||||
withEnabledBlockingInitialNavigation(),
|
withEnabledBlockingInitialNavigation(),
|
||||||
withInMemoryScrolling({
|
withInMemoryScrolling({
|
||||||
scrollPositionRestoration: 'enabled',
|
scrollPositionRestoration: 'enabled',
|
||||||
anchorScrolling: 'enabled',
|
anchorScrolling: 'enabled',
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
...(environment.production ? [POSTHOG_INIT_PROVIDER] : []),
|
...(environment.production ? [POSTHOG_INIT_PROVIDER] : []),
|
||||||
provideAnimations(),
|
provideAnimations(),
|
||||||
provideQuillConfig({
|
provideQuillConfig({
|
||||||
modules: {
|
modules: {
|
||||||
syntax: true,
|
syntax: true,
|
||||||
toolbar: [
|
toolbar: [
|
||||||
['bold', 'italic', 'underline'], // Einige Standardoptionen
|
['bold', 'italic', 'underline'], // Einige Standardoptionen
|
||||||
[{ header: [1, 2, 3, false] }], // Benutzerdefinierte Header
|
[{ header: [1, 2, 3, false] }], // Benutzerdefinierte Header
|
||||||
[{ list: 'ordered' }, { list: 'bullet' }],
|
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||||
[{ color: [] }], // Dropdown mit Standardfarben
|
[{ color: [] }], // Dropdown mit Standardfarben
|
||||||
['clean'], // Entfernt Formatierungen
|
['clean'], // Entfernt Formatierungen
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
provideFirebaseApp(() => initializeApp(environment.firebaseConfig)),
|
provideFirebaseApp(() => initializeApp(environment.firebaseConfig)),
|
||||||
provideAuth(() => getAuth()),
|
provideAuth(() => getAuth()),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
function initServices(selectOptions: SelectOptionsService) {
|
function initServices(selectOptions: SelectOptionsService) {
|
||||||
return async () => {
|
return async () => {
|
||||||
await selectOptions.init();
|
await selectOptions.init();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,193 +1,193 @@
|
||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import { LogoutComponent } from './components/logout/logout.component';
|
import { LogoutComponent } from './components/logout/logout.component';
|
||||||
import { NotFoundComponent } from './components/not-found/not-found.component';
|
import { NotFoundComponent } from './components/not-found/not-found.component';
|
||||||
import { TestSsrComponent } from './components/test-ssr/test-ssr.component';
|
import { TestSsrComponent } from './components/test-ssr/test-ssr.component';
|
||||||
|
|
||||||
import { EmailAuthorizedComponent } from './components/email-authorized/email-authorized.component';
|
import { EmailAuthorizedComponent } from './components/email-authorized/email-authorized.component';
|
||||||
import { EmailVerificationComponent } from './components/email-verification/email-verification.component';
|
import { EmailVerificationComponent } from './components/email-verification/email-verification.component';
|
||||||
import { LoginRegisterComponent } from './components/login-register/login-register.component';
|
import { LoginRegisterComponent } from './components/login-register/login-register.component';
|
||||||
import { AuthGuard } from './guards/auth.guard';
|
import { AuthGuard } from './guards/auth.guard';
|
||||||
import { ListingCategoryGuard } from './guards/listing-category.guard';
|
import { ListingCategoryGuard } from './guards/listing-category.guard';
|
||||||
import { UserListComponent } from './pages/admin/user-list/user-list.component';
|
import { UserListComponent } from './pages/admin/user-list/user-list.component';
|
||||||
import { DetailsBusinessListingComponent } from './pages/details/details-business-listing/details-business-listing.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 { DetailsCommercialPropertyListingComponent } from './pages/details/details-commercial-property-listing/details-commercial-property-listing.component';
|
||||||
import { DetailsUserComponent } from './pages/details/details-user/details-user.component';
|
import { DetailsUserComponent } from './pages/details/details-user/details-user.component';
|
||||||
import { HomeComponent } from './pages/home/home.component';
|
import { HomeComponent } from './pages/home/home.component';
|
||||||
import { BrokerListingsComponent } from './pages/listings/broker-listings/broker-listings.component';
|
import { BrokerListingsComponent } from './pages/listings/broker-listings/broker-listings.component';
|
||||||
import { BusinessListingsComponent } from './pages/listings/business-listings/business-listings.component';
|
import { BusinessListingsComponent } from './pages/listings/business-listings/business-listings.component';
|
||||||
import { CommercialPropertyListingsComponent } from './pages/listings/commercial-property-listings/commercial-property-listings.component';
|
import { CommercialPropertyListingsComponent } from './pages/listings/commercial-property-listings/commercial-property-listings.component';
|
||||||
import { AccountComponent } from './pages/subscription/account/account.component';
|
import { AccountComponent } from './pages/subscription/account/account.component';
|
||||||
import { EditBusinessListingComponent } from './pages/subscription/edit-business-listing/edit-business-listing.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 { EditCommercialPropertyListingComponent } from './pages/subscription/edit-commercial-property-listing/edit-commercial-property-listing.component';
|
||||||
import { EmailUsComponent } from './pages/subscription/email-us/email-us.component';
|
import { EmailUsComponent } from './pages/subscription/email-us/email-us.component';
|
||||||
import { FavoritesComponent } from './pages/subscription/favorites/favorites.component';
|
import { FavoritesComponent } from './pages/subscription/favorites/favorites.component';
|
||||||
import { MyListingComponent } from './pages/subscription/my-listing/my-listing.component';
|
import { MyListingComponent } from './pages/subscription/my-listing/my-listing.component';
|
||||||
import { SuccessComponent } from './pages/success/success.component';
|
import { SuccessComponent } from './pages/success/success.component';
|
||||||
import { TermsOfUseComponent } from './pages/legal/terms-of-use.component';
|
import { TermsOfUseComponent } from './pages/legal/terms-of-use.component';
|
||||||
import { PrivacyStatementComponent } from './pages/legal/privacy-statement.component';
|
import { PrivacyStatementComponent } from './pages/legal/privacy-statement.component';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
path: 'test-ssr',
|
path: 'test-ssr',
|
||||||
component: TestSsrComponent,
|
component: TestSsrComponent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'businessListings',
|
path: 'businessListings',
|
||||||
component: BusinessListingsComponent,
|
component: BusinessListingsComponent,
|
||||||
runGuardsAndResolvers: 'always',
|
runGuardsAndResolvers: 'always',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'commercialPropertyListings',
|
path: 'commercialPropertyListings',
|
||||||
component: CommercialPropertyListingsComponent,
|
component: CommercialPropertyListingsComponent,
|
||||||
runGuardsAndResolvers: 'always',
|
runGuardsAndResolvers: 'always',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'brokerListings',
|
path: 'brokerListings',
|
||||||
component: BrokerListingsComponent,
|
component: BrokerListingsComponent,
|
||||||
runGuardsAndResolvers: 'always',
|
runGuardsAndResolvers: 'always',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'home',
|
path: 'home',
|
||||||
component: HomeComponent,
|
component: HomeComponent,
|
||||||
},
|
},
|
||||||
// #########
|
// #########
|
||||||
// Listings Details - New SEO-friendly slug-based URLs
|
// Listings Details - New SEO-friendly slug-based URLs
|
||||||
{
|
{
|
||||||
path: 'business/:slug',
|
path: 'business/:slug',
|
||||||
component: DetailsBusinessListingComponent,
|
component: DetailsBusinessListingComponent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'commercial-property/:slug',
|
path: 'commercial-property/:slug',
|
||||||
component: DetailsCommercialPropertyListingComponent,
|
component: DetailsCommercialPropertyListingComponent,
|
||||||
},
|
},
|
||||||
// Backward compatibility redirects for old UUID-based URLs
|
// Backward compatibility redirects for old UUID-based URLs
|
||||||
{
|
{
|
||||||
path: 'details-business-listing/:id',
|
path: 'details-business-listing/:id',
|
||||||
redirectTo: 'business/:id',
|
redirectTo: 'business/:id',
|
||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'details-commercial-property-listing/:id',
|
path: 'details-commercial-property-listing/:id',
|
||||||
redirectTo: 'commercial-property/:id',
|
redirectTo: 'commercial-property/:id',
|
||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'listing/:id',
|
path: 'listing/:id',
|
||||||
canActivate: [ListingCategoryGuard],
|
canActivate: [ListingCategoryGuard],
|
||||||
component: NotFoundComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet
|
component: NotFoundComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// path: 'login/:page',
|
// path: 'login/:page',
|
||||||
// component: LoginComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet
|
// component: LoginComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet
|
||||||
// },
|
// },
|
||||||
{
|
{
|
||||||
path: 'login/:page',
|
path: 'login/:page',
|
||||||
component: LoginRegisterComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet
|
component: LoginRegisterComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'login',
|
path: 'login',
|
||||||
component: LoginRegisterComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet
|
component: LoginRegisterComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'notfound',
|
path: 'notfound',
|
||||||
component: NotFoundComponent,
|
component: NotFoundComponent,
|
||||||
},
|
},
|
||||||
// #########
|
// #########
|
||||||
// User Details
|
// User Details
|
||||||
{
|
{
|
||||||
path: 'details-user/:id',
|
path: 'details-user/:id',
|
||||||
component: DetailsUserComponent,
|
component: DetailsUserComponent,
|
||||||
},
|
},
|
||||||
// #########
|
// #########
|
||||||
// User edit
|
// User edit
|
||||||
{
|
{
|
||||||
path: 'account',
|
path: 'account',
|
||||||
component: AccountComponent,
|
component: AccountComponent,
|
||||||
canActivate: [AuthGuard],
|
canActivate: [AuthGuard],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'account/:id',
|
path: 'account/:id',
|
||||||
component: AccountComponent,
|
component: AccountComponent,
|
||||||
canActivate: [AuthGuard],
|
canActivate: [AuthGuard],
|
||||||
},
|
},
|
||||||
// #########
|
// #########
|
||||||
// Create, Update Listings
|
// Create, Update Listings
|
||||||
{
|
{
|
||||||
path: 'editBusinessListing/:id',
|
path: 'editBusinessListing/:id',
|
||||||
component: EditBusinessListingComponent,
|
component: EditBusinessListingComponent,
|
||||||
canActivate: [AuthGuard],
|
canActivate: [AuthGuard],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'createBusinessListing',
|
path: 'createBusinessListing',
|
||||||
component: EditBusinessListingComponent,
|
component: EditBusinessListingComponent,
|
||||||
canActivate: [AuthGuard],
|
canActivate: [AuthGuard],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'editCommercialPropertyListing/:id',
|
path: 'editCommercialPropertyListing/:id',
|
||||||
component: EditCommercialPropertyListingComponent,
|
component: EditCommercialPropertyListingComponent,
|
||||||
canActivate: [AuthGuard],
|
canActivate: [AuthGuard],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'createCommercialPropertyListing',
|
path: 'createCommercialPropertyListing',
|
||||||
component: EditCommercialPropertyListingComponent,
|
component: EditCommercialPropertyListingComponent,
|
||||||
canActivate: [AuthGuard],
|
canActivate: [AuthGuard],
|
||||||
},
|
},
|
||||||
// #########
|
// #########
|
||||||
// My Listings
|
// My Listings
|
||||||
{
|
{
|
||||||
path: 'myListings',
|
path: 'myListings',
|
||||||
component: MyListingComponent,
|
component: MyListingComponent,
|
||||||
canActivate: [AuthGuard],
|
canActivate: [AuthGuard],
|
||||||
},
|
},
|
||||||
// #########
|
// #########
|
||||||
// My Favorites
|
// My Favorites
|
||||||
{
|
{
|
||||||
path: 'myFavorites',
|
path: 'myFavorites',
|
||||||
component: FavoritesComponent,
|
component: FavoritesComponent,
|
||||||
canActivate: [AuthGuard],
|
canActivate: [AuthGuard],
|
||||||
},
|
},
|
||||||
// #########
|
// #########
|
||||||
// EMAil Us
|
// EMAil Us
|
||||||
{
|
{
|
||||||
path: 'emailUs',
|
path: 'emailUs',
|
||||||
component: EmailUsComponent,
|
component: EmailUsComponent,
|
||||||
// canActivate: [AuthGuard],
|
// canActivate: [AuthGuard],
|
||||||
},
|
},
|
||||||
// #########
|
// #########
|
||||||
// Logout
|
// Logout
|
||||||
{
|
{
|
||||||
path: 'logout',
|
path: 'logout',
|
||||||
component: LogoutComponent,
|
component: LogoutComponent,
|
||||||
canActivate: [AuthGuard],
|
canActivate: [AuthGuard],
|
||||||
},
|
},
|
||||||
// #########
|
// #########
|
||||||
// Email Verification
|
// Email Verification
|
||||||
{
|
{
|
||||||
path: 'emailVerification',
|
path: 'emailVerification',
|
||||||
component: EmailVerificationComponent,
|
component: EmailVerificationComponent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'email-authorized',
|
path: 'email-authorized',
|
||||||
component: EmailAuthorizedComponent,
|
component: EmailAuthorizedComponent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'success',
|
path: 'success',
|
||||||
component: SuccessComponent,
|
component: SuccessComponent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'admin/users',
|
path: 'admin/users',
|
||||||
component: UserListComponent,
|
component: UserListComponent,
|
||||||
canActivate: [AuthGuard],
|
canActivate: [AuthGuard],
|
||||||
},
|
},
|
||||||
// #########
|
// #########
|
||||||
// Legal Pages
|
// Legal Pages
|
||||||
{
|
{
|
||||||
path: 'terms-of-use',
|
path: 'terms-of-use',
|
||||||
component: TermsOfUseComponent,
|
component: TermsOfUseComponent,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'privacy-statement',
|
path: 'privacy-statement',
|
||||||
component: PrivacyStatementComponent,
|
component: PrivacyStatementComponent,
|
||||||
},
|
},
|
||||||
{ path: '**', redirectTo: 'home' },
|
{ path: '**', redirectTo: 'home' },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,57 +1,57 @@
|
||||||
import { Component, Input } from '@angular/core';
|
import { Component, Input } from '@angular/core';
|
||||||
import { ControlValueAccessor } from '@angular/forms';
|
import { ControlValueAccessor } from '@angular/forms';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
import { ValidationMessagesService } from '../validation-messages.service';
|
import { ValidationMessagesService } from '../validation-messages.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-base-input',
|
selector: 'app-base-input',
|
||||||
template: ``,
|
template: ``,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [],
|
imports: [],
|
||||||
})
|
})
|
||||||
export abstract class BaseInputComponent implements ControlValueAccessor {
|
export abstract class BaseInputComponent implements ControlValueAccessor {
|
||||||
@Input() value: any = '';
|
@Input() value: any = '';
|
||||||
validationMessage: string = '';
|
validationMessage: string = '';
|
||||||
onChange: any = () => {};
|
onChange: any = () => {};
|
||||||
onTouched: any = () => {};
|
onTouched: any = () => {};
|
||||||
subscription: Subscription | null = null;
|
subscription: Subscription | null = null;
|
||||||
@Input() label: string = '';
|
@Input() label: string = '';
|
||||||
// @Input() id: string = '';
|
// @Input() id: string = '';
|
||||||
@Input() name: string = '';
|
@Input() name: string = '';
|
||||||
isTooltipVisible = false;
|
isTooltipVisible = false;
|
||||||
constructor(protected validationMessagesService: ValidationMessagesService) {}
|
constructor(protected validationMessagesService: ValidationMessagesService) {}
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.subscription = this.validationMessagesService.messages$.subscribe(() => {
|
this.subscription = this.validationMessagesService.messages$.subscribe(() => {
|
||||||
this.updateValidationMessage();
|
this.updateValidationMessage();
|
||||||
});
|
});
|
||||||
// Flowbite is now initialized once in AppComponent
|
// Flowbite is now initialized once in AppComponent
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
if (this.subscription) {
|
if (this.subscription) {
|
||||||
this.subscription.unsubscribe();
|
this.subscription.unsubscribe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
writeValue(value: any): void {
|
writeValue(value: any): void {
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
this.value = value;
|
this.value = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
registerOnChange(fn: any): void {
|
registerOnChange(fn: any): void {
|
||||||
this.onChange = fn;
|
this.onChange = fn;
|
||||||
}
|
}
|
||||||
|
|
||||||
registerOnTouched(fn: any): void {
|
registerOnTouched(fn: any): void {
|
||||||
this.onTouched = fn;
|
this.onTouched = fn;
|
||||||
}
|
}
|
||||||
updateValidationMessage(): void {
|
updateValidationMessage(): void {
|
||||||
this.validationMessage = this.validationMessagesService.getMessage(this.name);
|
this.validationMessage = this.validationMessagesService.getMessage(this.name);
|
||||||
}
|
}
|
||||||
setDisabledState?(isDisabled: boolean): void {}
|
setDisabledState?(isDisabled: boolean): void {}
|
||||||
toggleTooltip(event: Event) {
|
toggleTooltip(event: Event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.isTooltipVisible = !this.isTooltipVisible;
|
this.isTooltipVisible = !this.isTooltipVisible;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,68 @@
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, Input } from '@angular/core';
|
import { Component, Input } from '@angular/core';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
|
|
||||||
export interface BreadcrumbItem {
|
export interface BreadcrumbItem {
|
||||||
label: string;
|
label: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-breadcrumbs',
|
selector: 'app-breadcrumbs',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterModule],
|
imports: [CommonModule, RouterModule],
|
||||||
template: `
|
template: `
|
||||||
<nav aria-label="Breadcrumb" class="mb-4">
|
<nav aria-label="Breadcrumb" class="mb-4">
|
||||||
<ol
|
<ol
|
||||||
class="flex flex-wrap items-center text-sm text-neutral-600"
|
class="flex flex-wrap items-center text-sm text-neutral-600"
|
||||||
itemscope
|
itemscope
|
||||||
itemtype="https://schema.org/BreadcrumbList"
|
itemtype="https://schema.org/BreadcrumbList"
|
||||||
>
|
>
|
||||||
@for (item of breadcrumbs; track $index) {
|
@for (item of breadcrumbs; track $index) {
|
||||||
<li
|
<li
|
||||||
class="inline-flex items-center"
|
class="inline-flex items-center"
|
||||||
itemprop="itemListElement"
|
itemprop="itemListElement"
|
||||||
itemscope
|
itemscope
|
||||||
itemtype="https://schema.org/ListItem"
|
itemtype="https://schema.org/ListItem"
|
||||||
>
|
>
|
||||||
@if ($index > 0) {
|
@if ($index > 0) {
|
||||||
<span class="inline-flex items-center mx-2 text-neutral-400 select-none">
|
<span class="inline-flex items-center mx-2 text-neutral-400 select-none">
|
||||||
<i class="fas fa-chevron-right text-xs"></i>
|
<i class="fas fa-chevron-right text-xs"></i>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (item.url && $index < breadcrumbs.length - 1) {
|
@if (item.url && $index < breadcrumbs.length - 1) {
|
||||||
<a
|
<a
|
||||||
[routerLink]="item.url"
|
[routerLink]="item.url"
|
||||||
class="inline-flex items-center hover:text-blue-600 transition-colors"
|
class="inline-flex items-center hover:text-blue-600 transition-colors"
|
||||||
itemprop="item"
|
itemprop="item"
|
||||||
>
|
>
|
||||||
@if (item.icon) {
|
@if (item.icon) {
|
||||||
<i [class]="item.icon + ' mr-1'"></i>
|
<i [class]="item.icon + ' mr-1'"></i>
|
||||||
}
|
}
|
||||||
<span itemprop="name">{{ item.label }}</span>
|
<span itemprop="name">{{ item.label }}</span>
|
||||||
</a>
|
</a>
|
||||||
} @else {
|
} @else {
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center font-semibold text-neutral-900"
|
class="inline-flex items-center font-semibold text-neutral-900"
|
||||||
itemprop="item"
|
itemprop="item"
|
||||||
>
|
>
|
||||||
@if (item.icon) {
|
@if (item.icon) {
|
||||||
<i [class]="item.icon + ' mr-1'"></i>
|
<i [class]="item.icon + ' mr-1'"></i>
|
||||||
}
|
}
|
||||||
<span itemprop="name">{{ item.label }}</span>
|
<span itemprop="name">{{ item.label }}</span>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
<meta itemprop="position" [content]="($index + 1).toString()" />
|
<meta itemprop="position" [content]="($index + 1).toString()" />
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
`,
|
`,
|
||||||
styles: []
|
styles: []
|
||||||
})
|
})
|
||||||
export class BreadcrumbsComponent {
|
export class BreadcrumbsComponent {
|
||||||
@Input() breadcrumbs: BreadcrumbItem[] = [];
|
@Input() breadcrumbs: BreadcrumbItem[] = [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,138 +1,138 @@
|
||||||
import { AfterViewInit, Component, ElementRef, HostBinding, Input, OnDestroy, ViewChild, PLATFORM_ID, inject } from '@angular/core';
|
import { AfterViewInit, Component, ElementRef, HostBinding, Input, OnDestroy, ViewChild, PLATFORM_ID, inject } from '@angular/core';
|
||||||
import { isPlatformBrowser } from '@angular/common';
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
import { createPopper, Instance as PopperInstance } from '@popperjs/core';
|
import { createPopper, Instance as PopperInstance } from '@popperjs/core';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-dropdown',
|
selector: 'app-dropdown',
|
||||||
template: `
|
template: `
|
||||||
<div #targetEl [class.hidden]="!isVisible" class="z-50">
|
<div #targetEl [class.hidden]="!isVisible" class="z-50">
|
||||||
<ng-content></ng-content>
|
<ng-content></ng-content>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
})
|
})
|
||||||
export class DropdownComponent implements AfterViewInit, OnDestroy {
|
export class DropdownComponent implements AfterViewInit, OnDestroy {
|
||||||
@ViewChild('targetEl') targetEl!: ElementRef<HTMLElement>;
|
@ViewChild('targetEl') targetEl!: ElementRef<HTMLElement>;
|
||||||
@Input() triggerEl!: HTMLElement;
|
@Input() triggerEl!: HTMLElement;
|
||||||
|
|
||||||
@Input() placement: any = 'bottom';
|
@Input() placement: any = 'bottom';
|
||||||
@Input() triggerType: 'click' | 'hover' = 'click';
|
@Input() triggerType: 'click' | 'hover' = 'click';
|
||||||
@Input() offsetSkidding: number = 0;
|
@Input() offsetSkidding: number = 0;
|
||||||
@Input() offsetDistance: number = 10;
|
@Input() offsetDistance: number = 10;
|
||||||
@Input() delay: number = 300;
|
@Input() delay: number = 300;
|
||||||
@Input() ignoreClickOutsideClass: string | false = false;
|
@Input() ignoreClickOutsideClass: string | false = false;
|
||||||
|
|
||||||
@HostBinding('class.hidden') isHidden: boolean = true;
|
@HostBinding('class.hidden') isHidden: boolean = true;
|
||||||
|
|
||||||
private platformId = inject(PLATFORM_ID);
|
private platformId = inject(PLATFORM_ID);
|
||||||
private isBrowser = isPlatformBrowser(this.platformId);
|
private isBrowser = isPlatformBrowser(this.platformId);
|
||||||
private popperInstance: PopperInstance | null = null;
|
private popperInstance: PopperInstance | null = null;
|
||||||
isVisible: boolean = false;
|
isVisible: boolean = false;
|
||||||
private clickOutsideListener: any;
|
private clickOutsideListener: any;
|
||||||
private hoverShowListener: any;
|
private hoverShowListener: any;
|
||||||
private hoverHideListener: any;
|
private hoverHideListener: any;
|
||||||
|
|
||||||
ngAfterViewInit() {
|
ngAfterViewInit() {
|
||||||
if (!this.isBrowser) return;
|
if (!this.isBrowser) return;
|
||||||
|
|
||||||
if (!this.triggerEl) {
|
if (!this.triggerEl) {
|
||||||
console.error('Trigger element is not provided to the dropdown component.');
|
console.error('Trigger element is not provided to the dropdown component.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.initializePopper();
|
this.initializePopper();
|
||||||
this.setupEventListeners();
|
this.setupEventListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.destroyPopper();
|
this.destroyPopper();
|
||||||
this.removeEventListeners();
|
this.removeEventListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
private initializePopper() {
|
private initializePopper() {
|
||||||
this.popperInstance = createPopper(this.triggerEl, this.targetEl.nativeElement, {
|
this.popperInstance = createPopper(this.triggerEl, this.targetEl.nativeElement, {
|
||||||
placement: this.placement,
|
placement: this.placement,
|
||||||
modifiers: [
|
modifiers: [
|
||||||
{
|
{
|
||||||
name: 'offset',
|
name: 'offset',
|
||||||
options: {
|
options: {
|
||||||
offset: [this.offsetSkidding, this.offsetDistance],
|
offset: [this.offsetSkidding, this.offsetDistance],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupEventListeners() {
|
private setupEventListeners() {
|
||||||
if (!this.isBrowser) return;
|
if (!this.isBrowser) return;
|
||||||
|
|
||||||
if (this.triggerType === 'click') {
|
if (this.triggerType === 'click') {
|
||||||
this.triggerEl.addEventListener('click', () => this.toggle());
|
this.triggerEl.addEventListener('click', () => this.toggle());
|
||||||
} else if (this.triggerType === 'hover') {
|
} else if (this.triggerType === 'hover') {
|
||||||
this.hoverShowListener = () => this.show();
|
this.hoverShowListener = () => this.show();
|
||||||
this.hoverHideListener = () => this.hide();
|
this.hoverHideListener = () => this.hide();
|
||||||
this.triggerEl.addEventListener('mouseenter', this.hoverShowListener);
|
this.triggerEl.addEventListener('mouseenter', this.hoverShowListener);
|
||||||
this.triggerEl.addEventListener('mouseleave', this.hoverHideListener);
|
this.triggerEl.addEventListener('mouseleave', this.hoverHideListener);
|
||||||
this.targetEl.nativeElement.addEventListener('mouseenter', this.hoverShowListener);
|
this.targetEl.nativeElement.addEventListener('mouseenter', this.hoverShowListener);
|
||||||
this.targetEl.nativeElement.addEventListener('mouseleave', this.hoverHideListener);
|
this.targetEl.nativeElement.addEventListener('mouseleave', this.hoverHideListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.clickOutsideListener = (event: MouseEvent) => this.handleClickOutside(event);
|
this.clickOutsideListener = (event: MouseEvent) => this.handleClickOutside(event);
|
||||||
document.addEventListener('click', this.clickOutsideListener);
|
document.addEventListener('click', this.clickOutsideListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
private removeEventListeners() {
|
private removeEventListeners() {
|
||||||
if (!this.isBrowser) return;
|
if (!this.isBrowser) return;
|
||||||
|
|
||||||
if (this.triggerType === 'click') {
|
if (this.triggerType === 'click') {
|
||||||
this.triggerEl.removeEventListener('click', () => this.toggle());
|
this.triggerEl.removeEventListener('click', () => this.toggle());
|
||||||
} else if (this.triggerType === 'hover') {
|
} else if (this.triggerType === 'hover') {
|
||||||
this.triggerEl.removeEventListener('mouseenter', this.hoverShowListener);
|
this.triggerEl.removeEventListener('mouseenter', this.hoverShowListener);
|
||||||
this.triggerEl.removeEventListener('mouseleave', this.hoverHideListener);
|
this.triggerEl.removeEventListener('mouseleave', this.hoverHideListener);
|
||||||
this.targetEl.nativeElement.removeEventListener('mouseenter', this.hoverShowListener);
|
this.targetEl.nativeElement.removeEventListener('mouseenter', this.hoverShowListener);
|
||||||
this.targetEl.nativeElement.removeEventListener('mouseleave', this.hoverHideListener);
|
this.targetEl.nativeElement.removeEventListener('mouseleave', this.hoverHideListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.removeEventListener('click', this.clickOutsideListener);
|
document.removeEventListener('click', this.clickOutsideListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
this.isVisible ? this.hide() : this.show();
|
this.isVisible ? this.hide() : this.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
show() {
|
show() {
|
||||||
this.isVisible = true;
|
this.isVisible = true;
|
||||||
this.isHidden = false;
|
this.isHidden = false;
|
||||||
this.targetEl.nativeElement.classList.remove('hidden');
|
this.targetEl.nativeElement.classList.remove('hidden');
|
||||||
this.popperInstance?.update();
|
this.popperInstance?.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
hide() {
|
hide() {
|
||||||
this.isVisible = false;
|
this.isVisible = false;
|
||||||
this.isHidden = true;
|
this.isHidden = true;
|
||||||
this.targetEl.nativeElement.classList.add('hidden');
|
this.targetEl.nativeElement.classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleClickOutside(event: MouseEvent) {
|
private handleClickOutside(event: MouseEvent) {
|
||||||
if (!this.isVisible || !this.isBrowser) return;
|
if (!this.isVisible || !this.isBrowser) return;
|
||||||
|
|
||||||
const clickedElement = event.target as HTMLElement;
|
const clickedElement = event.target as HTMLElement;
|
||||||
if (this.ignoreClickOutsideClass) {
|
if (this.ignoreClickOutsideClass) {
|
||||||
const ignoredElements = document.querySelectorAll(`.${this.ignoreClickOutsideClass}`);
|
const ignoredElements = document.querySelectorAll(`.${this.ignoreClickOutsideClass}`);
|
||||||
const arr = Array.from(ignoredElements);
|
const arr = Array.from(ignoredElements);
|
||||||
for (const el of arr) {
|
for (const el of arr) {
|
||||||
if (el.contains(clickedElement)) return;
|
if (el.contains(clickedElement)) return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.targetEl.nativeElement.contains(clickedElement) && !this.triggerEl.contains(clickedElement)) {
|
if (!this.targetEl.nativeElement.contains(clickedElement) && !this.triggerEl.contains(clickedElement)) {
|
||||||
this.hide();
|
this.hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private destroyPopper() {
|
private destroyPopper() {
|
||||||
if (this.popperInstance) {
|
if (this.popperInstance) {
|
||||||
this.popperInstance.destroy();
|
this.popperInstance.destroy();
|
||||||
this.popperInstance = null;
|
this.popperInstance = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,48 @@
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||||
import { ShareByEMail } from '../../../../../bizmatch-server/src/models/db.model';
|
import { ShareByEMail } from '../../../../../bizmatch-server/src/models/db.model';
|
||||||
import { MailService } from '../../services/mail.service';
|
import { MailService } from '../../services/mail.service';
|
||||||
import { ValidatedInputComponent } from '../validated-input/validated-input.component';
|
import { ValidatedInputComponent } from '../validated-input/validated-input.component';
|
||||||
import { ValidationMessagesService } from '../validation-messages.service';
|
import { ValidationMessagesService } from '../validation-messages.service';
|
||||||
import { EMailService } from './email.service';
|
import { EMailService } from './email.service';
|
||||||
|
|
||||||
@UntilDestroy()
|
@UntilDestroy()
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-email',
|
selector: 'app-email',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, ValidatedInputComponent],
|
imports: [CommonModule, FormsModule, ValidatedInputComponent],
|
||||||
templateUrl: './email.component.html',
|
templateUrl: './email.component.html',
|
||||||
template: ``,
|
template: ``,
|
||||||
})
|
})
|
||||||
export class EMailComponent {
|
export class EMailComponent {
|
||||||
shareByEMail: ShareByEMail = {
|
shareByEMail: ShareByEMail = {
|
||||||
yourName: '',
|
yourName: '',
|
||||||
recipientEmail: '',
|
recipientEmail: '',
|
||||||
yourEmail: '',
|
yourEmail: '',
|
||||||
type: 'business',
|
type: 'business',
|
||||||
listingTitle: '',
|
listingTitle: '',
|
||||||
url: '',
|
url: '',
|
||||||
id: ''
|
id: ''
|
||||||
};
|
};
|
||||||
constructor(public eMailService: EMailService, private mailService: MailService, private validationMessagesService: ValidationMessagesService) {}
|
constructor(public eMailService: EMailService, private mailService: MailService, private validationMessagesService: ValidationMessagesService) {}
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.eMailService.shareByEMail$.pipe(untilDestroyed(this)).subscribe(val => {
|
this.eMailService.shareByEMail$.pipe(untilDestroyed(this)).subscribe(val => {
|
||||||
this.shareByEMail = val;
|
this.shareByEMail = val;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async sendMail() {
|
async sendMail() {
|
||||||
try {
|
try {
|
||||||
const result = await this.mailService.mailToFriend(this.shareByEMail);
|
const result = await this.mailService.mailToFriend(this.shareByEMail);
|
||||||
this.eMailService.accept(this.shareByEMail);
|
this.eMailService.accept(this.shareByEMail);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.error && Array.isArray(error.error?.message)) {
|
if (error.error && Array.isArray(error.error?.message)) {
|
||||||
this.validationMessagesService.updateMessages(error.error.message);
|
this.validationMessagesService.updateMessages(error.error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
|
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,93 +1,93 @@
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, Input, OnInit } from '@angular/core';
|
import { Component, Input, OnInit } from '@angular/core';
|
||||||
import { SeoService } from '../../services/seo.service';
|
import { SeoService } from '../../services/seo.service';
|
||||||
|
|
||||||
export interface FAQItem {
|
export interface FAQItem {
|
||||||
question: string;
|
question: string;
|
||||||
answer: string;
|
answer: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-faq',
|
selector: 'app-faq',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
template: `
|
template: `
|
||||||
<section class="bg-white rounded-lg shadow-lg p-6 md:p-8 my-8">
|
<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>
|
<h2 class="text-2xl md:text-3xl font-bold text-gray-900 mb-6">Frequently Asked Questions</h2>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@for (item of faqItems; track $index) {
|
@for (item of faqItems; track $index) {
|
||||||
<div class="border-b border-gray-200 pb-4">
|
<div class="border-b border-gray-200 pb-4">
|
||||||
<button
|
<button
|
||||||
(click)="toggle($index)"
|
(click)="toggle($index)"
|
||||||
class="w-full text-left flex justify-between items-center py-2 hover:text-blue-600 transition-colors"
|
class="w-full text-left flex justify-between items-center py-2 hover:text-blue-600 transition-colors"
|
||||||
[attr.aria-expanded]="openIndex === $index"
|
[attr.aria-expanded]="openIndex === $index"
|
||||||
>
|
>
|
||||||
<h3 class="text-lg font-semibold text-gray-800">{{ item.question }}</h3>
|
<h3 class="text-lg font-semibold text-gray-800">{{ item.question }}</h3>
|
||||||
<svg
|
<svg
|
||||||
class="w-5 h-5 transition-transform"
|
class="w-5 h-5 transition-transform"
|
||||||
[class.rotate-180]="openIndex === $index"
|
[class.rotate-180]="openIndex === $index"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@if (openIndex === $index) {
|
@if (openIndex === $index) {
|
||||||
<div class="mt-3 text-gray-600 leading-relaxed">
|
<div class="mt-3 text-gray-600 leading-relaxed">
|
||||||
<p [innerHTML]="item.answer"></p>
|
<p [innerHTML]="item.answer"></p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
`,
|
`,
|
||||||
styles: [`
|
styles: [`
|
||||||
.rotate-180 {
|
.rotate-180 {
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
`]
|
`]
|
||||||
})
|
})
|
||||||
export class FaqComponent implements OnInit {
|
export class FaqComponent implements OnInit {
|
||||||
@Input() faqItems: FAQItem[] = [];
|
@Input() faqItems: FAQItem[] = [];
|
||||||
openIndex: number | null = null;
|
openIndex: number | null = null;
|
||||||
|
|
||||||
constructor(private seoService: SeoService) {}
|
constructor(private seoService: SeoService) {}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
// Generate and inject FAQ Schema for rich snippets
|
// Generate and inject FAQ Schema for rich snippets
|
||||||
if (this.faqItems.length > 0) {
|
if (this.faqItems.length > 0) {
|
||||||
const faqSchema = {
|
const faqSchema = {
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'FAQPage',
|
'@type': 'FAQPage',
|
||||||
'mainEntity': this.faqItems.map(item => ({
|
'mainEntity': this.faqItems.map(item => ({
|
||||||
'@type': 'Question',
|
'@type': 'Question',
|
||||||
'name': item.question,
|
'name': item.question,
|
||||||
'acceptedAnswer': {
|
'acceptedAnswer': {
|
||||||
'@type': 'Answer',
|
'@type': 'Answer',
|
||||||
'text': this.stripHtml(item.answer)
|
'text': this.stripHtml(item.answer)
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|
||||||
this.seoService.injectStructuredData(faqSchema);
|
this.seoService.injectStructuredData(faqSchema);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle(index: number) {
|
toggle(index: number) {
|
||||||
this.openIndex = this.openIndex === index ? null : index;
|
this.openIndex = this.openIndex === index ? null : index;
|
||||||
}
|
}
|
||||||
|
|
||||||
private stripHtml(html: string): string {
|
private stripHtml(html: string): string {
|
||||||
const tmp = document.createElement('DIV');
|
const tmp = document.createElement('DIV');
|
||||||
tmp.innerHTML = html;
|
tmp.innerHTML = html;
|
||||||
return tmp.textContent || tmp.innerText || '';
|
return tmp.textContent || tmp.innerText || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.seoService.clearStructuredData();
|
this.seoService.clearStructuredData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,33 @@
|
||||||
<footer class="bg-white px-4 py-2 md:px-6 mt-auto w-full print:hidden">
|
<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="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">
|
<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" /> -->
|
<!-- <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">
|
<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" />
|
<img src="/assets/images/header-logo.png" class="h-8 mb-2 lg:mb-0 lg:mr-4" alt="BizMatch Logo" />
|
||||||
</a>
|
</a>
|
||||||
<p class="text-sm text-neutral-600 text-center lg:text-left">© {{ currentYear }} Bizmatch All rights reserved.</p>
|
<p class="text-sm text-neutral-600 text-center lg:text-left">© {{ currentYear }} Bizmatch All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col lg:flex-row items-center order-3 lg:order-2">
|
<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
|
<a class="text-sm text-primary-600 hover:underline hover:cursor-pointer mx-2" routerLink="/terms-of-use">Terms of
|
||||||
use</a>
|
use</a>
|
||||||
<a class="text-sm text-primary-600 hover:underline hover:cursor-pointer mx-2"
|
<a class="text-sm text-primary-600 hover:underline hover:cursor-pointer mx-2"
|
||||||
routerLink="/privacy-statement">Privacy statement</a>
|
routerLink="/privacy-statement">Privacy statement</a>
|
||||||
<!-- <a class="text-sm text-primary-600 hover:underline hover:cursor-pointer mx-2" routerLink="/pricingOverview">Pricing</a> -->
|
<!-- <a class="text-sm text-primary-600 hover:underline hover:cursor-pointer mx-2" routerLink="/pricingOverview">Pricing</a> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col lg:flex-row items-center order-2 lg:order-3">
|
<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">
|
<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 mb-1 lg:mb-2">BizMatch, Inc., 1001 Blucher Street, Corpus</p>
|
||||||
<p class="text-sm text-neutral-600">Christi, Texas 78401</p>
|
<p class="text-sm text-neutral-600">Christi, Texas 78401</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4 lg:mb-0 flex flex-col items-center lg:items-end">
|
<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
|
<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>
|
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
|
<a class="text-sm text-neutral-600 hover:text-primary-600"> <i
|
||||||
class="fas fa-envelope mr-2"></i>info@bizmatch.net </a>
|
class="fas fa-envelope mr-2"></i>info@bizmatch.net </a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
@ -1,22 +1,22 @@
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router, RouterModule } from '@angular/router';
|
import { Router, RouterModule } from '@angular/router';
|
||||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-footer',
|
selector: 'app-footer',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, RouterModule, FontAwesomeModule],
|
imports: [CommonModule, FormsModule, RouterModule, FontAwesomeModule],
|
||||||
templateUrl: './footer.component.html',
|
templateUrl: './footer.component.html',
|
||||||
styleUrl: './footer.component.scss',
|
styleUrl: './footer.component.scss',
|
||||||
})
|
})
|
||||||
export class FooterComponent {
|
export class FooterComponent {
|
||||||
privacyVisible = false;
|
privacyVisible = false;
|
||||||
termsVisible = false;
|
termsVisible = false;
|
||||||
currentYear: number = new Date().getFullYear();
|
currentYear: number = new Date().getFullYear();
|
||||||
constructor(private router: Router) {}
|
constructor(private router: Router) {}
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
// Flowbite is now initialized once in AppComponent
|
// Flowbite is now initialized once in AppComponent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,210 +1,210 @@
|
||||||
<nav class="bg-white border-neutral-200 dark:bg-neutral-900 print:hidden">
|
<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">
|
<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">
|
<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"
|
<img src="/assets/images/header-logo.png" class="h-10 w-auto"
|
||||||
alt="BizMatch - Business Marketplace for Buying and Selling Businesses" />
|
alt="BizMatch - Business Marketplace for Buying and Selling Businesses" />
|
||||||
</a>
|
</a>
|
||||||
<div class="flex items-center md:order-2 space-x-3 rtl:space-x-reverse">
|
<div class="flex items-center md:order-2 space-x-3 rtl:space-x-reverse">
|
||||||
<!-- Filter button -->
|
<!-- Filter button -->
|
||||||
@if(isFilterUrl()){
|
@if(isFilterUrl()){
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button type="button" id="sortDropdownButton"
|
<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"
|
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()"
|
(click)="toggleSortDropdown()"
|
||||||
[ngClass]="{ 'text-primary-500': selectOptions.getSortByOption(sortBy) !== 'Sort', 'text-neutral-900': selectOptions.getSortByOption(sortBy) === 'Sort' }">
|
[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) }}
|
<i class="fas fa-sort mr-2"></i>{{ selectOptions.getSortByOption(sortBy) }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Sort options dropdown -->
|
<!-- Sort options dropdown -->
|
||||||
<div *ngIf="sortDropdownVisible"
|
<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">
|
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">
|
<ul class="py-1 text-sm text-neutral-700 dark:text-neutral-200">
|
||||||
@for(item of sortByOptions; track item){
|
@for(item of sortByOptions; track item){
|
||||||
<li (click)="sortByFct(item.value)"
|
<li (click)="sortByFct(item.value)"
|
||||||
class="block px-4 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-700 cursor-pointer">{{ item.selectName ?
|
class="block px-4 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-700 cursor-pointer">{{ item.selectName ?
|
||||||
item.selectName : item.name }}</li>
|
item.selectName : item.name }}</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<button type="button"
|
<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"
|
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'">
|
id="user-menu-button" aria-expanded="false" [attr.data-dropdown-toggle]="user ? 'user-login' : 'user-unknown'">
|
||||||
<span class="sr-only">Open user menu</span>
|
<span class="sr-only">Open user menu</span>
|
||||||
@if(isProfessional || (authService.isAdmin() | async) && user?.hasProfile){
|
@if(isProfessional || (authService.isAdmin() | async) && user?.hasProfile){
|
||||||
<img class="w-8 h-8 rounded-full object-cover" src="{{ profileUrl }}"
|
<img class="w-8 h-8 rounded-full object-cover" src="{{ profileUrl }}"
|
||||||
alt="{{ user?.firstname }} {{ user?.lastname }} profile photo" width="32" height="32" />
|
alt="{{ user?.firstname }} {{ user?.lastname }} profile photo" width="32" height="32" />
|
||||||
} @else {
|
} @else {
|
||||||
<i class="flex justify-center items-center text-stone-50 w-8 h-8 rounded-full fa-solid fa-bars"></i>
|
<i class="flex justify-center items-center text-stone-50 w-8 h-8 rounded-full fa-solid fa-bars"></i>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
<!-- Dropdown menu -->
|
<!-- Dropdown menu -->
|
||||||
@if(user){
|
@if(user){
|
||||||
<div
|
<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"
|
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">
|
id="user-login">
|
||||||
<div class="px-4 py-3">
|
<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-900 dark:text-white">Welcome, {{ user.firstname }} </span>
|
||||||
<span class="block text-sm text-neutral-500 truncate dark:text-neutral-400">{{ user.email }}</span>
|
<span class="block text-sm text-neutral-500 truncate dark:text-neutral-400">{{ user.email }}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul class="py-2" aria-labelledby="user-menu-button">
|
<ul class="py-2" aria-labelledby="user-menu-button">
|
||||||
<li>
|
<li>
|
||||||
<a routerLink="/account" (click)="closeDropdown()"
|
<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>
|
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>
|
</li>
|
||||||
@if((user.customerType==='professional' && user.customerSubType==='broker') || user.customerType==='seller' ||
|
@if((user.customerType==='professional' && user.customerSubType==='broker') || user.customerType==='seller' ||
|
||||||
(authService.isAdmin() | async)){
|
(authService.isAdmin() | async)){
|
||||||
<li>
|
<li>
|
||||||
@if(user.customerType==='professional'){
|
@if(user.customerType==='professional'){
|
||||||
<a routerLink="/createBusinessListing" (click)="closeDropdown()"
|
<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
|
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>
|
Listing</a>
|
||||||
}@else {
|
}@else {
|
||||||
<a routerLink="/createCommercialPropertyListing" (click)="closeDropdown()"
|
<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
|
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>
|
Listing</a>
|
||||||
}
|
}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a routerLink="/myListings" (click)="closeDropdown()"
|
<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
|
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>
|
Listings</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
<li>
|
<li>
|
||||||
<a routerLink="/myFavorites" (click)="closeDropdown()"
|
<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
|
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>
|
Favorites</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a routerLink="/emailUs" (click)="closeDropdown()"
|
<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
|
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>
|
Us</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a routerLink="/logout" (click)="closeDropdown()"
|
<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>
|
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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@if(authService.isAdmin() | async){
|
@if(authService.isAdmin() | async){
|
||||||
<ul class="py-2">
|
<ul class="py-2">
|
||||||
<li>
|
<li>
|
||||||
<a routerLink="admin/users" (click)="closeDropdown()"
|
<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
|
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>
|
(Admin)</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
<ul class="py-2 md:hidden">
|
<ul class="py-2 md:hidden">
|
||||||
<li>
|
<li>
|
||||||
<a routerLink="/businessListings"
|
<a routerLink="/businessListings"
|
||||||
[ngClass]="{ 'text-primary-600': isActive('/businessListings'), 'text-neutral-700': !isActive('/businessListings') }"
|
[ngClass]="{ 'text-primary-600': isActive('/businessListings'), 'text-neutral-700': !isActive('/businessListings') }"
|
||||||
class="block px-4 py-2 text-sm font-semibold"
|
class="block px-4 py-2 text-sm font-semibold"
|
||||||
(click)="closeMenusAndSetCriteria('businessListings')">Businesses</a>
|
(click)="closeMenusAndSetCriteria('businessListings')">Businesses</a>
|
||||||
</li>
|
</li>
|
||||||
@if ((numberOfCommercial$ | async) > 0) {
|
@if ((numberOfCommercial$ | async) > 0) {
|
||||||
<li>
|
<li>
|
||||||
<a routerLink="/commercialPropertyListings"
|
<a routerLink="/commercialPropertyListings"
|
||||||
[ngClass]="{ 'text-primary-600': isActive('/commercialPropertyListings'), 'text-neutral-700': !isActive('/commercialPropertyListings') }"
|
[ngClass]="{ 'text-primary-600': isActive('/commercialPropertyListings'), 'text-neutral-700': !isActive('/commercialPropertyListings') }"
|
||||||
class="block px-4 py-2 text-sm font-semibold"
|
class="block px-4 py-2 text-sm font-semibold"
|
||||||
(click)="closeMenusAndSetCriteria('commercialPropertyListings')">Properties</a>
|
(click)="closeMenusAndSetCriteria('commercialPropertyListings')">Properties</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
<li>
|
<li>
|
||||||
<a routerLink="/brokerListings"
|
<a routerLink="/brokerListings"
|
||||||
[ngClass]="{ 'text-primary-600': isActive('/brokerListings'), 'text-neutral-700': !isActive('/brokerListings') }"
|
[ngClass]="{ 'text-primary-600': isActive('/brokerListings'), 'text-neutral-700': !isActive('/brokerListings') }"
|
||||||
class="block px-4 py-2 text-sm font-semibold"
|
class="block px-4 py-2 text-sm font-semibold"
|
||||||
(click)="closeMenusAndSetCriteria('brokerListings')">Professionals</a>
|
(click)="closeMenusAndSetCriteria('brokerListings')">Professionals</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div
|
<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"
|
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">
|
id="user-unknown">
|
||||||
<ul class="py-2" aria-labelledby="user-menu-button">
|
<ul class="py-2" aria-labelledby="user-menu-button">
|
||||||
<li>
|
<li>
|
||||||
<a routerLink="/login" [queryParams]="{ mode: 'login' }"
|
<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
|
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>
|
In</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a routerLink="/login" [queryParams]="{ mode: 'register' }"
|
<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
|
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>
|
Up</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="py-2 md:hidden">
|
<ul class="py-2 md:hidden">
|
||||||
<li>
|
<li>
|
||||||
<a routerLink="/businessListings"
|
<a routerLink="/businessListings"
|
||||||
[ngClass]="{ 'text-primary-600': isActive('/businessListings'), 'text-neutral-700': !isActive('/businessListings') }"
|
[ngClass]="{ 'text-primary-600': isActive('/businessListings'), 'text-neutral-700': !isActive('/businessListings') }"
|
||||||
class="block px-4 py-2 text-sm font-bold"
|
class="block px-4 py-2 text-sm font-bold"
|
||||||
(click)="closeMenusAndSetCriteria('businessListings')">Businesses</a>
|
(click)="closeMenusAndSetCriteria('businessListings')">Businesses</a>
|
||||||
</li>
|
</li>
|
||||||
@if ((numberOfCommercial$ | async) > 0) {
|
@if ((numberOfCommercial$ | async) > 0) {
|
||||||
<li>
|
<li>
|
||||||
<a routerLink="/commercialPropertyListings"
|
<a routerLink="/commercialPropertyListings"
|
||||||
[ngClass]="{ 'text-primary-600': isActive('/commercialPropertyListings'), 'text-neutral-700': !isActive('/commercialPropertyListings') }"
|
[ngClass]="{ 'text-primary-600': isActive('/commercialPropertyListings'), 'text-neutral-700': !isActive('/commercialPropertyListings') }"
|
||||||
class="block px-4 py-2 text-sm font-bold"
|
class="block px-4 py-2 text-sm font-bold"
|
||||||
(click)="closeMenusAndSetCriteria('commercialPropertyListings')">Properties</a>
|
(click)="closeMenusAndSetCriteria('commercialPropertyListings')">Properties</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
<li>
|
<li>
|
||||||
<a routerLink="/brokerListings"
|
<a routerLink="/brokerListings"
|
||||||
[ngClass]="{ 'text-primary-600': isActive('/brokerListings'), 'text-neutral-700': !isActive('/brokerListings') }"
|
[ngClass]="{ 'text-primary-600': isActive('/brokerListings'), 'text-neutral-700': !isActive('/brokerListings') }"
|
||||||
class="block px-4 py-2 text-sm font-bold"
|
class="block px-4 py-2 text-sm font-bold"
|
||||||
(click)="closeMenusAndSetCriteria('brokerListings')">Professionals</a>
|
(click)="closeMenusAndSetCriteria('brokerListings')">Professionals</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="items-center justify-between hidden w-full md:flex md:w-auto md:order-1" id="navbar-user">
|
<div class="items-center justify-between hidden w-full md:flex md:w-auto md:order-1" id="navbar-user">
|
||||||
<ul
|
<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">
|
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>
|
<li>
|
||||||
<a routerLinkActive="active-link" routerLink="/businessListings"
|
<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') }"
|
[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"
|
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')">
|
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"
|
<img src="/assets/images/business_logo.png" alt="Business" class="w-5 h-5 mr-2 object-contain" width="20"
|
||||||
height="20" />
|
height="20" />
|
||||||
<span>Businesses</span>
|
<span>Businesses</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@if ((numberOfCommercial$ | async) > 0) {
|
@if ((numberOfCommercial$ | async) > 0) {
|
||||||
<li>
|
<li>
|
||||||
<a routerLinkActive="active-link" routerLink="/commercialPropertyListings"
|
<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') }"
|
[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"
|
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')">
|
(click)="closeMenusAndSetCriteria('commercialPropertyListings')">
|
||||||
<img src="/assets/images/properties_logo.png" alt="Properties" class="w-5 h-5 mr-2 object-contain"
|
<img src="/assets/images/properties_logo.png" alt="Properties" class="w-5 h-5 mr-2 object-contain"
|
||||||
width="20" height="20" />
|
width="20" height="20" />
|
||||||
<span>Properties</span>
|
<span>Properties</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
<li>
|
<li>
|
||||||
<a routerLinkActive="active-link" routerLink="/brokerListings"
|
<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') }"
|
[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"
|
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')">
|
(click)="closeMenusAndSetCriteria('brokerListings')">
|
||||||
<img src="/assets/images/icon_professionals.png" alt="Professionals"
|
<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;" />
|
class="w-5 h-5 mr-2 object-contain bg-transparent" style="mix-blend-mode: darken;" />
|
||||||
<span>Professionals</span>
|
<span>Professionals</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Mobile filter button -->
|
<!-- Mobile filter button -->
|
||||||
<div class="md:hidden flex justify-center pb-4">
|
<div class="md:hidden flex justify-center pb-4">
|
||||||
<button (click)="toggleSortDropdown()" type="button" id="sortDropdownMobileButton"
|
<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"
|
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' }">
|
[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) }}
|
<i class="fas fa-sort mr-2"></i>{{ selectOptions.getSortByOption(sortBy) }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -1,322 +1,322 @@
|
||||||
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
||||||
import { Component, HostListener, OnDestroy, OnInit, AfterViewInit, PLATFORM_ID, inject } from '@angular/core';
|
import { Component, HostListener, OnDestroy, OnInit, AfterViewInit, PLATFORM_ID, inject } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { NavigationEnd, Router, RouterModule } from '@angular/router';
|
import { NavigationEnd, Router, RouterModule } from '@angular/router';
|
||||||
import { faUserGear } from '@fortawesome/free-solid-svg-icons';
|
import { faUserGear } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||||
import { Collapse, Dropdown, initFlowbite } from 'flowbite';
|
import { Collapse, Dropdown, initFlowbite } from 'flowbite';
|
||||||
import { filter, Observable, Subject, takeUntil } from 'rxjs';
|
import { filter, Observable, Subject, takeUntil } from 'rxjs';
|
||||||
|
|
||||||
import { SortByOptions, User } from '../../../../../bizmatch-server/src/models/db.model';
|
import { SortByOptions, User } from '../../../../../bizmatch-server/src/models/db.model';
|
||||||
import { emailToDirName, KeycloakUser, KeyValueAsSortBy, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
|
import { emailToDirName, KeycloakUser, KeyValueAsSortBy, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { AuthService } from '../../services/auth.service';
|
import { AuthService } from '../../services/auth.service';
|
||||||
import { FilterStateService } from '../../services/filter-state.service';
|
import { FilterStateService } from '../../services/filter-state.service';
|
||||||
import { ListingsService } from '../../services/listings.service';
|
import { ListingsService } from '../../services/listings.service';
|
||||||
import { SearchService } from '../../services/search.service';
|
import { SearchService } from '../../services/search.service';
|
||||||
import { SelectOptionsService } from '../../services/select-options.service';
|
import { SelectOptionsService } from '../../services/select-options.service';
|
||||||
import { SharedService } from '../../services/shared.service';
|
import { SharedService } from '../../services/shared.service';
|
||||||
import { UserService } from '../../services/user.service';
|
import { UserService } from '../../services/user.service';
|
||||||
import { map2User } from '../../utils/utils';
|
import { map2User } from '../../utils/utils';
|
||||||
|
|
||||||
import { ModalService } from '../search-modal/modal.service';
|
import { ModalService } from '../search-modal/modal.service';
|
||||||
|
|
||||||
@UntilDestroy()
|
@UntilDestroy()
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'header',
|
selector: 'header',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterModule, FormsModule],
|
imports: [CommonModule, RouterModule, FormsModule],
|
||||||
templateUrl: './header.component.html',
|
templateUrl: './header.component.html',
|
||||||
styleUrl: './header.component.scss',
|
styleUrl: './header.component.scss',
|
||||||
})
|
})
|
||||||
export class HeaderComponent implements OnInit, OnDestroy, AfterViewInit {
|
export class HeaderComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||||
public buildVersion = environment.buildVersion;
|
public buildVersion = environment.buildVersion;
|
||||||
user$: Observable<KeycloakUser>;
|
user$: Observable<KeycloakUser>;
|
||||||
keycloakUser: KeycloakUser;
|
keycloakUser: KeycloakUser;
|
||||||
user: User;
|
user: User;
|
||||||
activeItem;
|
activeItem;
|
||||||
faUserGear = faUserGear;
|
faUserGear = faUserGear;
|
||||||
profileUrl: string;
|
profileUrl: string;
|
||||||
env = environment;
|
env = environment;
|
||||||
private filterDropdown: Dropdown | null = null;
|
private filterDropdown: Dropdown | null = null;
|
||||||
isMobile: boolean = false;
|
isMobile: boolean = false;
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
prompt: string;
|
prompt: string;
|
||||||
private platformId = inject(PLATFORM_ID);
|
private platformId = inject(PLATFORM_ID);
|
||||||
private isBrowser = isPlatformBrowser(this.platformId);
|
private isBrowser = isPlatformBrowser(this.platformId);
|
||||||
|
|
||||||
// Aktueller Listing-Typ basierend auf Route
|
// Aktueller Listing-Typ basierend auf Route
|
||||||
currentListingType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings' | null = null;
|
currentListingType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings' | null = null;
|
||||||
|
|
||||||
// Sortierung
|
// Sortierung
|
||||||
sortDropdownVisible: boolean = false;
|
sortDropdownVisible: boolean = false;
|
||||||
sortByOptions: KeyValueAsSortBy[] = [];
|
sortByOptions: KeyValueAsSortBy[] = [];
|
||||||
sortBy: SortByOptions = null;
|
sortBy: SortByOptions = null;
|
||||||
|
|
||||||
// Observable für Anzahl der Listings
|
// Observable für Anzahl der Listings
|
||||||
numberOfBroker$: Observable<number>;
|
numberOfBroker$: Observable<number>;
|
||||||
numberOfCommercial$: Observable<number>;
|
numberOfCommercial$: Observable<number>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
private sharedService: SharedService,
|
private sharedService: SharedService,
|
||||||
private modalService: ModalService,
|
private modalService: ModalService,
|
||||||
private searchService: SearchService,
|
private searchService: SearchService,
|
||||||
private filterStateService: FilterStateService,
|
private filterStateService: FilterStateService,
|
||||||
public selectOptions: SelectOptionsService,
|
public selectOptions: SelectOptionsService,
|
||||||
public authService: AuthService,
|
public authService: AuthService,
|
||||||
private listingService: ListingsService,
|
private listingService: ListingsService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
@HostListener('document:click', ['$event'])
|
@HostListener('document:click', ['$event'])
|
||||||
handleGlobalClick(event: Event) {
|
handleGlobalClick(event: Event) {
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
// Don't close sort dropdown when clicking on sort buttons or user menu button
|
// Don't close sort dropdown when clicking on sort buttons or user menu button
|
||||||
const excludedIds = ['sortDropdownButton', 'sortDropdownMobileButton', 'user-menu-button'];
|
const excludedIds = ['sortDropdownButton', 'sortDropdownMobileButton', 'user-menu-button'];
|
||||||
if (!excludedIds.includes(target.id) && !target.closest('#user-menu-button')) {
|
if (!excludedIds.includes(target.id) && !target.closest('#user-menu-button')) {
|
||||||
this.sortDropdownVisible = false;
|
this.sortDropdownVisible = false;
|
||||||
|
|
||||||
// Close User Menu if clicked outside
|
// Close User Menu if clicked outside
|
||||||
// We check if the click was inside the menu containers
|
// We check if the click was inside the menu containers
|
||||||
const userLogin = document.getElementById('user-login');
|
const userLogin = document.getElementById('user-login');
|
||||||
const userUnknown = document.getElementById('user-unknown');
|
const userUnknown = document.getElementById('user-unknown');
|
||||||
const clickedInsideMenu = (userLogin && userLogin.contains(target)) || (userUnknown && userUnknown.contains(target));
|
const clickedInsideMenu = (userLogin && userLogin.contains(target)) || (userUnknown && userUnknown.contains(target));
|
||||||
|
|
||||||
if (!clickedInsideMenu) {
|
if (!clickedInsideMenu) {
|
||||||
this.closeDropdown();
|
this.closeDropdown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
// User Setup
|
// User Setup
|
||||||
const token = await this.authService.getToken();
|
const token = await this.authService.getToken();
|
||||||
this.keycloakUser = map2User(token);
|
this.keycloakUser = map2User(token);
|
||||||
if (this.keycloakUser) {
|
if (this.keycloakUser) {
|
||||||
this.user = await this.userService.getByMail(this.keycloakUser?.email);
|
this.user = await this.userService.getByMail(this.keycloakUser?.email);
|
||||||
this.profileUrl = this.user.hasProfile ? `${this.env.imageBaseUrl}/pictures/profile/${emailToDirName(this.user.email)}.avif?_ts=${new Date().getTime()}` : `/assets/images/placeholder.png`;
|
this.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
|
// Lade Anzahl der Listings
|
||||||
this.numberOfBroker$ = this.userService.getNumberOfBroker(this.createEmptyUserListingCriteria());
|
this.numberOfBroker$ = this.userService.getNumberOfBroker(this.createEmptyUserListingCriteria());
|
||||||
this.numberOfCommercial$ = this.listingService.getNumberOfListings('commercialProperty');
|
this.numberOfCommercial$ = this.listingService.getNumberOfListings('commercialProperty');
|
||||||
|
|
||||||
// Flowbite is now initialized once in AppComponent
|
// Flowbite is now initialized once in AppComponent
|
||||||
|
|
||||||
// Profile Photo Updates
|
// Profile Photo Updates
|
||||||
this.sharedService.currentProfilePhoto.pipe(untilDestroyed(this)).subscribe(photoUrl => {
|
this.sharedService.currentProfilePhoto.pipe(untilDestroyed(this)).subscribe(photoUrl => {
|
||||||
this.profileUrl = photoUrl;
|
this.profileUrl = photoUrl;
|
||||||
});
|
});
|
||||||
|
|
||||||
// User Updates - re-initialize Flowbite when user state changes
|
// User Updates - re-initialize Flowbite when user state changes
|
||||||
// This ensures the dropdown bindings are updated when the dropdown target changes
|
// This ensures the dropdown bindings are updated when the dropdown target changes
|
||||||
this.userService.currentUser.pipe(untilDestroyed(this)).subscribe(u => {
|
this.userService.currentUser.pipe(untilDestroyed(this)).subscribe(u => {
|
||||||
const previousUser = this.user;
|
const previousUser = this.user;
|
||||||
this.user = u;
|
this.user = u;
|
||||||
// Re-initialize Flowbite if user logged in/out state changed
|
// Re-initialize Flowbite if user logged in/out state changed
|
||||||
if ((previousUser === null) !== (u === null) && this.isBrowser) {
|
if ((previousUser === null) !== (u === null) && this.isBrowser) {
|
||||||
setTimeout(() => initFlowbite(), 50);
|
setTimeout(() => initFlowbite(), 50);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Router Events
|
// Router Events
|
||||||
this.router.events
|
this.router.events
|
||||||
.pipe(
|
.pipe(
|
||||||
filter(event => event instanceof NavigationEnd),
|
filter(event => event instanceof NavigationEnd),
|
||||||
untilDestroyed(this),
|
untilDestroyed(this),
|
||||||
)
|
)
|
||||||
.subscribe((event: NavigationEnd) => {
|
.subscribe((event: NavigationEnd) => {
|
||||||
this.checkCurrentRoute(event.urlAfterRedirects);
|
this.checkCurrentRoute(event.urlAfterRedirects);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initial Route Check
|
// Initial Route Check
|
||||||
this.checkCurrentRoute(this.router.url);
|
this.checkCurrentRoute(this.router.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
private checkCurrentRoute(url: string): void {
|
private checkCurrentRoute(url: string): void {
|
||||||
const baseRoute = url.split('/')[1];
|
const baseRoute = url.split('/')[1];
|
||||||
|
|
||||||
// Bestimme den aktuellen Listing-Typ
|
// Bestimme den aktuellen Listing-Typ
|
||||||
if (baseRoute === 'businessListings') {
|
if (baseRoute === 'businessListings') {
|
||||||
this.currentListingType = 'businessListings';
|
this.currentListingType = 'businessListings';
|
||||||
} else if (baseRoute === 'commercialPropertyListings') {
|
} else if (baseRoute === 'commercialPropertyListings') {
|
||||||
this.currentListingType = 'commercialPropertyListings';
|
this.currentListingType = 'commercialPropertyListings';
|
||||||
} else if (baseRoute === 'brokerListings') {
|
} else if (baseRoute === 'brokerListings') {
|
||||||
this.currentListingType = 'brokerListings';
|
this.currentListingType = 'brokerListings';
|
||||||
} else {
|
} else {
|
||||||
this.currentListingType = null;
|
this.currentListingType = null;
|
||||||
return; // Keine relevante Route für Filter/Sort
|
return; // Keine relevante Route für Filter/Sort
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup für diese Route
|
// Setup für diese Route
|
||||||
this.setupSortByOptions();
|
this.setupSortByOptions();
|
||||||
this.subscribeToStateChanges();
|
this.subscribeToStateChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
private subscribeToStateChanges(): void {
|
private subscribeToStateChanges(): void {
|
||||||
if (!this.currentListingType) return;
|
if (!this.currentListingType) return;
|
||||||
|
|
||||||
// Abonniere State-Änderungen für den aktuellen Listing-Typ
|
// Abonniere State-Änderungen für den aktuellen Listing-Typ
|
||||||
this.filterStateService
|
this.filterStateService
|
||||||
.getState$(this.currentListingType)
|
.getState$(this.currentListingType)
|
||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe(state => {
|
.subscribe(state => {
|
||||||
this.sortBy = state.sortBy;
|
this.sortBy = state.sortBy;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupSortByOptions(): void {
|
private setupSortByOptions(): void {
|
||||||
this.sortByOptions = [];
|
this.sortByOptions = [];
|
||||||
|
|
||||||
if (!this.currentListingType) return;
|
if (!this.currentListingType) return;
|
||||||
|
|
||||||
switch (this.currentListingType) {
|
switch (this.currentListingType) {
|
||||||
case 'brokerListings':
|
case 'brokerListings':
|
||||||
this.sortByOptions = [...this.selectOptions.sortByOptions.filter(s => s.type === 'professional')];
|
this.sortByOptions = [...this.selectOptions.sortByOptions.filter(s => s.type === 'professional')];
|
||||||
break;
|
break;
|
||||||
case 'businessListings':
|
case 'businessListings':
|
||||||
this.sortByOptions = [...this.selectOptions.sortByOptions.filter(s => s.type === 'business' || s.type === 'listing')];
|
this.sortByOptions = [...this.selectOptions.sortByOptions.filter(s => s.type === 'business' || s.type === 'listing')];
|
||||||
break;
|
break;
|
||||||
case 'commercialPropertyListings':
|
case 'commercialPropertyListings':
|
||||||
this.sortByOptions = [...this.selectOptions.sortByOptions.filter(s => s.type === 'commercial' || s.type === 'listing')];
|
this.sortByOptions = [...this.selectOptions.sortByOptions.filter(s => s.type === 'commercial' || s.type === 'listing')];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Füge generische Optionen hinzu (ohne type)
|
// Füge generische Optionen hinzu (ohne type)
|
||||||
this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => !s.type)];
|
this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => !s.type)];
|
||||||
}
|
}
|
||||||
|
|
||||||
sortByFct(selectedSortBy: SortByOptions): void {
|
sortByFct(selectedSortBy: SortByOptions): void {
|
||||||
if (!this.currentListingType) return;
|
if (!this.currentListingType) return;
|
||||||
|
|
||||||
this.sortDropdownVisible = false;
|
this.sortDropdownVisible = false;
|
||||||
|
|
||||||
// Update sortBy im State
|
// Update sortBy im State
|
||||||
this.filterStateService.updateSortBy(this.currentListingType, selectedSortBy);
|
this.filterStateService.updateSortBy(this.currentListingType, selectedSortBy);
|
||||||
|
|
||||||
// Trigger search
|
// Trigger search
|
||||||
this.searchService.search(this.currentListingType);
|
this.searchService.search(this.currentListingType);
|
||||||
}
|
}
|
||||||
|
|
||||||
async openModal() {
|
async openModal() {
|
||||||
if (!this.currentListingType) return;
|
if (!this.currentListingType) return;
|
||||||
|
|
||||||
const criteria = this.filterStateService.getCriteria(this.currentListingType);
|
const criteria = this.filterStateService.getCriteria(this.currentListingType);
|
||||||
const modalResult = await this.modalService.showModal(criteria);
|
const modalResult = await this.modalService.showModal(criteria);
|
||||||
|
|
||||||
if (modalResult.accepted) {
|
if (modalResult.accepted) {
|
||||||
this.searchService.search(this.currentListingType);
|
this.searchService.search(this.currentListingType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
navigateWithState(dest: string, state: any) {
|
navigateWithState(dest: string, state: any) {
|
||||||
this.router.navigate([dest], { state: state });
|
this.router.navigate([dest], { state: state });
|
||||||
}
|
}
|
||||||
|
|
||||||
isActive(route: string): boolean {
|
isActive(route: string): boolean {
|
||||||
return this.router.url === route;
|
return this.router.url === route;
|
||||||
}
|
}
|
||||||
|
|
||||||
isFilterUrl(): boolean {
|
isFilterUrl(): boolean {
|
||||||
return ['/businessListings', '/commercialPropertyListings', '/brokerListings'].includes(this.router.url);
|
return ['/businessListings', '/commercialPropertyListings', '/brokerListings'].includes(this.router.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
isBusinessListing(): boolean {
|
isBusinessListing(): boolean {
|
||||||
return this.router.url === '/businessListings';
|
return this.router.url === '/businessListings';
|
||||||
}
|
}
|
||||||
|
|
||||||
isCommercialPropertyListing(): boolean {
|
isCommercialPropertyListing(): boolean {
|
||||||
return this.router.url === '/commercialPropertyListings';
|
return this.router.url === '/commercialPropertyListings';
|
||||||
}
|
}
|
||||||
|
|
||||||
isProfessionalListing(): boolean {
|
isProfessionalListing(): boolean {
|
||||||
return this.router.url === '/brokerListings';
|
return this.router.url === '/brokerListings';
|
||||||
}
|
}
|
||||||
|
|
||||||
closeDropdown() {
|
closeDropdown() {
|
||||||
if (!this.isBrowser) return;
|
if (!this.isBrowser) return;
|
||||||
|
|
||||||
const dropdownButton = document.getElementById('user-menu-button');
|
const dropdownButton = document.getElementById('user-menu-button');
|
||||||
const dropdownMenu = this.user ? document.getElementById('user-login') : document.getElementById('user-unknown');
|
const dropdownMenu = this.user ? document.getElementById('user-login') : document.getElementById('user-unknown');
|
||||||
|
|
||||||
if (dropdownButton && dropdownMenu) {
|
if (dropdownButton && dropdownMenu) {
|
||||||
const dropdown = new Dropdown(dropdownMenu, dropdownButton);
|
const dropdown = new Dropdown(dropdownMenu, dropdownButton);
|
||||||
dropdown.hide();
|
dropdown.hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
closeMobileMenu() {
|
closeMobileMenu() {
|
||||||
if (!this.isBrowser) return;
|
if (!this.isBrowser) return;
|
||||||
|
|
||||||
const targetElement = document.getElementById('navbar-user');
|
const targetElement = document.getElementById('navbar-user');
|
||||||
const triggerElement = document.querySelector('[data-collapse-toggle="navbar-user"]');
|
const triggerElement = document.querySelector('[data-collapse-toggle="navbar-user"]');
|
||||||
|
|
||||||
if (targetElement instanceof HTMLElement && triggerElement instanceof HTMLElement) {
|
if (targetElement instanceof HTMLElement && triggerElement instanceof HTMLElement) {
|
||||||
const collapse = new Collapse(targetElement, triggerElement);
|
const collapse = new Collapse(targetElement, triggerElement);
|
||||||
collapse.collapse();
|
collapse.collapse();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
closeMenusAndSetCriteria(path: string) {
|
closeMenusAndSetCriteria(path: string) {
|
||||||
this.closeDropdown();
|
this.closeDropdown();
|
||||||
this.closeMobileMenu();
|
this.closeMobileMenu();
|
||||||
|
|
||||||
// Bestimme Listing-Typ aus dem Pfad
|
// Bestimme Listing-Typ aus dem Pfad
|
||||||
let listingType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings' | null = null;
|
let listingType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings' | null = null;
|
||||||
|
|
||||||
if (path === 'businessListings') {
|
if (path === 'businessListings') {
|
||||||
listingType = 'businessListings';
|
listingType = 'businessListings';
|
||||||
} else if (path === 'commercialPropertyListings') {
|
} else if (path === 'commercialPropertyListings') {
|
||||||
listingType = 'commercialPropertyListings';
|
listingType = 'commercialPropertyListings';
|
||||||
} else if (path === 'brokerListings') {
|
} else if (path === 'brokerListings') {
|
||||||
listingType = 'brokerListings';
|
listingType = 'brokerListings';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (listingType) {
|
if (listingType) {
|
||||||
// Reset Pagination beim Wechsel zwischen Views
|
// Reset Pagination beim Wechsel zwischen Views
|
||||||
this.filterStateService.updateCriteria(listingType, {
|
this.filterStateService.updateCriteria(listingType, {
|
||||||
page: 1,
|
page: 1,
|
||||||
start: 0,
|
start: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSortDropdown() {
|
toggleSortDropdown() {
|
||||||
this.sortDropdownVisible = !this.sortDropdownVisible;
|
this.sortDropdownVisible = !this.sortDropdownVisible;
|
||||||
}
|
}
|
||||||
|
|
||||||
get isProfessional() {
|
get isProfessional() {
|
||||||
return this.user?.customerType === 'professional';
|
return this.user?.customerType === 'professional';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper method für leere UserListingCriteria
|
// Helper method für leere UserListingCriteria
|
||||||
private createEmptyUserListingCriteria(): UserListingCriteria {
|
private createEmptyUserListingCriteria(): UserListingCriteria {
|
||||||
return {
|
return {
|
||||||
criteriaType: 'brokerListings',
|
criteriaType: 'brokerListings',
|
||||||
types: [],
|
types: [],
|
||||||
state: null,
|
state: null,
|
||||||
city: null,
|
city: null,
|
||||||
radius: null,
|
radius: null,
|
||||||
searchType: 'exact' as const,
|
searchType: 'exact' as const,
|
||||||
brokerName: null,
|
brokerName: null,
|
||||||
companyName: null,
|
companyName: null,
|
||||||
counties: [],
|
counties: [],
|
||||||
prompt: null,
|
prompt: null,
|
||||||
page: 1,
|
page: 1,
|
||||||
start: 0,
|
start: 0,
|
||||||
length: 12,
|
length: 12,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
// Flowbite initialization is now handled manually or via AppComponent
|
// Flowbite initialization is now handled manually or via AppComponent
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.destroy$.next();
|
this.destroy$.next();
|
||||||
this.destroy$.complete();
|
this.destroy$.complete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,106 +1,106 @@
|
||||||
<div class="flex flex-col items-center justify-center min-h-screen">
|
<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">
|
<div class="bg-white p-8 rounded-lg drop-shadow-custom-bg w-full max-w-md">
|
||||||
<!-- Home Button -->
|
<!-- Home Button -->
|
||||||
<div class="flex justify-end mb-4">
|
<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">
|
<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>
|
<i class="fas fa-home mr-2"></i>
|
||||||
Home
|
Home
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="text-2xl font-bold mb-6 text-center text-gray-800">
|
<h2 class="text-2xl font-bold mb-6 text-center text-gray-800">
|
||||||
{{ isLoginMode ? 'Login' : 'Sign Up' }}
|
{{ isLoginMode ? 'Login' : 'Sign Up' }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<!-- Toggle Switch mit Flowbite -->
|
<!-- Toggle Switch mit Flowbite -->
|
||||||
<div class="flex items-center justify-center mb-6">
|
<div class="flex items-center justify-center mb-6">
|
||||||
<span class="mr-3 text-gray-700 font-medium">Login</span>
|
<span class="mr-3 text-gray-700 font-medium">Login</span>
|
||||||
<label for="toggle-switch" class="inline-flex relative items-center cursor-pointer">
|
<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()" />
|
<input type="checkbox" id="toggle-switch" class="sr-only peer" [checked]="!isLoginMode" (change)="toggleMode()" />
|
||||||
<div
|
<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"
|
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>
|
></div>
|
||||||
</label>
|
</label>
|
||||||
<span class="ml-3 text-gray-700 font-medium">Sign Up</span>
|
<span class="ml-3 text-gray-700 font-medium">Sign Up</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- E-Mail Eingabe -->
|
<!-- E-Mail Eingabe -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="email" class="block text-gray-700 mb-2 font-medium">E-Mail</label>
|
<label for="email" class="block text-gray-700 mb-2 font-medium">E-Mail</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
[(ngModel)]="email"
|
[(ngModel)]="email"
|
||||||
placeholder="Please enter E-Mail Address"
|
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"
|
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>
|
<fa-icon [icon]="envelope" class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></fa-icon>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Passwort Eingabe -->
|
<!-- Passwort Eingabe -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="password" class="block text-gray-700 mb-2 font-medium">Password</label>
|
<label for="password" class="block text-gray-700 mb-2 font-medium">Password</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
[(ngModel)]="password"
|
[(ngModel)]="password"
|
||||||
placeholder="Please enter 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"
|
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>
|
<fa-icon [icon]="lock" class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></fa-icon>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Passwort-Bestätigung nur im Registrierungsmodus -->
|
<!-- Passwort-Bestätigung nur im Registrierungsmodus -->
|
||||||
<div *ngIf="!isLoginMode" class="mb-6">
|
<div *ngIf="!isLoginMode" class="mb-6">
|
||||||
<label for="confirmPassword" class="block text-gray-700 mb-2 font-medium">Confirm Password</label>
|
<label for="confirmPassword" class="block text-gray-700 mb-2 font-medium">Confirm Password</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
type="password"
|
type="password"
|
||||||
[(ngModel)]="confirmPassword"
|
[(ngModel)]="confirmPassword"
|
||||||
placeholder="Repeat Password"
|
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"
|
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>
|
<fa-icon [icon]="lock" class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></fa-icon>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fehlermeldung -->
|
<!-- Fehlermeldung -->
|
||||||
<div *ngIf="errorMessage" class="text-red-500 text-center mb-4 text-sm">
|
<div *ngIf="errorMessage" class="text-red-500 text-center mb-4 text-sm">
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Submit Button -->
|
<!-- 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">
|
<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> -->
|
<!-- <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-user-plus mr-2"></i>
|
||||||
<i *ngIf="!isLoginMode" class="fa-solid fa-arrow-right mr-2"></i>
|
<i *ngIf="!isLoginMode" class="fa-solid fa-arrow-right mr-2"></i>
|
||||||
{{ isLoginMode ? 'Sign in with Email' : 'Sign Up' }}
|
{{ isLoginMode ? 'Sign in with Email' : 'Sign Up' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Trennlinie -->
|
<!-- Trennlinie -->
|
||||||
<div class="flex items-center justify-center my-4">
|
<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="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="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>
|
<span class="border-b w-1/5 md:w-1/4 border-gray-300"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Google Button -->
|
<!-- 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">
|
<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">
|
<svg class="w-6 h-6 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
|
||||||
<path
|
<path
|
||||||
fill="#FFC107"
|
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"
|
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="#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="#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" />
|
<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>
|
</svg>
|
||||||
Continue with Google
|
Continue with Google
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,95 +1,95 @@
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||||
import { faArrowRight, faEnvelope, faLock, faUserPlus } from '@fortawesome/free-solid-svg-icons';
|
import { faArrowRight, faEnvelope, faLock, faUserPlus } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { AuthService } from '../../services/auth.service';
|
import { AuthService } from '../../services/auth.service';
|
||||||
import { LoadingService } from '../../services/loading.service';
|
import { LoadingService } from '../../services/loading.service';
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-login-register',
|
selector: 'app-login-register',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, FontAwesomeModule, RouterModule],
|
imports: [CommonModule, FormsModule, FontAwesomeModule, RouterModule],
|
||||||
templateUrl: './login-register.component.html',
|
templateUrl: './login-register.component.html',
|
||||||
})
|
})
|
||||||
export class LoginRegisterComponent {
|
export class LoginRegisterComponent {
|
||||||
email: string = '';
|
email: string = '';
|
||||||
password: string = '';
|
password: string = '';
|
||||||
confirmPassword: string = '';
|
confirmPassword: string = '';
|
||||||
isLoginMode: boolean = true; // true: Login, false: Registration
|
isLoginMode: boolean = true; // true: Login, false: Registration
|
||||||
errorMessage: string = '';
|
errorMessage: string = '';
|
||||||
envelope = faEnvelope;
|
envelope = faEnvelope;
|
||||||
lock = faLock;
|
lock = faLock;
|
||||||
arrowRight = faArrowRight;
|
arrowRight = faArrowRight;
|
||||||
userplus = faUserPlus;
|
userplus = faUserPlus;
|
||||||
constructor(private authService: AuthService, private route: ActivatedRoute, private router: Router, private loadingService: LoadingService) {}
|
constructor(private authService: AuthService, private route: ActivatedRoute, private router: Router, private loadingService: LoadingService) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
// Set mode based on query parameter "mode"
|
// Set mode based on query parameter "mode"
|
||||||
this.route.queryParamMap.subscribe(params => {
|
this.route.queryParamMap.subscribe(params => {
|
||||||
const mode = params.get('mode');
|
const mode = params.get('mode');
|
||||||
this.isLoginMode = mode !== 'register';
|
this.isLoginMode = mode !== 'register';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleMode(): void {
|
toggleMode(): void {
|
||||||
this.isLoginMode = !this.isLoginMode;
|
this.isLoginMode = !this.isLoginMode;
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login with Email
|
// Login with Email
|
||||||
onSubmit(): void {
|
onSubmit(): void {
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
if (this.isLoginMode) {
|
if (this.isLoginMode) {
|
||||||
this.authService
|
this.authService
|
||||||
.loginWithEmail(this.email, this.password)
|
.loginWithEmail(this.email, this.password)
|
||||||
.then(userCredential => {
|
.then(userCredential => {
|
||||||
console.log('Successfully logged in:', userCredential);
|
console.log('Successfully logged in:', userCredential);
|
||||||
this.router.navigate([`myListing`]);
|
this.router.navigate([`myListing`]);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Error during email login:', error);
|
console.error('Error during email login:', error);
|
||||||
this.errorMessage = error.message;
|
this.errorMessage = error.message;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Registration mode: also check if passwords match
|
// Registration mode: also check if passwords match
|
||||||
if (this.password !== this.confirmPassword) {
|
if (this.password !== this.confirmPassword) {
|
||||||
console.error('Passwords do not match');
|
console.error('Passwords do not match');
|
||||||
this.errorMessage = 'Passwords do not match.';
|
this.errorMessage = 'Passwords do not match.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.loadingService.startLoading('googleAuth');
|
this.loadingService.startLoading('googleAuth');
|
||||||
this.authService
|
this.authService
|
||||||
.registerWithEmail(this.email, this.password)
|
.registerWithEmail(this.email, this.password)
|
||||||
.then(userCredential => {
|
.then(userCredential => {
|
||||||
console.log('Successfully registered:', userCredential);
|
console.log('Successfully registered:', userCredential);
|
||||||
this.loadingService.stopLoading('googleAuth');
|
this.loadingService.stopLoading('googleAuth');
|
||||||
this.router.navigate(['emailVerification']);
|
this.router.navigate(['emailVerification']);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
this.loadingService.stopLoading('googleAuth');
|
this.loadingService.stopLoading('googleAuth');
|
||||||
console.error('Error during registration:', error);
|
console.error('Error during registration:', error);
|
||||||
if (error.code === 'auth/email-already-in-use') {
|
if (error.code === 'auth/email-already-in-use') {
|
||||||
this.errorMessage = 'This email address is already in use. Please try logging in.';
|
this.errorMessage = 'This email address is already in use. Please try logging in.';
|
||||||
} else {
|
} else {
|
||||||
this.errorMessage = error.message;
|
this.errorMessage = error.message;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login with Google
|
// Login with Google
|
||||||
loginWithGoogle(): void {
|
loginWithGoogle(): void {
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
this.authService
|
this.authService
|
||||||
.loginWithGoogle()
|
.loginWithGoogle()
|
||||||
.then(userCredential => {
|
.then(userCredential => {
|
||||||
console.log('Successfully logged in with Google:', userCredential);
|
console.log('Successfully logged in with Google:', userCredential);
|
||||||
this.router.navigate([`myListing`]);
|
this.router.navigate([`myListing`]);
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Error during Google login:', error);
|
console.error('Error during Google login:', error);
|
||||||
this.errorMessage = error.message;
|
this.errorMessage = error.message;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,40 @@
|
||||||
<!-- <section class="bg-white dark:bg-gray-900">
|
<!-- <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="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">
|
<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>
|
<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-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>
|
<p class="mb-4 text-lg font-light text-gray-500 dark:text-gray-400">Sorry, we can't find that page.</p>
|
||||||
<a
|
<a
|
||||||
routerLink="/home"
|
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"
|
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
|
>Back to Homepage</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section> -->
|
</section> -->
|
||||||
<section class="bg-white dark:bg-gray-900">
|
<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="py-8 px-4 mx-auto max-w-screen-xl lg:py-16 lg:px-6">
|
||||||
<!-- Breadcrumbs -->
|
<!-- Breadcrumbs -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
|
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mx-auto max-w-screen-sm text-center">
|
<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>
|
<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-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>
|
<p class="mb-4 text-lg font-light text-gray-500 dark:text-gray-400">Sorry, we can't find that page</p>
|
||||||
<!-- <a
|
<!-- <a
|
||||||
href="#"
|
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"
|
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
|
>Back to Homepage</a
|
||||||
> -->
|
> -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
[routerLink]="['/home']"
|
[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"
|
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
|
Back to Homepage
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,32 @@
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { SeoService } from '../../services/seo.service';
|
import { SeoService } from '../../services/seo.service';
|
||||||
import { BreadcrumbItem, BreadcrumbsComponent } from '../breadcrumbs/breadcrumbs.component';
|
import { BreadcrumbItem, BreadcrumbsComponent } from '../breadcrumbs/breadcrumbs.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-not-found',
|
selector: 'app-not-found',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterModule, BreadcrumbsComponent],
|
imports: [CommonModule, RouterModule, BreadcrumbsComponent],
|
||||||
templateUrl: './not-found.component.html',
|
templateUrl: './not-found.component.html',
|
||||||
})
|
})
|
||||||
export class NotFoundComponent implements OnInit {
|
export class NotFoundComponent implements OnInit {
|
||||||
breadcrumbs: BreadcrumbItem[] = [
|
breadcrumbs: BreadcrumbItem[] = [
|
||||||
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
|
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
|
||||||
{ label: '404 - Page Not Found' }
|
{ label: '404 - Page Not Found' }
|
||||||
];
|
];
|
||||||
|
|
||||||
constructor(private seoService: SeoService) {}
|
constructor(private seoService: SeoService) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
// Set noindex to prevent 404 pages from being indexed
|
// Set noindex to prevent 404 pages from being indexed
|
||||||
this.seoService.setNoIndex();
|
this.seoService.setNoIndex();
|
||||||
|
|
||||||
// Set appropriate meta tags for 404 page
|
// Set appropriate meta tags for 404 page
|
||||||
this.seoService.updateMetaTags({
|
this.seoService.updateMetaTags({
|
||||||
title: '404 - Page Not Found | BizMatch',
|
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.',
|
description: 'The page you are looking for could not be found. Return to BizMatch to browse businesses for sale or commercial properties.',
|
||||||
type: 'website'
|
type: 'website'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,260 +1,260 @@
|
||||||
<div
|
<div
|
||||||
*ngIf="isModal && (modalService.modalVisible$ | async)?.visible && (modalService.modalVisible$ | async)?.type === 'brokerListings'"
|
*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"
|
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 w-full h-screen max-h-screen">
|
||||||
<div class="relative bg-white rounded-lg shadow h-full">
|
<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">
|
<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>
|
<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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span class="sr-only">Close Modal</span>
|
<span class="sr-only">Close Modal</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6 space-y-6">
|
<div class="p-6 space-y-6">
|
||||||
<div class="flex space-x-4 mb-4">
|
<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>
|
<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>
|
<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">
|
<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
|
Clear all Filter
|
||||||
<div class="tooltip-arrow" data-popper-arrow></div>
|
<div class="tooltip-arrow" data-popper-arrow></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Display active filters as tags -->
|
<!-- Display active filters as tags -->
|
||||||
<div class="flex flex-wrap gap-2 mb-4" *ngIf="hasActiveFilters()">
|
<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">
|
<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>
|
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Types: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Professional Name: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Company: {{ criteria.companyName }} <button (click)="removeFilter('companyName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Areas Served: {{ criteria.counties.join(', ') }} <button (click)="removeFilter('counties')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@if(criteria.criteriaType==='brokerListings') {
|
@if(criteria.criteriaType==='brokerListings') {
|
||||||
<div class="grid grid-cols-1 gap-6">
|
<div class="grid grid-cols-1 gap-6">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="state" class="block mb-2 text-sm font-medium text-neutral-900">Location - State</label>
|
<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>
|
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<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>
|
||||||
<div *ngIf="criteria.city">
|
<div *ngIf="criteria.city">
|
||||||
<label class="block mb-2 text-sm font-medium text-neutral-900">Search Type</label>
|
<label class="block mb-2 text-sm font-medium text-neutral-900">Search Type</label>
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<label class="inline-flex items-center">
|
<label class="inline-flex items-center">
|
||||||
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="exact" />
|
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="exact" />
|
||||||
<span class="ml-2">Exact City</span>
|
<span class="ml-2">Exact City</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="inline-flex items-center">
|
<label class="inline-flex items-center">
|
||||||
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
|
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
|
||||||
<span class="ml-2">Radius Search</span>
|
<span class="ml-2">Radius Search</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2">
|
<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>
|
<label class="block mb-2 text-sm font-medium text-neutral-900">Select Radius (in miles)</label>
|
||||||
<div class="flex flex-wrap">
|
<div class="flex flex-wrap">
|
||||||
@for (radius of [5, 20, 50, 100, 200, 300, 400, 500]; track radius) {
|
@for (radius of [5, 20, 50, 100, 200, 300, 400, 500]; track radius) {
|
||||||
<button
|
<button
|
||||||
type="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"
|
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'"
|
[ngClass]="criteria.radius === radius ? 'text-white bg-gray-500' : 'text-neutral-900 bg-white'"
|
||||||
(click)="setRadius(radius)"
|
(click)="setRadius(radius)"
|
||||||
>
|
>
|
||||||
{{ radius }}
|
{{ radius }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block mb-2 text-sm font-medium text-neutral-900">Professional Type</label>
|
<label class="block mb-2 text-sm font-medium text-neutral-900">Professional Type</label>
|
||||||
<ng-select
|
<ng-select
|
||||||
class="custom"
|
class="custom"
|
||||||
[items]="selectOptions.customerSubTypes"
|
[items]="selectOptions.customerSubTypes"
|
||||||
bindLabel="name"
|
bindLabel="name"
|
||||||
bindValue="value"
|
bindValue="value"
|
||||||
[ngModel]="criteria.types"
|
[ngModel]="criteria.types"
|
||||||
(ngModelChange)="onCategoryChange($event)"
|
(ngModelChange)="onCategoryChange($event)"
|
||||||
[multiple]="true"
|
[multiple]="true"
|
||||||
[closeOnSelect]="true"
|
[closeOnSelect]="true"
|
||||||
placeholder="Select professional types"
|
placeholder="Select professional types"
|
||||||
></ng-select>
|
></ng-select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="brokerName" class="block mb-2 text-sm font-medium text-neutral-900">Professional Name</label>
|
<label for="brokerName" class="block mb-2 text-sm font-medium text-neutral-900">Professional Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="brokerName"
|
id="brokerName"
|
||||||
[ngModel]="criteria.brokerName"
|
[ngModel]="criteria.brokerName"
|
||||||
(ngModelChange)="updateCriteria({ brokerName: $event })"
|
(ngModelChange)="updateCriteria({ brokerName: $event })"
|
||||||
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
|
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
|
||||||
placeholder="e.g. John Smith"
|
placeholder="e.g. John Smith"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="companyName" class="block mb-2 text-sm font-medium text-neutral-900">Company Name</label>
|
<label for="companyName" class="block mb-2 text-sm font-medium text-neutral-900">Company Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="companyName"
|
id="companyName"
|
||||||
[ngModel]="criteria.companyName"
|
[ngModel]="criteria.companyName"
|
||||||
(ngModelChange)="updateCriteria({ companyName: $event })"
|
(ngModelChange)="updateCriteria({ companyName: $event })"
|
||||||
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
|
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
|
||||||
placeholder="e.g. ABC Brokers"
|
placeholder="e.g. ABC Brokers"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block mb-2 text-sm font-medium text-neutral-900">Counties / Areas Served</label>
|
<label class="block mb-2 text-sm font-medium text-neutral-900">Counties / Areas Served</label>
|
||||||
<ng-select
|
<ng-select
|
||||||
class="custom"
|
class="custom"
|
||||||
[items]="counties$ | async"
|
[items]="counties$ | async"
|
||||||
[multiple]="true"
|
[multiple]="true"
|
||||||
[loading]="countyLoading"
|
[loading]="countyLoading"
|
||||||
[typeahead]="countyInput$"
|
[typeahead]="countyInput$"
|
||||||
[ngModel]="criteria.counties"
|
[ngModel]="criteria.counties"
|
||||||
(ngModelChange)="onCountiesChange($event)"
|
(ngModelChange)="onCountiesChange($event)"
|
||||||
[closeOnSelect]="true"
|
[closeOnSelect]="true"
|
||||||
placeholder="Type to search counties"
|
placeholder="Type to search counties"
|
||||||
></ng-select>
|
></ng-select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="!isModal" class="space-y-6 pb-10">
|
<div *ngIf="!isModal" class="space-y-6 pb-10">
|
||||||
<div class="flex space-x-4 mb-4">
|
<div class="flex space-x-4 mb-4">
|
||||||
<h3 class="text-xl font-semibold text-neutral-900">Filter ({{ numberOfResults$ | async }})</h3>
|
<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>
|
<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">
|
<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
|
Clear all Filter
|
||||||
<div class="tooltip-arrow" data-popper-arrow></div>
|
<div class="tooltip-arrow" data-popper-arrow></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Display active filters as tags -->
|
<!-- Display active filters as tags -->
|
||||||
<div class="flex flex-wrap gap-2" *ngIf="hasActiveFilters()">
|
<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">
|
<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>
|
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Types: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Professional Name: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Company: {{ criteria.companyName }} <button (click)="removeFilter('companyName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Areas Served: {{ criteria.counties.join(', ') }} <button (click)="removeFilter('counties')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@if(criteria.criteriaType==='brokerListings') {
|
@if(criteria.criteriaType==='brokerListings') {
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="state" class="block mb-2 text-sm font-medium text-neutral-900">Location - State</label>
|
<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>
|
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<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>
|
||||||
<div *ngIf="criteria.city">
|
<div *ngIf="criteria.city">
|
||||||
<label class="block mb-2 text-sm font-medium text-neutral-900">Search Type</label>
|
<label class="block mb-2 text-sm font-medium text-neutral-900">Search Type</label>
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<label class="inline-flex items-center">
|
<label class="inline-flex items-center">
|
||||||
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="exact" />
|
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="exact" />
|
||||||
<span class="ml-2">Exact City</span>
|
<span class="ml-2">Exact City</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="inline-flex items-center">
|
<label class="inline-flex items-center">
|
||||||
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
|
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
|
||||||
<span class="ml-2">Radius Search</span>
|
<span class="ml-2">Radius Search</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2">
|
<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>
|
<label class="block mb-2 text-sm font-medium text-neutral-900">Select Radius (in miles)</label>
|
||||||
<div class="flex flex-wrap">
|
<div class="flex flex-wrap">
|
||||||
@for (radius of [5, 20, 50, 100, 200, 300, 400, 500]; track radius) {
|
@for (radius of [5, 20, 50, 100, 200, 300, 400, 500]; track radius) {
|
||||||
<button
|
<button
|
||||||
type="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"
|
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'"
|
[ngClass]="criteria.radius === radius ? 'text-white bg-gray-500' : 'text-neutral-900 bg-white'"
|
||||||
(click)="setRadius(radius)"
|
(click)="setRadius(radius)"
|
||||||
>
|
>
|
||||||
{{ radius }}
|
{{ radius }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block mb-2 text-sm font-medium text-neutral-900">Professional Type</label>
|
<label class="block mb-2 text-sm font-medium text-neutral-900">Professional Type</label>
|
||||||
<ng-select
|
<ng-select
|
||||||
class="custom"
|
class="custom"
|
||||||
[items]="selectOptions.customerSubTypes"
|
[items]="selectOptions.customerSubTypes"
|
||||||
bindLabel="name"
|
bindLabel="name"
|
||||||
bindValue="value"
|
bindValue="value"
|
||||||
[ngModel]="criteria.types"
|
[ngModel]="criteria.types"
|
||||||
(ngModelChange)="onCategoryChange($event)"
|
(ngModelChange)="onCategoryChange($event)"
|
||||||
[multiple]="true"
|
[multiple]="true"
|
||||||
[closeOnSelect]="true"
|
[closeOnSelect]="true"
|
||||||
placeholder="Select professional types"
|
placeholder="Select professional types"
|
||||||
></ng-select>
|
></ng-select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="brokerName" class="block mb-2 text-sm font-medium text-neutral-900">Professional Name</label>
|
<label for="brokerName" class="block mb-2 text-sm font-medium text-neutral-900">Professional Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="brokerName"
|
id="brokerName"
|
||||||
[ngModel]="criteria.brokerName"
|
[ngModel]="criteria.brokerName"
|
||||||
(ngModelChange)="updateCriteria({ brokerName: $event })"
|
(ngModelChange)="updateCriteria({ brokerName: $event })"
|
||||||
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
|
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
|
||||||
placeholder="e.g. John Smith"
|
placeholder="e.g. John Smith"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="companyName" class="block mb-2 text-sm font-medium text-neutral-900">Company Name</label>
|
<label for="companyName" class="block mb-2 text-sm font-medium text-neutral-900">Company Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="companyName"
|
id="companyName"
|
||||||
[ngModel]="criteria.companyName"
|
[ngModel]="criteria.companyName"
|
||||||
(ngModelChange)="updateCriteria({ companyName: $event })"
|
(ngModelChange)="updateCriteria({ companyName: $event })"
|
||||||
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
|
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
|
||||||
placeholder="e.g. ABC Brokers"
|
placeholder="e.g. ABC Brokers"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block mb-2 text-sm font-medium text-neutral-900">Counties / Areas Served</label>
|
<label class="block mb-2 text-sm font-medium text-neutral-900">Counties / Areas Served</label>
|
||||||
<ng-select
|
<ng-select
|
||||||
class="custom"
|
class="custom"
|
||||||
[items]="counties$ | async"
|
[items]="counties$ | async"
|
||||||
[multiple]="true"
|
[multiple]="true"
|
||||||
[loading]="countyLoading"
|
[loading]="countyLoading"
|
||||||
[typeahead]="countyInput$"
|
[typeahead]="countyInput$"
|
||||||
[ngModel]="criteria.counties"
|
[ngModel]="criteria.counties"
|
||||||
(ngModelChange)="onCountiesChange($event)"
|
(ngModelChange)="onCountiesChange($event)"
|
||||||
[closeOnSelect]="true"
|
[closeOnSelect]="true"
|
||||||
placeholder="Type to search counties"
|
placeholder="Type to search counties"
|
||||||
></ng-select>
|
></ng-select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,316 +1,316 @@
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { NgSelectModule } from '@ng-select/ng-select';
|
import { NgSelectModule } from '@ng-select/ng-select';
|
||||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||||
import { catchError, concat, debounceTime, distinctUntilChanged, map, Observable, of, Subject, switchMap, takeUntil, tap } from 'rxjs';
|
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 { CountyResult, GeoResult, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
|
||||||
import { FilterStateService } from '../../services/filter-state.service';
|
import { FilterStateService } from '../../services/filter-state.service';
|
||||||
import { GeoService } from '../../services/geo.service';
|
import { GeoService } from '../../services/geo.service';
|
||||||
import { SearchService } from '../../services/search.service';
|
import { SearchService } from '../../services/search.service';
|
||||||
import { SelectOptionsService } from '../../services/select-options.service';
|
import { SelectOptionsService } from '../../services/select-options.service';
|
||||||
import { UserService } from '../../services/user.service';
|
import { UserService } from '../../services/user.service';
|
||||||
import { ValidatedCityComponent } from '../validated-city/validated-city.component';
|
import { ValidatedCityComponent } from '../validated-city/validated-city.component';
|
||||||
import { ModalService } from './modal.service';
|
import { ModalService } from './modal.service';
|
||||||
|
|
||||||
@UntilDestroy()
|
@UntilDestroy()
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-search-modal-broker',
|
selector: 'app-search-modal-broker',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, NgSelectModule, ValidatedCityComponent],
|
imports: [CommonModule, FormsModule, NgSelectModule, ValidatedCityComponent],
|
||||||
templateUrl: './search-modal-broker.component.html',
|
templateUrl: './search-modal-broker.component.html',
|
||||||
styleUrls: ['./search-modal.component.scss'],
|
styleUrls: ['./search-modal.component.scss'],
|
||||||
})
|
})
|
||||||
export class SearchModalBrokerComponent implements OnInit, OnDestroy {
|
export class SearchModalBrokerComponent implements OnInit, OnDestroy {
|
||||||
@Input() isModal: boolean = true;
|
@Input() isModal: boolean = true;
|
||||||
|
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
private searchDebounce$ = new Subject<void>();
|
private searchDebounce$ = new Subject<void>();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
criteria: UserListingCriteria;
|
criteria: UserListingCriteria;
|
||||||
backupCriteria: any;
|
backupCriteria: any;
|
||||||
|
|
||||||
// Geo search
|
// Geo search
|
||||||
counties$: Observable<CountyResult[]>;
|
counties$: Observable<CountyResult[]>;
|
||||||
countyLoading = false;
|
countyLoading = false;
|
||||||
countyInput$ = new Subject<string>();
|
countyInput$ = new Subject<string>();
|
||||||
|
|
||||||
// Results count
|
// Results count
|
||||||
numberOfResults$: Observable<number>;
|
numberOfResults$: Observable<number>;
|
||||||
cancelDisable = false;
|
cancelDisable = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public selectOptions: SelectOptionsService,
|
public selectOptions: SelectOptionsService,
|
||||||
public modalService: ModalService,
|
public modalService: ModalService,
|
||||||
private geoService: GeoService,
|
private geoService: GeoService,
|
||||||
private filterStateService: FilterStateService,
|
private filterStateService: FilterStateService,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
private searchService: SearchService,
|
private searchService: SearchService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
// Load counties
|
// Load counties
|
||||||
this.loadCounties();
|
this.loadCounties();
|
||||||
|
|
||||||
if (this.isModal) {
|
if (this.isModal) {
|
||||||
// Modal mode: Wait for messages from ModalService
|
// Modal mode: Wait for messages from ModalService
|
||||||
this.modalService.message$.pipe(untilDestroyed(this)).subscribe(criteria => {
|
this.modalService.message$.pipe(untilDestroyed(this)).subscribe(criteria => {
|
||||||
if (criteria?.criteriaType === 'brokerListings') {
|
if (criteria?.criteriaType === 'brokerListings') {
|
||||||
this.initializeWithCriteria(criteria);
|
this.initializeWithCriteria(criteria);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.modalService.modalVisible$.pipe(untilDestroyed(this)).subscribe(val => {
|
this.modalService.modalVisible$.pipe(untilDestroyed(this)).subscribe(val => {
|
||||||
if (val.visible && val.type === 'brokerListings') {
|
if (val.visible && val.type === 'brokerListings') {
|
||||||
// Reset pagination when modal opens
|
// Reset pagination when modal opens
|
||||||
if (this.criteria) {
|
if (this.criteria) {
|
||||||
this.criteria.page = 1;
|
this.criteria.page = 1;
|
||||||
this.criteria.start = 0;
|
this.criteria.start = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Embedded mode: Subscribe to state changes
|
// Embedded mode: Subscribe to state changes
|
||||||
this.subscribeToStateChanges();
|
this.subscribeToStateChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup debounced search
|
// Setup debounced search
|
||||||
this.searchDebounce$.pipe(debounceTime(400), takeUntil(this.destroy$)).subscribe(() => {
|
this.searchDebounce$.pipe(debounceTime(400), takeUntil(this.destroy$)).subscribe(() => {
|
||||||
this.triggerSearch();
|
this.triggerSearch();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private initializeWithCriteria(criteria: UserListingCriteria): void {
|
private initializeWithCriteria(criteria: UserListingCriteria): void {
|
||||||
this.criteria = criteria;
|
this.criteria = criteria;
|
||||||
this.backupCriteria = JSON.parse(JSON.stringify(criteria));
|
this.backupCriteria = JSON.parse(JSON.stringify(criteria));
|
||||||
this.setTotalNumberOfResults();
|
this.setTotalNumberOfResults();
|
||||||
}
|
}
|
||||||
|
|
||||||
private subscribeToStateChanges(): void {
|
private subscribeToStateChanges(): void {
|
||||||
if (!this.isModal) {
|
if (!this.isModal) {
|
||||||
this.filterStateService
|
this.filterStateService
|
||||||
.getState$('brokerListings')
|
.getState$('brokerListings')
|
||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe(state => {
|
.subscribe(state => {
|
||||||
this.criteria = { ...state.criteria };
|
this.criteria = { ...state.criteria };
|
||||||
this.setTotalNumberOfResults();
|
this.setTotalNumberOfResults();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadCounties(): void {
|
private loadCounties(): void {
|
||||||
this.counties$ = concat(
|
this.counties$ = concat(
|
||||||
of([]), // default items
|
of([]), // default items
|
||||||
this.countyInput$.pipe(
|
this.countyInput$.pipe(
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
tap(() => (this.countyLoading = true)),
|
tap(() => (this.countyLoading = true)),
|
||||||
switchMap(term =>
|
switchMap(term =>
|
||||||
this.geoService.findCountiesStartingWith(term).pipe(
|
this.geoService.findCountiesStartingWith(term).pipe(
|
||||||
catchError(() => of([])),
|
catchError(() => of([])),
|
||||||
map(counties => counties.map(county => county.name)),
|
map(counties => counties.map(county => county.name)),
|
||||||
tap(() => (this.countyLoading = false)),
|
tap(() => (this.countyLoading = false)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter removal methods
|
// Filter removal methods
|
||||||
removeFilter(filterType: string): void {
|
removeFilter(filterType: string): void {
|
||||||
const updates: any = {};
|
const updates: any = {};
|
||||||
|
|
||||||
switch (filterType) {
|
switch (filterType) {
|
||||||
case 'state':
|
case 'state':
|
||||||
updates.state = null;
|
updates.state = null;
|
||||||
updates.city = null;
|
updates.city = null;
|
||||||
updates.radius = null;
|
updates.radius = null;
|
||||||
updates.searchType = 'exact';
|
updates.searchType = 'exact';
|
||||||
break;
|
break;
|
||||||
case 'city':
|
case 'city':
|
||||||
updates.city = null;
|
updates.city = null;
|
||||||
updates.radius = null;
|
updates.radius = null;
|
||||||
updates.searchType = 'exact';
|
updates.searchType = 'exact';
|
||||||
break;
|
break;
|
||||||
case 'types':
|
case 'types':
|
||||||
updates.types = [];
|
updates.types = [];
|
||||||
break;
|
break;
|
||||||
case 'brokerName':
|
case 'brokerName':
|
||||||
updates.brokerName = null;
|
updates.brokerName = null;
|
||||||
break;
|
break;
|
||||||
case 'companyName':
|
case 'companyName':
|
||||||
updates.companyName = null;
|
updates.companyName = null;
|
||||||
break;
|
break;
|
||||||
case 'counties':
|
case 'counties':
|
||||||
updates.counties = [];
|
updates.counties = [];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateCriteria(updates);
|
this.updateCriteria(updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Professional type handling
|
// Professional type handling
|
||||||
onCategoryChange(selectedCategories: string[]): void {
|
onCategoryChange(selectedCategories: string[]): void {
|
||||||
this.updateCriteria({ types: selectedCategories });
|
this.updateCriteria({ types: selectedCategories });
|
||||||
}
|
}
|
||||||
|
|
||||||
categoryClicked(checked: boolean, value: string): void {
|
categoryClicked(checked: boolean, value: string): void {
|
||||||
const types = [...(this.criteria.types || [])];
|
const types = [...(this.criteria.types || [])];
|
||||||
if (checked) {
|
if (checked) {
|
||||||
if (!types.includes(value)) {
|
if (!types.includes(value)) {
|
||||||
types.push(value);
|
types.push(value);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const index = types.indexOf(value);
|
const index = types.indexOf(value);
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
types.splice(index, 1);
|
types.splice(index, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.updateCriteria({ types });
|
this.updateCriteria({ types });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Counties handling
|
// Counties handling
|
||||||
onCountiesChange(selectedCounties: string[]): void {
|
onCountiesChange(selectedCounties: string[]): void {
|
||||||
this.updateCriteria({ counties: selectedCounties });
|
this.updateCriteria({ counties: selectedCounties });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Location handling
|
// Location handling
|
||||||
setState(state: string): void {
|
setState(state: string): void {
|
||||||
const updates: any = { state };
|
const updates: any = { state };
|
||||||
if (!state) {
|
if (!state) {
|
||||||
updates.city = null;
|
updates.city = null;
|
||||||
updates.radius = null;
|
updates.radius = null;
|
||||||
updates.searchType = 'exact';
|
updates.searchType = 'exact';
|
||||||
}
|
}
|
||||||
this.updateCriteria(updates);
|
this.updateCriteria(updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
setCity(city: any): void {
|
setCity(city: any): void {
|
||||||
const updates: any = {};
|
const updates: any = {};
|
||||||
if (city) {
|
if (city) {
|
||||||
updates.city = city;
|
updates.city = city;
|
||||||
updates.state = city.state;
|
updates.state = city.state;
|
||||||
// Automatically set radius to 50 miles and enable radius search
|
// Automatically set radius to 50 miles and enable radius search
|
||||||
updates.searchType = 'radius';
|
updates.searchType = 'radius';
|
||||||
updates.radius = 50;
|
updates.radius = 50;
|
||||||
} else {
|
} else {
|
||||||
updates.city = null;
|
updates.city = null;
|
||||||
updates.radius = null;
|
updates.radius = null;
|
||||||
updates.searchType = 'exact';
|
updates.searchType = 'exact';
|
||||||
}
|
}
|
||||||
this.updateCriteria(updates);
|
this.updateCriteria(updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
setRadius(radius: number): void {
|
setRadius(radius: number): void {
|
||||||
this.updateCriteria({ radius });
|
this.updateCriteria({ radius });
|
||||||
}
|
}
|
||||||
|
|
||||||
onCriteriaChange(): void {
|
onCriteriaChange(): void {
|
||||||
this.triggerSearch();
|
this.triggerSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debounced search for text inputs
|
// Debounced search for text inputs
|
||||||
debouncedSearch(): void {
|
debouncedSearch(): void {
|
||||||
this.searchDebounce$.next();
|
this.searchDebounce$.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear all filters
|
// Clear all filters
|
||||||
clearFilter(): void {
|
clearFilter(): void {
|
||||||
if (this.isModal) {
|
if (this.isModal) {
|
||||||
// In modal: Reset locally
|
// In modal: Reset locally
|
||||||
const defaultCriteria = this.getDefaultCriteria();
|
const defaultCriteria = this.getDefaultCriteria();
|
||||||
this.criteria = defaultCriteria;
|
this.criteria = defaultCriteria;
|
||||||
this.setTotalNumberOfResults();
|
this.setTotalNumberOfResults();
|
||||||
} else {
|
} else {
|
||||||
// Embedded: Use state service
|
// Embedded: Use state service
|
||||||
this.filterStateService.clearFilters('brokerListings');
|
this.filterStateService.clearFilters('brokerListings');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modal-specific methods
|
// Modal-specific methods
|
||||||
closeAndSearch(): void {
|
closeAndSearch(): void {
|
||||||
if (this.isModal) {
|
if (this.isModal) {
|
||||||
// Save changes to state
|
// Save changes to state
|
||||||
this.filterStateService.setCriteria('brokerListings', this.criteria);
|
this.filterStateService.setCriteria('brokerListings', this.criteria);
|
||||||
this.modalService.accept();
|
this.modalService.accept();
|
||||||
this.searchService.search('brokerListings');
|
this.searchService.search('brokerListings');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
if (this.isModal) {
|
if (this.isModal) {
|
||||||
// Discard changes
|
// Discard changes
|
||||||
this.modalService.reject(this.backupCriteria);
|
this.modalService.reject(this.backupCriteria);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper methods
|
// Helper methods
|
||||||
public updateCriteria(updates: any): void {
|
public updateCriteria(updates: any): void {
|
||||||
if (this.isModal) {
|
if (this.isModal) {
|
||||||
// In modal: Update locally only
|
// In modal: Update locally only
|
||||||
this.criteria = { ...this.criteria, ...updates };
|
this.criteria = { ...this.criteria, ...updates };
|
||||||
this.setTotalNumberOfResults();
|
this.setTotalNumberOfResults();
|
||||||
} else {
|
} else {
|
||||||
// Embedded: Update through state service
|
// Embedded: Update through state service
|
||||||
this.filterStateService.updateCriteria('brokerListings', updates);
|
this.filterStateService.updateCriteria('brokerListings', updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger search after update
|
// Trigger search after update
|
||||||
this.debouncedSearch();
|
this.debouncedSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
private triggerSearch(): void {
|
private triggerSearch(): void {
|
||||||
if (this.isModal) {
|
if (this.isModal) {
|
||||||
// In modal: Only update count
|
// In modal: Only update count
|
||||||
this.setTotalNumberOfResults();
|
this.setTotalNumberOfResults();
|
||||||
this.cancelDisable = true;
|
this.cancelDisable = true;
|
||||||
} else {
|
} else {
|
||||||
// Embedded: Full search
|
// Embedded: Full search
|
||||||
this.searchService.search('brokerListings');
|
this.searchService.search('brokerListings');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private setTotalNumberOfResults(): void {
|
private setTotalNumberOfResults(): void {
|
||||||
this.numberOfResults$ = this.userService.getNumberOfBroker(this.criteria);
|
this.numberOfResults$ = this.userService.getNumberOfBroker(this.criteria);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDefaultCriteria(): UserListingCriteria {
|
private getDefaultCriteria(): UserListingCriteria {
|
||||||
return {
|
return {
|
||||||
criteriaType: 'brokerListings',
|
criteriaType: 'brokerListings',
|
||||||
types: [],
|
types: [],
|
||||||
state: null,
|
state: null,
|
||||||
city: null,
|
city: null,
|
||||||
radius: null,
|
radius: null,
|
||||||
searchType: 'exact' as const,
|
searchType: 'exact' as const,
|
||||||
brokerName: null,
|
brokerName: null,
|
||||||
companyName: null,
|
companyName: null,
|
||||||
counties: [],
|
counties: [],
|
||||||
prompt: null,
|
prompt: null,
|
||||||
page: 1,
|
page: 1,
|
||||||
start: 0,
|
start: 0,
|
||||||
length: 12,
|
length: 12,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
hasActiveFilters(): boolean {
|
hasActiveFilters(): boolean {
|
||||||
if (!this.criteria) return false;
|
if (!this.criteria) return false;
|
||||||
|
|
||||||
return !!(
|
return !!(
|
||||||
this.criteria.state ||
|
this.criteria.state ||
|
||||||
this.criteria.city ||
|
this.criteria.city ||
|
||||||
this.criteria.types?.length ||
|
this.criteria.types?.length ||
|
||||||
this.criteria.brokerName ||
|
this.criteria.brokerName ||
|
||||||
this.criteria.companyName ||
|
this.criteria.companyName ||
|
||||||
this.criteria.counties?.length
|
this.criteria.counties?.length
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
trackByFn(item: GeoResult): any {
|
trackByFn(item: GeoResult): any {
|
||||||
return item.id;
|
return item.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.destroy$.next();
|
this.destroy$.next();
|
||||||
this.destroy$.complete();
|
this.destroy$.complete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,250 +1,250 @@
|
||||||
<div
|
<div
|
||||||
*ngIf="isModal && (modalService.modalVisible$ | async)?.visible && (modalService.modalVisible$ | async)?.type === 'commercialPropertyListings'"
|
*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"
|
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 w-full h-screen max-h-screen">
|
||||||
<div class="relative bg-white rounded-lg shadow h-full">
|
<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">
|
<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>
|
<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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span class="sr-only">Close Modal</span>
|
<span class="sr-only">Close Modal</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6 space-y-6">
|
<div class="p-6 space-y-6">
|
||||||
<div class="flex space-x-4 mb-4">
|
<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>
|
<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>
|
<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">
|
<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
|
Clear all Filter
|
||||||
<div class="tooltip-arrow" data-popper-arrow></div>
|
<div class="tooltip-arrow" data-popper-arrow></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Display active filters as tags -->
|
<!-- Display active filters as tags -->
|
||||||
<div class="flex flex-wrap gap-2 mb-4" *ngIf="hasActiveFilters()">
|
<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">
|
<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>
|
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@if(criteria.criteriaType==='commercialPropertyListings') {
|
@if(criteria.criteriaType==='commercialPropertyListings') {
|
||||||
<div class="grid grid-cols-1 gap-6">
|
<div class="grid grid-cols-1 gap-6">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="state" class="block mb-2 text-sm font-medium text-neutral-900">Location - State</label>
|
<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>
|
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<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>
|
||||||
<div *ngIf="criteria.city">
|
<div *ngIf="criteria.city">
|
||||||
<label class="block mb-2 text-sm font-medium text-neutral-900">Search Type</label>
|
<label class="block mb-2 text-sm font-medium text-neutral-900">Search Type</label>
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<label class="inline-flex items-center">
|
<label class="inline-flex items-center">
|
||||||
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="exact" />
|
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="exact" />
|
||||||
<span class="ml-2">Exact City</span>
|
<span class="ml-2">Exact City</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="inline-flex items-center">
|
<label class="inline-flex items-center">
|
||||||
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
|
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
|
||||||
<span class="ml-2">Radius Search</span>
|
<span class="ml-2">Radius Search</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2">
|
<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>
|
<label class="block mb-2 text-sm font-medium text-neutral-900">Select Radius (in miles)</label>
|
||||||
<div class="flex flex-wrap">
|
<div class="flex flex-wrap">
|
||||||
@for (radius of [5, 20, 50, 100, 200, 300, 400, 500]; track radius) {
|
@for (radius of [5, 20, 50, 100, 200, 300, 400, 500]; track radius) {
|
||||||
<button
|
<button
|
||||||
type="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"
|
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'"
|
[ngClass]="criteria.radius === radius ? 'text-white bg-gray-500' : 'text-neutral-900 bg-white'"
|
||||||
(click)="setRadius(radius)"
|
(click)="setRadius(radius)"
|
||||||
>
|
>
|
||||||
{{ radius }}
|
{{ radius }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="price" class="block mb-2 text-sm font-medium text-neutral-900">Price</label>
|
<label for="price" class="block mb-2 text-sm font-medium text-neutral-900">Price</label>
|
||||||
<div class="flex items-center space-x-2">
|
<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 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>
|
</app-validated-price>
|
||||||
<span>-</span>
|
<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 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>
|
</app-validated-price>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="title" class="block mb-2 text-sm font-medium text-neutral-900">Title / Description (Free Search)</label>
|
<label for="title" class="block mb-2 text-sm font-medium text-neutral-900">Title / Description (Free Search)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="title"
|
id="title"
|
||||||
[ngModel]="criteria.title"
|
[ngModel]="criteria.title"
|
||||||
(ngModelChange)="updateCriteria({ title: $event })"
|
(ngModelChange)="updateCriteria({ title: $event })"
|
||||||
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
|
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
|
||||||
placeholder="e.g. Office Space"
|
placeholder="e.g. Office Space"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block mb-2 text-sm font-medium text-neutral-900">Category</label>
|
<label class="block mb-2 text-sm font-medium text-neutral-900">Category</label>
|
||||||
<ng-select
|
<ng-select
|
||||||
class="custom"
|
class="custom"
|
||||||
[items]="selectOptions.typesOfCommercialProperty"
|
[items]="selectOptions.typesOfCommercialProperty"
|
||||||
bindLabel="name"
|
bindLabel="name"
|
||||||
bindValue="value"
|
bindValue="value"
|
||||||
[ngModel]="criteria.types"
|
[ngModel]="criteria.types"
|
||||||
(ngModelChange)="onCategoryChange($event)"
|
(ngModelChange)="onCategoryChange($event)"
|
||||||
[multiple]="true"
|
[multiple]="true"
|
||||||
[closeOnSelect]="true"
|
[closeOnSelect]="true"
|
||||||
placeholder="Select categories"
|
placeholder="Select categories"
|
||||||
></ng-select>
|
></ng-select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="brokername" class="block mb-2 text-sm font-medium text-neutral-900">Broker Name / Company Name</label>
|
<label for="brokername" class="block mb-2 text-sm font-medium text-neutral-900">Broker Name / Company Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="brokername"
|
id="brokername"
|
||||||
[ngModel]="criteria.brokerName"
|
[ngModel]="criteria.brokerName"
|
||||||
(ngModelChange)="updateCriteria({ brokerName: $event })"
|
(ngModelChange)="updateCriteria({ brokerName: $event })"
|
||||||
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
|
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
|
||||||
placeholder="e.g. Brokers Invest"
|
placeholder="e.g. Brokers Invest"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="!isModal" class="space-y-6 pb-10">
|
<div *ngIf="!isModal" class="space-y-6 pb-10">
|
||||||
<div class="flex space-x-4 mb-4">
|
<div class="flex space-x-4 mb-4">
|
||||||
<h3 class="text-xl font-semibold text-neutral-900">Filter ({{ numberOfResults$ | async }})</h3>
|
<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>
|
<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">
|
<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
|
Clear all Filter
|
||||||
<div class="tooltip-arrow" data-popper-arrow></div>
|
<div class="tooltip-arrow" data-popper-arrow></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Display active filters as tags -->
|
<!-- Display active filters as tags -->
|
||||||
<div class="flex flex-wrap gap-2" *ngIf="hasActiveFilters()">
|
<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">
|
<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>
|
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@if(criteria.criteriaType==='commercialPropertyListings') {
|
@if(criteria.criteriaType==='commercialPropertyListings') {
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="state" class="block mb-2 text-sm font-medium text-neutral-900">Location - State</label>
|
<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>
|
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<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>
|
||||||
<div *ngIf="criteria.city">
|
<div *ngIf="criteria.city">
|
||||||
<label class="block mb-2 text-sm font-medium text-neutral-900">Search Type</label>
|
<label class="block mb-2 text-sm font-medium text-neutral-900">Search Type</label>
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<label class="inline-flex items-center">
|
<label class="inline-flex items-center">
|
||||||
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="exact" />
|
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="exact" />
|
||||||
<span class="ml-2">Exact City</span>
|
<span class="ml-2">Exact City</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="inline-flex items-center">
|
<label class="inline-flex items-center">
|
||||||
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
|
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
|
||||||
<span class="ml-2">Radius Search</span>
|
<span class="ml-2">Radius Search</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2">
|
<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>
|
<label class="block mb-2 text-sm font-medium text-neutral-900">Select Radius (in miles)</label>
|
||||||
<div class="flex flex-wrap">
|
<div class="flex flex-wrap">
|
||||||
@for (radius of [5, 20, 50, 100, 200, 300, 400, 500]; track radius) {
|
@for (radius of [5, 20, 50, 100, 200, 300, 400, 500]; track radius) {
|
||||||
<button
|
<button
|
||||||
type="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"
|
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'"
|
[ngClass]="criteria.radius === radius ? 'text-white bg-gray-500' : 'text-neutral-900 bg-white'"
|
||||||
(click)="setRadius(radius)"
|
(click)="setRadius(radius)"
|
||||||
>
|
>
|
||||||
{{ radius }}
|
{{ radius }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block mb-2 text-sm font-medium text-neutral-900">Category</label>
|
<label class="block mb-2 text-sm font-medium text-neutral-900">Category</label>
|
||||||
<ng-select
|
<ng-select
|
||||||
class="custom"
|
class="custom"
|
||||||
[items]="selectOptions.typesOfCommercialProperty"
|
[items]="selectOptions.typesOfCommercialProperty"
|
||||||
bindLabel="name"
|
bindLabel="name"
|
||||||
bindValue="value"
|
bindValue="value"
|
||||||
[ngModel]="criteria.types"
|
[ngModel]="criteria.types"
|
||||||
(ngModelChange)="onCategoryChange($event)"
|
(ngModelChange)="onCategoryChange($event)"
|
||||||
[multiple]="true"
|
[multiple]="true"
|
||||||
[closeOnSelect]="true"
|
[closeOnSelect]="true"
|
||||||
placeholder="Select categories"
|
placeholder="Select categories"
|
||||||
></ng-select>
|
></ng-select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="price" class="block mb-2 text-sm font-medium text-neutral-900">Price</label>
|
<label for="price" class="block mb-2 text-sm font-medium text-neutral-900">Price</label>
|
||||||
<div class="flex items-center space-x-2">
|
<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>
|
<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>
|
<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>
|
<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>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="title" class="block mb-2 text-sm font-medium text-neutral-900">Title / Description (Free Search)</label>
|
<label for="title" class="block mb-2 text-sm font-medium text-neutral-900">Title / Description (Free Search)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="title"
|
id="title"
|
||||||
[ngModel]="criteria.title"
|
[ngModel]="criteria.title"
|
||||||
(ngModelChange)="updateCriteria({ title: $event })"
|
(ngModelChange)="updateCriteria({ title: $event })"
|
||||||
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
|
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
|
||||||
placeholder="e.g. Office Space"
|
placeholder="e.g. Office Space"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="brokername-embedded" class="block mb-2 text-sm font-medium text-neutral-900">Broker Name / Company Name</label>
|
<label for="brokername-embedded" class="block mb-2 text-sm font-medium text-neutral-900">Broker Name / Company Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="brokername-embedded"
|
id="brokername-embedded"
|
||||||
[ngModel]="criteria.brokerName"
|
[ngModel]="criteria.brokerName"
|
||||||
(ngModelChange)="updateCriteria({ brokerName: $event })"
|
(ngModelChange)="updateCriteria({ brokerName: $event })"
|
||||||
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
|
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
|
||||||
placeholder="e.g. Brokers Invest"
|
placeholder="e.g. Brokers Invest"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,316 +1,316 @@
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { NgSelectModule } from '@ng-select/ng-select';
|
import { NgSelectModule } from '@ng-select/ng-select';
|
||||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||||
import { catchError, concat, debounceTime, distinctUntilChanged, map, Observable, of, Subject, switchMap, takeUntil, tap } from 'rxjs';
|
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 { CommercialPropertyListingCriteria, CountyResult, GeoResult } from '../../../../../bizmatch-server/src/models/main.model';
|
||||||
import { FilterStateService } from '../../services/filter-state.service';
|
import { FilterStateService } from '../../services/filter-state.service';
|
||||||
import { GeoService } from '../../services/geo.service';
|
import { GeoService } from '../../services/geo.service';
|
||||||
import { ListingsService } from '../../services/listings.service';
|
import { ListingsService } from '../../services/listings.service';
|
||||||
import { SearchService } from '../../services/search.service';
|
import { SearchService } from '../../services/search.service';
|
||||||
import { SelectOptionsService } from '../../services/select-options.service';
|
import { SelectOptionsService } from '../../services/select-options.service';
|
||||||
import { ValidatedCityComponent } from '../validated-city/validated-city.component';
|
import { ValidatedCityComponent } from '../validated-city/validated-city.component';
|
||||||
import { ValidatedPriceComponent } from '../validated-price/validated-price.component';
|
import { ValidatedPriceComponent } from '../validated-price/validated-price.component';
|
||||||
import { ModalService } from './modal.service';
|
import { ModalService } from './modal.service';
|
||||||
|
|
||||||
@UntilDestroy()
|
@UntilDestroy()
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-search-modal-commercial',
|
selector: 'app-search-modal-commercial',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, NgSelectModule, ValidatedCityComponent, ValidatedPriceComponent],
|
imports: [CommonModule, FormsModule, NgSelectModule, ValidatedCityComponent, ValidatedPriceComponent],
|
||||||
templateUrl: './search-modal-commercial.component.html',
|
templateUrl: './search-modal-commercial.component.html',
|
||||||
styleUrls: ['./search-modal.component.scss'],
|
styleUrls: ['./search-modal.component.scss'],
|
||||||
})
|
})
|
||||||
export class SearchModalCommercialComponent implements OnInit, OnDestroy {
|
export class SearchModalCommercialComponent implements OnInit, OnDestroy {
|
||||||
@Input() isModal: boolean = true;
|
@Input() isModal: boolean = true;
|
||||||
|
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
private searchDebounce$ = new Subject<void>();
|
private searchDebounce$ = new Subject<void>();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
criteria: CommercialPropertyListingCriteria;
|
criteria: CommercialPropertyListingCriteria;
|
||||||
backupCriteria: any;
|
backupCriteria: any;
|
||||||
|
|
||||||
// Geo search
|
// Geo search
|
||||||
counties$: Observable<CountyResult[]>;
|
counties$: Observable<CountyResult[]>;
|
||||||
countyLoading = false;
|
countyLoading = false;
|
||||||
countyInput$ = new Subject<string>();
|
countyInput$ = new Subject<string>();
|
||||||
|
|
||||||
// Results count
|
// Results count
|
||||||
numberOfResults$: Observable<number>;
|
numberOfResults$: Observable<number>;
|
||||||
cancelDisable = false;
|
cancelDisable = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public selectOptions: SelectOptionsService,
|
public selectOptions: SelectOptionsService,
|
||||||
public modalService: ModalService,
|
public modalService: ModalService,
|
||||||
private geoService: GeoService,
|
private geoService: GeoService,
|
||||||
private filterStateService: FilterStateService,
|
private filterStateService: FilterStateService,
|
||||||
private listingService: ListingsService,
|
private listingService: ListingsService,
|
||||||
private searchService: SearchService,
|
private searchService: SearchService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
// Load counties
|
// Load counties
|
||||||
this.loadCounties();
|
this.loadCounties();
|
||||||
|
|
||||||
if (this.isModal) {
|
if (this.isModal) {
|
||||||
// Modal mode: Wait for messages from ModalService
|
// Modal mode: Wait for messages from ModalService
|
||||||
this.modalService.message$.pipe(untilDestroyed(this)).subscribe(criteria => {
|
this.modalService.message$.pipe(untilDestroyed(this)).subscribe(criteria => {
|
||||||
if (criteria?.criteriaType === 'commercialPropertyListings') {
|
if (criteria?.criteriaType === 'commercialPropertyListings') {
|
||||||
this.initializeWithCriteria(criteria);
|
this.initializeWithCriteria(criteria);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.modalService.modalVisible$.pipe(untilDestroyed(this)).subscribe(val => {
|
this.modalService.modalVisible$.pipe(untilDestroyed(this)).subscribe(val => {
|
||||||
if (val.visible && val.type === 'commercialPropertyListings') {
|
if (val.visible && val.type === 'commercialPropertyListings') {
|
||||||
// Reset pagination when modal opens
|
// Reset pagination when modal opens
|
||||||
if (this.criteria) {
|
if (this.criteria) {
|
||||||
this.criteria.page = 1;
|
this.criteria.page = 1;
|
||||||
this.criteria.start = 0;
|
this.criteria.start = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Embedded mode: Subscribe to state changes
|
// Embedded mode: Subscribe to state changes
|
||||||
this.subscribeToStateChanges();
|
this.subscribeToStateChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup debounced search
|
// Setup debounced search
|
||||||
this.searchDebounce$.pipe(debounceTime(400), takeUntil(this.destroy$)).subscribe(() => {
|
this.searchDebounce$.pipe(debounceTime(400), takeUntil(this.destroy$)).subscribe(() => {
|
||||||
this.triggerSearch();
|
this.triggerSearch();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private initializeWithCriteria(criteria: CommercialPropertyListingCriteria): void {
|
private initializeWithCriteria(criteria: CommercialPropertyListingCriteria): void {
|
||||||
this.criteria = criteria;
|
this.criteria = criteria;
|
||||||
this.backupCriteria = JSON.parse(JSON.stringify(criteria));
|
this.backupCriteria = JSON.parse(JSON.stringify(criteria));
|
||||||
this.setTotalNumberOfResults();
|
this.setTotalNumberOfResults();
|
||||||
}
|
}
|
||||||
|
|
||||||
private subscribeToStateChanges(): void {
|
private subscribeToStateChanges(): void {
|
||||||
if (!this.isModal) {
|
if (!this.isModal) {
|
||||||
this.filterStateService
|
this.filterStateService
|
||||||
.getState$('commercialPropertyListings')
|
.getState$('commercialPropertyListings')
|
||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe(state => {
|
.subscribe(state => {
|
||||||
this.criteria = { ...state.criteria };
|
this.criteria = { ...state.criteria };
|
||||||
this.setTotalNumberOfResults();
|
this.setTotalNumberOfResults();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadCounties(): void {
|
private loadCounties(): void {
|
||||||
this.counties$ = concat(
|
this.counties$ = concat(
|
||||||
of([]), // default items
|
of([]), // default items
|
||||||
this.countyInput$.pipe(
|
this.countyInput$.pipe(
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
tap(() => (this.countyLoading = true)),
|
tap(() => (this.countyLoading = true)),
|
||||||
switchMap(term =>
|
switchMap(term =>
|
||||||
this.geoService.findCountiesStartingWith(term).pipe(
|
this.geoService.findCountiesStartingWith(term).pipe(
|
||||||
catchError(() => of([])),
|
catchError(() => of([])),
|
||||||
map(counties => counties.map(county => county.name)),
|
map(counties => counties.map(county => county.name)),
|
||||||
tap(() => (this.countyLoading = false)),
|
tap(() => (this.countyLoading = false)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter removal methods
|
// Filter removal methods
|
||||||
removeFilter(filterType: string): void {
|
removeFilter(filterType: string): void {
|
||||||
const updates: any = {};
|
const updates: any = {};
|
||||||
|
|
||||||
switch (filterType) {
|
switch (filterType) {
|
||||||
case 'state':
|
case 'state':
|
||||||
updates.state = null;
|
updates.state = null;
|
||||||
updates.city = null;
|
updates.city = null;
|
||||||
updates.radius = null;
|
updates.radius = null;
|
||||||
updates.searchType = 'exact';
|
updates.searchType = 'exact';
|
||||||
break;
|
break;
|
||||||
case 'city':
|
case 'city':
|
||||||
updates.city = null;
|
updates.city = null;
|
||||||
updates.radius = null;
|
updates.radius = null;
|
||||||
updates.searchType = 'exact';
|
updates.searchType = 'exact';
|
||||||
break;
|
break;
|
||||||
case 'price':
|
case 'price':
|
||||||
updates.minPrice = null;
|
updates.minPrice = null;
|
||||||
updates.maxPrice = null;
|
updates.maxPrice = null;
|
||||||
break;
|
break;
|
||||||
case 'types':
|
case 'types':
|
||||||
updates.types = [];
|
updates.types = [];
|
||||||
break;
|
break;
|
||||||
case 'title':
|
case 'title':
|
||||||
updates.title = null;
|
updates.title = null;
|
||||||
break;
|
break;
|
||||||
case 'brokerName':
|
case 'brokerName':
|
||||||
updates.brokerName = null;
|
updates.brokerName = null;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateCriteria(updates);
|
this.updateCriteria(updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Category handling
|
// Category handling
|
||||||
onCategoryChange(selectedCategories: string[]): void {
|
onCategoryChange(selectedCategories: string[]): void {
|
||||||
this.updateCriteria({ types: selectedCategories });
|
this.updateCriteria({ types: selectedCategories });
|
||||||
}
|
}
|
||||||
|
|
||||||
categoryClicked(checked: boolean, value: string): void {
|
categoryClicked(checked: boolean, value: string): void {
|
||||||
const types = [...(this.criteria.types || [])];
|
const types = [...(this.criteria.types || [])];
|
||||||
if (checked) {
|
if (checked) {
|
||||||
if (!types.includes(value)) {
|
if (!types.includes(value)) {
|
||||||
types.push(value);
|
types.push(value);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const index = types.indexOf(value);
|
const index = types.indexOf(value);
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
types.splice(index, 1);
|
types.splice(index, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.updateCriteria({ types });
|
this.updateCriteria({ types });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Location handling
|
// Location handling
|
||||||
setState(state: string): void {
|
setState(state: string): void {
|
||||||
const updates: any = { state };
|
const updates: any = { state };
|
||||||
if (!state) {
|
if (!state) {
|
||||||
updates.city = null;
|
updates.city = null;
|
||||||
updates.radius = null;
|
updates.radius = null;
|
||||||
updates.searchType = 'exact';
|
updates.searchType = 'exact';
|
||||||
}
|
}
|
||||||
this.updateCriteria(updates);
|
this.updateCriteria(updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
setCity(city: any): void {
|
setCity(city: any): void {
|
||||||
const updates: any = {};
|
const updates: any = {};
|
||||||
if (city) {
|
if (city) {
|
||||||
updates.city = city;
|
updates.city = city;
|
||||||
updates.state = city.state;
|
updates.state = city.state;
|
||||||
// Automatically set radius to 50 miles and enable radius search
|
// Automatically set radius to 50 miles and enable radius search
|
||||||
updates.searchType = 'radius';
|
updates.searchType = 'radius';
|
||||||
updates.radius = 50;
|
updates.radius = 50;
|
||||||
} else {
|
} else {
|
||||||
updates.city = null;
|
updates.city = null;
|
||||||
updates.radius = null;
|
updates.radius = null;
|
||||||
updates.searchType = 'exact';
|
updates.searchType = 'exact';
|
||||||
}
|
}
|
||||||
this.updateCriteria(updates);
|
this.updateCriteria(updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
setRadius(radius: number): void {
|
setRadius(radius: number): void {
|
||||||
this.updateCriteria({ radius });
|
this.updateCriteria({ radius });
|
||||||
}
|
}
|
||||||
|
|
||||||
onCriteriaChange(): void {
|
onCriteriaChange(): void {
|
||||||
this.triggerSearch();
|
this.triggerSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debounced search for text inputs
|
// Debounced search for text inputs
|
||||||
debouncedSearch(): void {
|
debouncedSearch(): void {
|
||||||
this.searchDebounce$.next();
|
this.searchDebounce$.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear all filters
|
// Clear all filters
|
||||||
clearFilter(): void {
|
clearFilter(): void {
|
||||||
if (this.isModal) {
|
if (this.isModal) {
|
||||||
// In modal: Reset locally
|
// In modal: Reset locally
|
||||||
const defaultCriteria = this.getDefaultCriteria();
|
const defaultCriteria = this.getDefaultCriteria();
|
||||||
this.criteria = defaultCriteria;
|
this.criteria = defaultCriteria;
|
||||||
this.setTotalNumberOfResults();
|
this.setTotalNumberOfResults();
|
||||||
} else {
|
} else {
|
||||||
// Embedded: Use state service
|
// Embedded: Use state service
|
||||||
this.filterStateService.clearFilters('commercialPropertyListings');
|
this.filterStateService.clearFilters('commercialPropertyListings');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modal-specific methods
|
// Modal-specific methods
|
||||||
closeAndSearch(): void {
|
closeAndSearch(): void {
|
||||||
if (this.isModal) {
|
if (this.isModal) {
|
||||||
// Save changes to state
|
// Save changes to state
|
||||||
this.filterStateService.setCriteria('commercialPropertyListings', this.criteria);
|
this.filterStateService.setCriteria('commercialPropertyListings', this.criteria);
|
||||||
this.modalService.accept();
|
this.modalService.accept();
|
||||||
this.searchService.search('commercialPropertyListings');
|
this.searchService.search('commercialPropertyListings');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
if (this.isModal) {
|
if (this.isModal) {
|
||||||
// Discard changes
|
// Discard changes
|
||||||
this.modalService.reject(this.backupCriteria);
|
this.modalService.reject(this.backupCriteria);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper methods
|
// Helper methods
|
||||||
public updateCriteria(updates: any): void {
|
public updateCriteria(updates: any): void {
|
||||||
if (this.isModal) {
|
if (this.isModal) {
|
||||||
// In modal: Update locally only
|
// In modal: Update locally only
|
||||||
this.criteria = { ...this.criteria, ...updates };
|
this.criteria = { ...this.criteria, ...updates };
|
||||||
this.setTotalNumberOfResults();
|
this.setTotalNumberOfResults();
|
||||||
} else {
|
} else {
|
||||||
// Embedded: Update through state service
|
// Embedded: Update through state service
|
||||||
this.filterStateService.updateCriteria('commercialPropertyListings', updates);
|
this.filterStateService.updateCriteria('commercialPropertyListings', updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger search after update
|
// Trigger search after update
|
||||||
this.debouncedSearch();
|
this.debouncedSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
private triggerSearch(): void {
|
private triggerSearch(): void {
|
||||||
if (this.isModal) {
|
if (this.isModal) {
|
||||||
// In modal: Only update count
|
// In modal: Only update count
|
||||||
this.setTotalNumberOfResults();
|
this.setTotalNumberOfResults();
|
||||||
this.cancelDisable = true;
|
this.cancelDisable = true;
|
||||||
} else {
|
} else {
|
||||||
// Embedded: Full search
|
// Embedded: Full search
|
||||||
this.searchService.search('commercialPropertyListings');
|
this.searchService.search('commercialPropertyListings');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private setTotalNumberOfResults(): void {
|
private setTotalNumberOfResults(): void {
|
||||||
this.numberOfResults$ = this.listingService.getNumberOfListings('commercialProperty', this.criteria);
|
this.numberOfResults$ = this.listingService.getNumberOfListings('commercialProperty', this.criteria);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDefaultCriteria(): CommercialPropertyListingCriteria {
|
private getDefaultCriteria(): CommercialPropertyListingCriteria {
|
||||||
// Access the private method through a workaround or create it here
|
// Access the private method through a workaround or create it here
|
||||||
return {
|
return {
|
||||||
criteriaType: 'commercialPropertyListings',
|
criteriaType: 'commercialPropertyListings',
|
||||||
types: [],
|
types: [],
|
||||||
state: null,
|
state: null,
|
||||||
city: null,
|
city: null,
|
||||||
radius: null,
|
radius: null,
|
||||||
searchType: 'exact' as const,
|
searchType: 'exact' as const,
|
||||||
minPrice: null,
|
minPrice: null,
|
||||||
maxPrice: null,
|
maxPrice: null,
|
||||||
title: null,
|
title: null,
|
||||||
brokerName: null,
|
brokerName: null,
|
||||||
prompt: null,
|
prompt: null,
|
||||||
page: 1,
|
page: 1,
|
||||||
start: 0,
|
start: 0,
|
||||||
length: 12,
|
length: 12,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
hasActiveFilters(): boolean {
|
hasActiveFilters(): boolean {
|
||||||
if (!this.criteria) return false;
|
if (!this.criteria) return false;
|
||||||
|
|
||||||
return !!(
|
return !!(
|
||||||
this.criteria.state ||
|
this.criteria.state ||
|
||||||
this.criteria.city ||
|
this.criteria.city ||
|
||||||
this.criteria.minPrice ||
|
this.criteria.minPrice ||
|
||||||
this.criteria.maxPrice ||
|
this.criteria.maxPrice ||
|
||||||
this.criteria.types?.length ||
|
this.criteria.types?.length ||
|
||||||
this.criteria.title ||
|
this.criteria.title ||
|
||||||
this.criteria.brokerName
|
this.criteria.brokerName
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
trackByFn(item: GeoResult): any {
|
trackByFn(item: GeoResult): any {
|
||||||
return item.id;
|
return item.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.destroy$.next();
|
this.destroy$.next();
|
||||||
this.destroy$.complete();
|
this.destroy$.complete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,415 +1,415 @@
|
||||||
<div
|
<div
|
||||||
*ngIf="isModal && (modalService.modalVisible$ | async)?.visible && (modalService.modalVisible$ | async)?.type === 'businessListings'"
|
*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"
|
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 w-full max-h-full">
|
||||||
<div class="relative bg-white rounded-lg shadow">
|
<div class="relative bg-white rounded-lg shadow">
|
||||||
<div class="flex items-start justify-between p-4 border-b rounded-t bg-primary-600">
|
<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>
|
<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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
<span class="sr-only">Close Modal</span>
|
<span class="sr-only">Close Modal</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6 space-y-6">
|
<div class="p-6 space-y-6">
|
||||||
<div class="flex space-x-4 mb-4">
|
<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>
|
<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>
|
<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">
|
<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
|
Clear all Filter
|
||||||
<div class="tooltip-arrow" data-popper-arrow></div>
|
<div class="tooltip-arrow" data-popper-arrow></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Display active filters as tags -->
|
<!-- Display active filters as tags -->
|
||||||
<div class="flex flex-wrap gap-2 mb-4" *ngIf="hasActiveFilters()">
|
<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">
|
<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>
|
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Revenue: {{ criteria.minRevenue || 'Any' }} - {{ criteria.maxRevenue || 'Any' }} <button (click)="removeFilter('revenue')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Cashflow: {{ criteria.minCashFlow || 'Any' }} - {{ criteria.maxCashFlow || 'Any' }} <button (click)="removeFilter('cashflow')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Property Type: {{ getSelectedPropertyTypeName() }} <button (click)="removeFilter('propertyType')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Employees: {{ criteria.minNumberEmployees || 'Any' }} - {{ criteria.maxNumberEmployees || 'Any' }} <button (click)="removeFilter('employees')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Established: {{ criteria.establishedMin || 'Any' }} <button (click)="removeFilter('established')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 gap-6">
|
<div class="grid grid-cols-1 gap-6">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="state" class="block mb-2 text-sm font-medium text-neutral-900">Location - State</label>
|
<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>
|
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<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>
|
||||||
<div *ngIf="criteria.city">
|
<div *ngIf="criteria.city">
|
||||||
<label class="block mb-2 text-sm font-medium text-neutral-900">Search Type</label>
|
<label class="block mb-2 text-sm font-medium text-neutral-900">Search Type</label>
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<label class="inline-flex items-center">
|
<label class="inline-flex items-center">
|
||||||
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="exact" />
|
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="exact" />
|
||||||
<span class="ml-2">Exact City</span>
|
<span class="ml-2">Exact City</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="inline-flex items-center">
|
<label class="inline-flex items-center">
|
||||||
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
|
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
|
||||||
<span class="ml-2">Radius Search</span>
|
<span class="ml-2">Radius Search</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2">
|
<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>
|
<label class="block mb-2 text-sm font-medium text-neutral-900">Select Radius (in miles)</label>
|
||||||
<div class="flex flex-wrap">
|
<div class="flex flex-wrap">
|
||||||
@for (radius of [5, 20, 50, 100, 200, 300, 400, 500]; track radius) {
|
@for (radius of [5, 20, 50, 100, 200, 300, 400, 500]; track radius) {
|
||||||
<button
|
<button
|
||||||
type="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"
|
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'"
|
[ngClass]="criteria.radius === radius ? 'text-white bg-neutral-500' : 'text-neutral-900 bg-white'"
|
||||||
(click)="setRadius(radius)"
|
(click)="setRadius(radius)"
|
||||||
>
|
>
|
||||||
{{ radius }}
|
{{ radius }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="price" class="block mb-2 text-sm font-medium text-neutral-900">Price</label>
|
<label for="price" class="block mb-2 text-sm font-medium text-neutral-900">Price</label>
|
||||||
<div class="flex items-center space-x-2">
|
<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 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>
|
</app-validated-price>
|
||||||
<span>-</span>
|
<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 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>
|
</app-validated-price>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="salesRevenue" class="block mb-2 text-sm font-medium text-neutral-900">Sales Revenue</label>
|
<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">
|
<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 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>
|
</app-validated-price>
|
||||||
<span>-</span>
|
<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 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>
|
</app-validated-price>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="cashflow" class="block mb-2 text-sm font-medium text-neutral-900">Cashflow</label>
|
<label for="cashflow" class="block mb-2 text-sm font-medium text-neutral-900">Cashflow</label>
|
||||||
<div class="flex items-center space-x-2">
|
<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 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>
|
</app-validated-price>
|
||||||
<span>-</span>
|
<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 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>
|
</app-validated-price>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="title" class="block mb-2 text-sm font-medium text-neutral-900">Title / Description (Free Search)</label>
|
<label for="title" class="block mb-2 text-sm font-medium text-neutral-900">Title / Description (Free Search)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="title"
|
id="title"
|
||||||
[ngModel]="criteria.title"
|
[ngModel]="criteria.title"
|
||||||
(ngModelChange)="updateCriteria({ title: $event })"
|
(ngModelChange)="updateCriteria({ title: $event })"
|
||||||
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-full p-2.5"
|
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-full p-2.5"
|
||||||
placeholder="e.g. Restaurant"
|
placeholder="e.g. Restaurant"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block mb-2 text-sm font-medium text-neutral-900">Category</label>
|
<label class="block mb-2 text-sm font-medium text-neutral-900">Category</label>
|
||||||
<ng-select
|
<ng-select
|
||||||
class="custom"
|
class="custom"
|
||||||
[items]="selectOptions.typesOfBusiness"
|
[items]="selectOptions.typesOfBusiness"
|
||||||
bindLabel="name"
|
bindLabel="name"
|
||||||
bindValue="value"
|
bindValue="value"
|
||||||
[ngModel]="criteria.types"
|
[ngModel]="criteria.types"
|
||||||
(ngModelChange)="onCategoryChange($event)"
|
(ngModelChange)="onCategoryChange($event)"
|
||||||
[multiple]="true"
|
[multiple]="true"
|
||||||
[closeOnSelect]="true"
|
[closeOnSelect]="true"
|
||||||
placeholder="Select categories"
|
placeholder="Select categories"
|
||||||
></ng-select>
|
></ng-select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block mb-2 text-sm font-medium text-neutral-900">Type of Property</label>
|
<label class="block mb-2 text-sm font-medium text-neutral-900">Type of Property</label>
|
||||||
<ng-select
|
<ng-select
|
||||||
class="custom"
|
class="custom"
|
||||||
[items]="propertyTypeOptions"
|
[items]="propertyTypeOptions"
|
||||||
bindLabel="name"
|
bindLabel="name"
|
||||||
bindValue="value"
|
bindValue="value"
|
||||||
[ngModel]="selectedPropertyType"
|
[ngModel]="selectedPropertyType"
|
||||||
(ngModelChange)="onPropertyTypeChange($event)"
|
(ngModelChange)="onPropertyTypeChange($event)"
|
||||||
placeholder="Select property type"
|
placeholder="Select property type"
|
||||||
></ng-select>
|
></ng-select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="numberEmployees" class="block mb-2 text-sm font-medium text-neutral-900">Number of Employees</label>
|
<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">
|
<div class="flex items-center space-x-2">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="numberEmployees-from"
|
id="numberEmployees-from"
|
||||||
[ngModel]="criteria.minNumberEmployees"
|
[ngModel]="criteria.minNumberEmployees"
|
||||||
(ngModelChange)="updateCriteria({ minNumberEmployees: $event })"
|
(ngModelChange)="updateCriteria({ minNumberEmployees: $event })"
|
||||||
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-1/2 p-2.5"
|
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-1/2 p-2.5"
|
||||||
placeholder="From"
|
placeholder="From"
|
||||||
/>
|
/>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="numberEmployees-to"
|
id="numberEmployees-to"
|
||||||
[ngModel]="criteria.maxNumberEmployees"
|
[ngModel]="criteria.maxNumberEmployees"
|
||||||
(ngModelChange)="updateCriteria({ maxNumberEmployees: $event })"
|
(ngModelChange)="updateCriteria({ maxNumberEmployees: $event })"
|
||||||
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-1/2 p-2.5"
|
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-1/2 p-2.5"
|
||||||
placeholder="To"
|
placeholder="To"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="establishedMin" class="block mb-2 text-sm font-medium text-neutral-900">Minimum years established</label>
|
<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">
|
<div class="flex items-center space-x-2">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="establishedMin"
|
id="establishedMin"
|
||||||
[ngModel]="criteria.establishedMin"
|
[ngModel]="criteria.establishedMin"
|
||||||
(ngModelChange)="updateCriteria({ establishedMin: $event })"
|
(ngModelChange)="updateCriteria({ establishedMin: $event })"
|
||||||
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-1/2 p-2.5"
|
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-1/2 p-2.5"
|
||||||
placeholder="YY"
|
placeholder="YY"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="brokername" class="block mb-2 text-sm font-medium text-neutral-900">Broker Name / Company Name</label>
|
<label for="brokername" class="block mb-2 text-sm font-medium text-neutral-900">Broker Name / Company Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="brokername"
|
id="brokername"
|
||||||
[ngModel]="criteria.brokerName"
|
[ngModel]="criteria.brokerName"
|
||||||
(ngModelChange)="updateCriteria({ brokerName: $event })"
|
(ngModelChange)="updateCriteria({ brokerName: $event })"
|
||||||
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-full p-2.5"
|
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-full p-2.5"
|
||||||
placeholder="e.g. Brokers Invest"
|
placeholder="e.g. Brokers Invest"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- ################################################################################## -->
|
<!-- ################################################################################## -->
|
||||||
<!-- ################################################################################## -->
|
<!-- ################################################################################## -->
|
||||||
<!-- ################################################################################## -->
|
<!-- ################################################################################## -->
|
||||||
<div *ngIf="!isModal" class="space-y-6">
|
<div *ngIf="!isModal" class="space-y-6">
|
||||||
<div class="flex space-x-4 mb-4">
|
<div class="flex space-x-4 mb-4">
|
||||||
<h3 class="text-xl font-semibold text-neutral-900">Filter ({{ numberOfResults$ | async }})</h3>
|
<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>
|
<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">
|
<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
|
Clear all Filter
|
||||||
<div class="tooltip-arrow" data-popper-arrow></div>
|
<div class="tooltip-arrow" data-popper-arrow></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Display active filters as tags -->
|
<!-- Display active filters as tags -->
|
||||||
<div class="flex flex-wrap gap-2" *ngIf="hasActiveFilters()">
|
<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">
|
<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>
|
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Revenue: {{ criteria.minRevenue || 'Any' }} - {{ criteria.maxRevenue || 'Any' }} <button (click)="removeFilter('revenue')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Cashflow: {{ criteria.minCashFlow || 'Any' }} - {{ criteria.maxCashFlow || 'Any' }} <button (click)="removeFilter('cashflow')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Property Type: {{ getSelectedPropertyTypeName() }} <button (click)="removeFilter('propertyType')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Employees: {{ criteria.minNumberEmployees || 'Any' }} - {{ criteria.maxNumberEmployees || 'Any' }} <button (click)="removeFilter('employees')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Years established: {{ criteria.establishedMin || 'Any' }} <button (click)="removeFilter('established')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</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">
|
<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>
|
Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@if(criteria.criteriaType==='businessListings') {
|
@if(criteria.criteriaType==='businessListings') {
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="state" class="block mb-2 text-sm font-medium text-neutral-900">Location - State</label>
|
<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>
|
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<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>
|
||||||
<div *ngIf="criteria.city">
|
<div *ngIf="criteria.city">
|
||||||
<label class="block mb-2 text-sm font-medium text-neutral-900">Search Type</label>
|
<label class="block mb-2 text-sm font-medium text-neutral-900">Search Type</label>
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<label class="inline-flex items-center">
|
<label class="inline-flex items-center">
|
||||||
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="exact" />
|
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="exact" />
|
||||||
<span class="ml-2">Exact City</span>
|
<span class="ml-2">Exact City</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="inline-flex items-center">
|
<label class="inline-flex items-center">
|
||||||
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
|
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
|
||||||
<span class="ml-2">Radius Search</span>
|
<span class="ml-2">Radius Search</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2">
|
<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>
|
<label class="block mb-2 text-sm font-medium text-neutral-900">Select Radius (in miles)</label>
|
||||||
<div class="flex flex-wrap">
|
<div class="flex flex-wrap">
|
||||||
@for (radius of [5, 20, 50, 100, 200, 300, 400, 500]; track radius) {
|
@for (radius of [5, 20, 50, 100, 200, 300, 400, 500]; track radius) {
|
||||||
<button
|
<button
|
||||||
type="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"
|
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'"
|
[ngClass]="criteria.radius === radius ? 'text-white bg-neutral-500' : 'text-neutral-900 bg-white'"
|
||||||
(click)="setRadius(radius)"
|
(click)="setRadius(radius)"
|
||||||
>
|
>
|
||||||
{{ radius }}
|
{{ radius }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="price" class="block mb-2 text-sm font-medium text-neutral-900">Price</label>
|
<label for="price" class="block mb-2 text-sm font-medium text-neutral-900">Price</label>
|
||||||
<div class="flex items-center space-x-2">
|
<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>
|
<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>
|
<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>
|
<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>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="salesRevenue" class="block mb-2 text-sm font-medium text-neutral-900">Sales Revenue</label>
|
<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">
|
<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 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>
|
</app-validated-price>
|
||||||
<span>-</span>
|
<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 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>
|
</app-validated-price>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="cashflow" class="block mb-2 text-sm font-medium text-neutral-900">Cashflow</label>
|
<label for="cashflow" class="block mb-2 text-sm font-medium text-neutral-900">Cashflow</label>
|
||||||
<div class="flex items-center space-x-2">
|
<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 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>
|
</app-validated-price>
|
||||||
<span>-</span>
|
<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 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>
|
</app-validated-price>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="title" class="block mb-2 text-sm font-medium text-neutral-900">Title / Description (Free Search)</label>
|
<label for="title" class="block mb-2 text-sm font-medium text-neutral-900">Title / Description (Free Search)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="title"
|
id="title"
|
||||||
[ngModel]="criteria.title"
|
[ngModel]="criteria.title"
|
||||||
(ngModelChange)="updateCriteria({ title: $event })"
|
(ngModelChange)="updateCriteria({ title: $event })"
|
||||||
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-full p-2.5"
|
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-full p-2.5"
|
||||||
placeholder="e.g. Restaurant"
|
placeholder="e.g. Restaurant"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block mb-2 text-sm font-medium text-neutral-900">Category</label>
|
<label class="block mb-2 text-sm font-medium text-neutral-900">Category</label>
|
||||||
<ng-select
|
<ng-select
|
||||||
class="custom"
|
class="custom"
|
||||||
[items]="selectOptions.typesOfBusiness"
|
[items]="selectOptions.typesOfBusiness"
|
||||||
bindLabel="name"
|
bindLabel="name"
|
||||||
bindValue="value"
|
bindValue="value"
|
||||||
[ngModel]="criteria.types"
|
[ngModel]="criteria.types"
|
||||||
(ngModelChange)="onCategoryChange($event)"
|
(ngModelChange)="onCategoryChange($event)"
|
||||||
[multiple]="true"
|
[multiple]="true"
|
||||||
[closeOnSelect]="true"
|
[closeOnSelect]="true"
|
||||||
placeholder="Select categories"
|
placeholder="Select categories"
|
||||||
></ng-select>
|
></ng-select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block mb-2 text-sm font-medium text-neutral-900">Type of Property</label>
|
<label class="block mb-2 text-sm font-medium text-neutral-900">Type of Property</label>
|
||||||
<ng-select
|
<ng-select
|
||||||
class="custom"
|
class="custom"
|
||||||
[items]="propertyTypeOptions"
|
[items]="propertyTypeOptions"
|
||||||
bindLabel="name"
|
bindLabel="name"
|
||||||
bindValue="value"
|
bindValue="value"
|
||||||
[ngModel]="selectedPropertyType"
|
[ngModel]="selectedPropertyType"
|
||||||
(ngModelChange)="onPropertyTypeChange($event)"
|
(ngModelChange)="onPropertyTypeChange($event)"
|
||||||
placeholder="Select property type"
|
placeholder="Select property type"
|
||||||
></ng-select>
|
></ng-select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="numberEmployees" class="block mb-2 text-sm font-medium text-neutral-900">Number of Employees</label>
|
<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">
|
<div class="flex items-center space-x-2">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="numberEmployees-from"
|
id="numberEmployees-from"
|
||||||
[ngModel]="criteria.minNumberEmployees"
|
[ngModel]="criteria.minNumberEmployees"
|
||||||
(ngModelChange)="updateCriteria({ minNumberEmployees: $event })"
|
(ngModelChange)="updateCriteria({ minNumberEmployees: $event })"
|
||||||
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-1/2 p-2.5"
|
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-1/2 p-2.5"
|
||||||
placeholder="From"
|
placeholder="From"
|
||||||
/>
|
/>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="numberEmployees-to"
|
id="numberEmployees-to"
|
||||||
[ngModel]="criteria.maxNumberEmployees"
|
[ngModel]="criteria.maxNumberEmployees"
|
||||||
(ngModelChange)="updateCriteria({ maxNumberEmployees: $event })"
|
(ngModelChange)="updateCriteria({ maxNumberEmployees: $event })"
|
||||||
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-1/2 p-2.5"
|
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-1/2 p-2.5"
|
||||||
placeholder="To"
|
placeholder="To"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="establishedMin" class="block mb-2 text-sm font-medium text-neutral-900">Minimum years established</label>
|
<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">
|
<div class="flex items-center space-x-2">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
id="establishedMin"
|
id="establishedMin"
|
||||||
[ngModel]="criteria.establishedMin"
|
[ngModel]="criteria.establishedMin"
|
||||||
(ngModelChange)="updateCriteria({ establishedMin: $event })"
|
(ngModelChange)="updateCriteria({ establishedMin: $event })"
|
||||||
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-1/2 p-2.5"
|
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-1/2 p-2.5"
|
||||||
placeholder="YY"
|
placeholder="YY"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="brokername" class="block mb-2 text-sm font-medium text-neutral-900">Broker Name / Company Name</label>
|
<label for="brokername" class="block mb-2 text-sm font-medium text-neutral-900">Broker Name / Company Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="brokername"
|
id="brokername"
|
||||||
[ngModel]="criteria.brokerName"
|
[ngModel]="criteria.brokerName"
|
||||||
(ngModelChange)="updateCriteria({ brokerName: $event })"
|
(ngModelChange)="updateCriteria({ brokerName: $event })"
|
||||||
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-full p-2.5"
|
class="bg-neutral-50 border border-neutral-300 text-sm rounded-lg block w-full p-2.5"
|
||||||
placeholder="e.g. Brokers Invest"
|
placeholder="e.g. Brokers Invest"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,445 +1,445 @@
|
||||||
import { AsyncPipe, NgIf } from '@angular/common';
|
import { AsyncPipe, NgIf } from '@angular/common';
|
||||||
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { NgSelectModule } from '@ng-select/ng-select';
|
import { NgSelectModule } from '@ng-select/ng-select';
|
||||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||||
import { catchError, concat, debounceTime, distinctUntilChanged, map, Observable, of, Subject, switchMap, takeUntil, tap } from 'rxjs';
|
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 { BusinessListingCriteria, CountyResult, GeoResult, KeyValue, KeyValueStyle } from '../../../../../bizmatch-server/src/models/main.model';
|
||||||
import { FilterStateService } from '../../services/filter-state.service';
|
import { FilterStateService } from '../../services/filter-state.service';
|
||||||
import { GeoService } from '../../services/geo.service';
|
import { GeoService } from '../../services/geo.service';
|
||||||
import { ListingsService } from '../../services/listings.service';
|
import { ListingsService } from '../../services/listings.service';
|
||||||
import { SearchService } from '../../services/search.service';
|
import { SearchService } from '../../services/search.service';
|
||||||
import { SelectOptionsService } from '../../services/select-options.service';
|
import { SelectOptionsService } from '../../services/select-options.service';
|
||||||
import { UserService } from '../../services/user.service';
|
import { UserService } from '../../services/user.service';
|
||||||
import { SharedModule } from '../../shared/shared/shared.module';
|
import { SharedModule } from '../../shared/shared/shared.module';
|
||||||
import { ValidatedCityComponent } from '../validated-city/validated-city.component';
|
import { ValidatedCityComponent } from '../validated-city/validated-city.component';
|
||||||
import { ValidatedPriceComponent } from '../validated-price/validated-price.component';
|
import { ValidatedPriceComponent } from '../validated-price/validated-price.component';
|
||||||
import { ModalService } from './modal.service';
|
import { ModalService } from './modal.service';
|
||||||
|
|
||||||
@UntilDestroy()
|
@UntilDestroy()
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-search-modal',
|
selector: 'app-search-modal',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [SharedModule, AsyncPipe, NgIf, NgSelectModule, ValidatedCityComponent, ValidatedPriceComponent],
|
imports: [SharedModule, AsyncPipe, NgIf, NgSelectModule, ValidatedCityComponent, ValidatedPriceComponent],
|
||||||
templateUrl: './search-modal.component.html',
|
templateUrl: './search-modal.component.html',
|
||||||
styleUrl: './search-modal.component.scss',
|
styleUrl: './search-modal.component.scss',
|
||||||
})
|
})
|
||||||
export class SearchModalComponent implements OnInit, OnDestroy {
|
export class SearchModalComponent implements OnInit, OnDestroy {
|
||||||
@Input() isModal: boolean = true;
|
@Input() isModal: boolean = true;
|
||||||
|
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
private searchDebounce$ = new Subject<void>();
|
private searchDebounce$ = new Subject<void>();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
criteria: BusinessListingCriteria;
|
criteria: BusinessListingCriteria;
|
||||||
backupCriteria: any;
|
backupCriteria: any;
|
||||||
currentListingType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
|
currentListingType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
|
||||||
|
|
||||||
// Geo search
|
// Geo search
|
||||||
counties$: Observable<CountyResult[]>;
|
counties$: Observable<CountyResult[]>;
|
||||||
countyLoading = false;
|
countyLoading = false;
|
||||||
countyInput$ = new Subject<string>();
|
countyInput$ = new Subject<string>();
|
||||||
|
|
||||||
// Property type for business listings
|
// Property type for business listings
|
||||||
selectedPropertyType: string | null = null;
|
selectedPropertyType: string | null = null;
|
||||||
propertyTypeOptions = [
|
propertyTypeOptions = [
|
||||||
{ name: 'Real Estate', value: 'realEstateChecked' },
|
{ name: 'Real Estate', value: 'realEstateChecked' },
|
||||||
{ name: 'Leased Location', value: 'leasedLocation' },
|
{ name: 'Leased Location', value: 'leasedLocation' },
|
||||||
{ name: 'Franchise', value: 'franchiseResale' },
|
{ name: 'Franchise', value: 'franchiseResale' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Results count
|
// Results count
|
||||||
numberOfResults$: Observable<number>;
|
numberOfResults$: Observable<number>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public selectOptions: SelectOptionsService,
|
public selectOptions: SelectOptionsService,
|
||||||
public modalService: ModalService,
|
public modalService: ModalService,
|
||||||
private geoService: GeoService,
|
private geoService: GeoService,
|
||||||
private filterStateService: FilterStateService,
|
private filterStateService: FilterStateService,
|
||||||
private listingService: ListingsService,
|
private listingService: ListingsService,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
private searchService: SearchService,
|
private searchService: SearchService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
// Load counties
|
// Load counties
|
||||||
this.loadCounties();
|
this.loadCounties();
|
||||||
|
|
||||||
if (this.isModal) {
|
if (this.isModal) {
|
||||||
// Modal mode: Wait for messages from ModalService
|
// Modal mode: Wait for messages from ModalService
|
||||||
this.modalService.message$.pipe(untilDestroyed(this)).subscribe(criteria => {
|
this.modalService.message$.pipe(untilDestroyed(this)).subscribe(criteria => {
|
||||||
this.initializeWithCriteria(criteria);
|
this.initializeWithCriteria(criteria);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.modalService.modalVisible$.pipe(untilDestroyed(this)).subscribe(val => {
|
this.modalService.modalVisible$.pipe(untilDestroyed(this)).subscribe(val => {
|
||||||
if (val.visible) {
|
if (val.visible) {
|
||||||
// Reset pagination when modal opens
|
// Reset pagination when modal opens
|
||||||
if (this.criteria) {
|
if (this.criteria) {
|
||||||
this.criteria.page = 1;
|
this.criteria.page = 1;
|
||||||
this.criteria.start = 0;
|
this.criteria.start = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Embedded mode: Determine type from route and subscribe to state
|
// Embedded mode: Determine type from route and subscribe to state
|
||||||
this.determineListingType();
|
this.determineListingType();
|
||||||
this.subscribeToStateChanges();
|
this.subscribeToStateChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup debounced search
|
// Setup debounced search
|
||||||
this.searchDebounce$.pipe(debounceTime(400), takeUntil(this.destroy$)).subscribe(() => this.triggerSearch());
|
this.searchDebounce$.pipe(debounceTime(400), takeUntil(this.destroy$)).subscribe(() => this.triggerSearch());
|
||||||
}
|
}
|
||||||
|
|
||||||
private initializeWithCriteria(criteria: any): void {
|
private initializeWithCriteria(criteria: any): void {
|
||||||
this.criteria = criteria;
|
this.criteria = criteria;
|
||||||
this.currentListingType = criteria?.criteriaType;
|
this.currentListingType = criteria?.criteriaType;
|
||||||
this.backupCriteria = JSON.parse(JSON.stringify(criteria));
|
this.backupCriteria = JSON.parse(JSON.stringify(criteria));
|
||||||
this.updateSelectedPropertyType();
|
this.updateSelectedPropertyType();
|
||||||
this.setTotalNumberOfResults();
|
this.setTotalNumberOfResults();
|
||||||
}
|
}
|
||||||
|
|
||||||
private determineListingType(): void {
|
private determineListingType(): void {
|
||||||
const url = window.location.pathname;
|
const url = window.location.pathname;
|
||||||
if (url.includes('businessListings')) {
|
if (url.includes('businessListings')) {
|
||||||
this.currentListingType = 'businessListings';
|
this.currentListingType = 'businessListings';
|
||||||
} else if (url.includes('commercialPropertyListings')) {
|
} else if (url.includes('commercialPropertyListings')) {
|
||||||
this.currentListingType = 'commercialPropertyListings';
|
this.currentListingType = 'commercialPropertyListings';
|
||||||
} else if (url.includes('brokerListings')) {
|
} else if (url.includes('brokerListings')) {
|
||||||
this.currentListingType = 'brokerListings';
|
this.currentListingType = 'brokerListings';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private subscribeToStateChanges(): void {
|
private subscribeToStateChanges(): void {
|
||||||
if (!this.isModal && this.currentListingType) {
|
if (!this.isModal && this.currentListingType) {
|
||||||
this.filterStateService
|
this.filterStateService
|
||||||
.getState$(this.currentListingType)
|
.getState$(this.currentListingType)
|
||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe(state => {
|
.subscribe(state => {
|
||||||
this.criteria = { ...state.criteria };
|
this.criteria = { ...state.criteria };
|
||||||
this.updateSelectedPropertyType();
|
this.updateSelectedPropertyType();
|
||||||
this.setTotalNumberOfResults();
|
this.setTotalNumberOfResults();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadCounties(): void {
|
private loadCounties(): void {
|
||||||
this.counties$ = concat(
|
this.counties$ = concat(
|
||||||
of([]), // default items
|
of([]), // default items
|
||||||
this.countyInput$.pipe(
|
this.countyInput$.pipe(
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
tap(() => (this.countyLoading = true)),
|
tap(() => (this.countyLoading = true)),
|
||||||
switchMap(term =>
|
switchMap(term =>
|
||||||
this.geoService.findCountiesStartingWith(term).pipe(
|
this.geoService.findCountiesStartingWith(term).pipe(
|
||||||
catchError(() => of([])),
|
catchError(() => of([])),
|
||||||
map(counties => counties.map(county => county.name)),
|
map(counties => counties.map(county => county.name)),
|
||||||
tap(() => (this.countyLoading = false)),
|
tap(() => (this.countyLoading = false)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter removal methods
|
// Filter removal methods
|
||||||
removeFilter(filterType: string): void {
|
removeFilter(filterType: string): void {
|
||||||
const updates: any = {};
|
const updates: any = {};
|
||||||
|
|
||||||
switch (filterType) {
|
switch (filterType) {
|
||||||
case 'state':
|
case 'state':
|
||||||
updates.state = null;
|
updates.state = null;
|
||||||
updates.city = null;
|
updates.city = null;
|
||||||
updates.radius = null;
|
updates.radius = null;
|
||||||
updates.searchType = 'exact';
|
updates.searchType = 'exact';
|
||||||
break;
|
break;
|
||||||
case 'city':
|
case 'city':
|
||||||
updates.city = null;
|
updates.city = null;
|
||||||
updates.radius = null;
|
updates.radius = null;
|
||||||
updates.searchType = 'exact';
|
updates.searchType = 'exact';
|
||||||
break;
|
break;
|
||||||
case 'price':
|
case 'price':
|
||||||
updates.minPrice = null;
|
updates.minPrice = null;
|
||||||
updates.maxPrice = null;
|
updates.maxPrice = null;
|
||||||
break;
|
break;
|
||||||
case 'revenue':
|
case 'revenue':
|
||||||
updates.minRevenue = null;
|
updates.minRevenue = null;
|
||||||
updates.maxRevenue = null;
|
updates.maxRevenue = null;
|
||||||
break;
|
break;
|
||||||
case 'cashflow':
|
case 'cashflow':
|
||||||
updates.minCashFlow = null;
|
updates.minCashFlow = null;
|
||||||
updates.maxCashFlow = null;
|
updates.maxCashFlow = null;
|
||||||
break;
|
break;
|
||||||
case 'types':
|
case 'types':
|
||||||
updates.types = [];
|
updates.types = [];
|
||||||
break;
|
break;
|
||||||
case 'propertyType':
|
case 'propertyType':
|
||||||
updates.realEstateChecked = false;
|
updates.realEstateChecked = false;
|
||||||
updates.leasedLocation = false;
|
updates.leasedLocation = false;
|
||||||
updates.franchiseResale = false;
|
updates.franchiseResale = false;
|
||||||
this.selectedPropertyType = null;
|
this.selectedPropertyType = null;
|
||||||
break;
|
break;
|
||||||
case 'employees':
|
case 'employees':
|
||||||
updates.minNumberEmployees = null;
|
updates.minNumberEmployees = null;
|
||||||
updates.maxNumberEmployees = null;
|
updates.maxNumberEmployees = null;
|
||||||
break;
|
break;
|
||||||
case 'established':
|
case 'established':
|
||||||
updates.establishedMin = null;
|
updates.establishedMin = null;
|
||||||
break;
|
break;
|
||||||
case 'brokerName':
|
case 'brokerName':
|
||||||
updates.brokerName = null;
|
updates.brokerName = null;
|
||||||
break;
|
break;
|
||||||
case 'title':
|
case 'title':
|
||||||
updates.title = null;
|
updates.title = null;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateCriteria(updates);
|
this.updateCriteria(updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Category handling
|
// Category handling
|
||||||
onCategoryChange(selectedCategories: string[]): void {
|
onCategoryChange(selectedCategories: string[]): void {
|
||||||
this.updateCriteria({ types: selectedCategories });
|
this.updateCriteria({ types: selectedCategories });
|
||||||
}
|
}
|
||||||
|
|
||||||
categoryClicked(checked: boolean, value: string): void {
|
categoryClicked(checked: boolean, value: string): void {
|
||||||
const types = [...(this.criteria.types || [])];
|
const types = [...(this.criteria.types || [])];
|
||||||
if (checked) {
|
if (checked) {
|
||||||
if (!types.includes(value)) {
|
if (!types.includes(value)) {
|
||||||
types.push(value);
|
types.push(value);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const index = types.indexOf(value);
|
const index = types.indexOf(value);
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
types.splice(index, 1);
|
types.splice(index, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.updateCriteria({ types });
|
this.updateCriteria({ types });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Property type handling (Business listings only)
|
// Property type handling (Business listings only)
|
||||||
onPropertyTypeChange(value: string): void {
|
onPropertyTypeChange(value: string): void {
|
||||||
const updates: any = {
|
const updates: any = {
|
||||||
realEstateChecked: false,
|
realEstateChecked: false,
|
||||||
leasedLocation: false,
|
leasedLocation: false,
|
||||||
franchiseResale: false,
|
franchiseResale: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (value) {
|
if (value) {
|
||||||
updates[value] = true;
|
updates[value] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.selectedPropertyType = value;
|
this.selectedPropertyType = value;
|
||||||
this.updateCriteria(updates);
|
this.updateCriteria(updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
onCheckboxChange(checkbox: string, value: boolean): void {
|
onCheckboxChange(checkbox: string, value: boolean): void {
|
||||||
const updates: any = {
|
const updates: any = {
|
||||||
realEstateChecked: false,
|
realEstateChecked: false,
|
||||||
leasedLocation: false,
|
leasedLocation: false,
|
||||||
franchiseResale: false,
|
franchiseResale: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
updates[checkbox] = value;
|
updates[checkbox] = value;
|
||||||
this.selectedPropertyType = value ? checkbox : null;
|
this.selectedPropertyType = value ? checkbox : null;
|
||||||
this.updateCriteria(updates);
|
this.updateCriteria(updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Location handling
|
// Location handling
|
||||||
setState(state: string): void {
|
setState(state: string): void {
|
||||||
const updates: any = { state };
|
const updates: any = { state };
|
||||||
if (!state) {
|
if (!state) {
|
||||||
updates.city = null;
|
updates.city = null;
|
||||||
updates.radius = null;
|
updates.radius = null;
|
||||||
updates.searchType = 'exact';
|
updates.searchType = 'exact';
|
||||||
}
|
}
|
||||||
this.updateCriteria(updates);
|
this.updateCriteria(updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
setCity(city: any): void {
|
setCity(city: any): void {
|
||||||
const updates: any = {};
|
const updates: any = {};
|
||||||
if (city) {
|
if (city) {
|
||||||
updates.city = city;
|
updates.city = city;
|
||||||
updates.state = city.state;
|
updates.state = city.state;
|
||||||
// Automatically set radius to 50 miles and enable radius search
|
// Automatically set radius to 50 miles and enable radius search
|
||||||
updates.searchType = 'radius';
|
updates.searchType = 'radius';
|
||||||
updates.radius = 50;
|
updates.radius = 50;
|
||||||
} else {
|
} else {
|
||||||
updates.city = null;
|
updates.city = null;
|
||||||
updates.radius = null;
|
updates.radius = null;
|
||||||
updates.searchType = 'exact';
|
updates.searchType = 'exact';
|
||||||
}
|
}
|
||||||
this.updateCriteria(updates);
|
this.updateCriteria(updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
setRadius(radius: number): void {
|
setRadius(radius: number): void {
|
||||||
this.updateCriteria({ radius });
|
this.updateCriteria({ radius });
|
||||||
}
|
}
|
||||||
|
|
||||||
onCriteriaChange(): void {
|
onCriteriaChange(): void {
|
||||||
this.triggerSearch();
|
this.triggerSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debounced search for text inputs
|
// Debounced search for text inputs
|
||||||
debouncedSearch(): void {
|
debouncedSearch(): void {
|
||||||
this.searchDebounce$.next();
|
this.searchDebounce$.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear all filters
|
// Clear all filters
|
||||||
clearFilter(): void {
|
clearFilter(): void {
|
||||||
if (this.isModal) {
|
if (this.isModal) {
|
||||||
// In modal: Reset locally
|
// In modal: Reset locally
|
||||||
const defaultCriteria = this.getDefaultCriteria();
|
const defaultCriteria = this.getDefaultCriteria();
|
||||||
this.criteria = defaultCriteria;
|
this.criteria = defaultCriteria;
|
||||||
this.updateSelectedPropertyType();
|
this.updateSelectedPropertyType();
|
||||||
this.setTotalNumberOfResults();
|
this.setTotalNumberOfResults();
|
||||||
} else {
|
} else {
|
||||||
// Embedded: Use state service
|
// Embedded: Use state service
|
||||||
this.filterStateService.clearFilters(this.currentListingType);
|
this.filterStateService.clearFilters(this.currentListingType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modal-specific methods
|
// Modal-specific methods
|
||||||
closeAndSearch(): void {
|
closeAndSearch(): void {
|
||||||
if (this.isModal) {
|
if (this.isModal) {
|
||||||
// Save changes to state
|
// Save changes to state
|
||||||
this.filterStateService.setCriteria(this.currentListingType, this.criteria);
|
this.filterStateService.setCriteria(this.currentListingType, this.criteria);
|
||||||
this.modalService.accept();
|
this.modalService.accept();
|
||||||
this.searchService.search(this.currentListingType);
|
this.searchService.search(this.currentListingType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
if (this.isModal) {
|
if (this.isModal) {
|
||||||
// Discard changes
|
// Discard changes
|
||||||
this.modalService.reject(this.backupCriteria);
|
this.modalService.reject(this.backupCriteria);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper methods
|
// Helper methods
|
||||||
public updateCriteria(updates: any): void {
|
public updateCriteria(updates: any): void {
|
||||||
if (this.isModal) {
|
if (this.isModal) {
|
||||||
// In modal: Update locally only
|
// In modal: Update locally only
|
||||||
this.criteria = { ...this.criteria, ...updates };
|
this.criteria = { ...this.criteria, ...updates };
|
||||||
this.setTotalNumberOfResults();
|
this.setTotalNumberOfResults();
|
||||||
} else {
|
} else {
|
||||||
// Embedded: Update through state service
|
// Embedded: Update through state service
|
||||||
this.filterStateService.updateCriteria(this.currentListingType, updates);
|
this.filterStateService.updateCriteria(this.currentListingType, updates);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger search after update
|
// Trigger search after update
|
||||||
this.debouncedSearch();
|
this.debouncedSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
private triggerSearch(): void {
|
private triggerSearch(): void {
|
||||||
if (this.isModal) {
|
if (this.isModal) {
|
||||||
// In modal: Only update count
|
// In modal: Only update count
|
||||||
this.setTotalNumberOfResults();
|
this.setTotalNumberOfResults();
|
||||||
} else {
|
} else {
|
||||||
// Embedded: Full search
|
// Embedded: Full search
|
||||||
this.searchService.search(this.currentListingType);
|
this.searchService.search(this.currentListingType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateSelectedPropertyType(): void {
|
private updateSelectedPropertyType(): void {
|
||||||
if (this.currentListingType === 'businessListings') {
|
if (this.currentListingType === 'businessListings') {
|
||||||
const businessCriteria = this.criteria as BusinessListingCriteria;
|
const businessCriteria = this.criteria as BusinessListingCriteria;
|
||||||
if (businessCriteria.realEstateChecked) {
|
if (businessCriteria.realEstateChecked) {
|
||||||
this.selectedPropertyType = 'realEstateChecked';
|
this.selectedPropertyType = 'realEstateChecked';
|
||||||
} else if (businessCriteria.leasedLocation) {
|
} else if (businessCriteria.leasedLocation) {
|
||||||
this.selectedPropertyType = 'leasedLocation';
|
this.selectedPropertyType = 'leasedLocation';
|
||||||
} else if (businessCriteria.franchiseResale) {
|
} else if (businessCriteria.franchiseResale) {
|
||||||
this.selectedPropertyType = 'franchiseResale';
|
this.selectedPropertyType = 'franchiseResale';
|
||||||
} else {
|
} else {
|
||||||
this.selectedPropertyType = null;
|
this.selectedPropertyType = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private setTotalNumberOfResults(): void {
|
private setTotalNumberOfResults(): void {
|
||||||
if (!this.criteria) return;
|
if (!this.criteria) return;
|
||||||
|
|
||||||
switch (this.currentListingType) {
|
switch (this.currentListingType) {
|
||||||
case 'businessListings':
|
case 'businessListings':
|
||||||
this.numberOfResults$ = this.listingService.getNumberOfListings('business', this.criteria);
|
this.numberOfResults$ = this.listingService.getNumberOfListings('business', this.criteria);
|
||||||
break;
|
break;
|
||||||
case 'commercialPropertyListings':
|
case 'commercialPropertyListings':
|
||||||
this.numberOfResults$ = this.listingService.getNumberOfListings('commercialProperty', this.criteria);
|
this.numberOfResults$ = this.listingService.getNumberOfListings('commercialProperty', this.criteria);
|
||||||
break;
|
break;
|
||||||
case 'brokerListings':
|
case 'brokerListings':
|
||||||
this.numberOfResults$ = this.userService.getNumberOfBroker();
|
this.numberOfResults$ = this.userService.getNumberOfBroker();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDefaultCriteria(): any {
|
private getDefaultCriteria(): any {
|
||||||
switch (this.currentListingType) {
|
switch (this.currentListingType) {
|
||||||
case 'businessListings':
|
case 'businessListings':
|
||||||
return this.filterStateService['createEmptyBusinessListingCriteria']();
|
return this.filterStateService['createEmptyBusinessListingCriteria']();
|
||||||
case 'commercialPropertyListings':
|
case 'commercialPropertyListings':
|
||||||
return this.filterStateService['createEmptyCommercialPropertyListingCriteria']();
|
return this.filterStateService['createEmptyCommercialPropertyListingCriteria']();
|
||||||
case 'brokerListings':
|
case 'brokerListings':
|
||||||
return this.filterStateService['createEmptyUserListingCriteria']();
|
return this.filterStateService['createEmptyUserListingCriteria']();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hasActiveFilters(): boolean {
|
hasActiveFilters(): boolean {
|
||||||
if (!this.criteria) return false;
|
if (!this.criteria) return false;
|
||||||
|
|
||||||
// Check all possible filter properties
|
// Check all possible filter properties
|
||||||
const hasBasicFilters = !!(this.criteria.state || this.criteria.city || this.criteria.types?.length);
|
const hasBasicFilters = !!(this.criteria.state || this.criteria.city || this.criteria.types?.length);
|
||||||
|
|
||||||
// Check business-specific filters
|
// Check business-specific filters
|
||||||
if (this.currentListingType === 'businessListings') {
|
if (this.currentListingType === 'businessListings') {
|
||||||
const bc = this.criteria as BusinessListingCriteria;
|
const bc = this.criteria as BusinessListingCriteria;
|
||||||
return (
|
return (
|
||||||
hasBasicFilters ||
|
hasBasicFilters ||
|
||||||
!!(
|
!!(
|
||||||
bc.minPrice ||
|
bc.minPrice ||
|
||||||
bc.maxPrice ||
|
bc.maxPrice ||
|
||||||
bc.minRevenue ||
|
bc.minRevenue ||
|
||||||
bc.maxRevenue ||
|
bc.maxRevenue ||
|
||||||
bc.minCashFlow ||
|
bc.minCashFlow ||
|
||||||
bc.maxCashFlow ||
|
bc.maxCashFlow ||
|
||||||
bc.minNumberEmployees ||
|
bc.minNumberEmployees ||
|
||||||
bc.maxNumberEmployees ||
|
bc.maxNumberEmployees ||
|
||||||
bc.establishedMin ||
|
bc.establishedMin ||
|
||||||
bc.brokerName ||
|
bc.brokerName ||
|
||||||
bc.title ||
|
bc.title ||
|
||||||
this.selectedPropertyType
|
this.selectedPropertyType
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check commercial property filters
|
// Check commercial property filters
|
||||||
// if (this.currentListingType === 'commercialPropertyListings') {
|
// if (this.currentListingType === 'commercialPropertyListings') {
|
||||||
// const cc = this.criteria as CommercialPropertyListingCriteria;
|
// const cc = this.criteria as CommercialPropertyListingCriteria;
|
||||||
// return hasBasicFilters || !!(cc.minPrice || cc.maxPrice || cc.title);
|
// return hasBasicFilters || !!(cc.minPrice || cc.maxPrice || cc.title);
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// Check user/broker filters
|
// Check user/broker filters
|
||||||
// if (this.currentListingType === 'brokerListings') {
|
// if (this.currentListingType === 'brokerListings') {
|
||||||
// const uc = this.criteria as UserListingCriteria;
|
// const uc = this.criteria as UserListingCriteria;
|
||||||
// return hasBasicFilters || !!(uc.brokerName || uc.companyName || uc.counties?.length);
|
// return hasBasicFilters || !!(uc.brokerName || uc.companyName || uc.counties?.length);
|
||||||
// }
|
// }
|
||||||
|
|
||||||
return hasBasicFilters;
|
return hasBasicFilters;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSelectedPropertyTypeName(): string | null {
|
getSelectedPropertyTypeName(): string | null {
|
||||||
return this.selectedPropertyType ? this.propertyTypeOptions.find(opt => opt.value === this.selectedPropertyType)?.name || null : null;
|
return this.selectedPropertyType ? this.propertyTypeOptions.find(opt => opt.value === this.selectedPropertyType)?.name || null : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
isTypeOfBusinessClicked(v: KeyValueStyle): boolean {
|
isTypeOfBusinessClicked(v: KeyValueStyle): boolean {
|
||||||
return !!this.criteria.types?.find(t => t === v.value);
|
return !!this.criteria.types?.find(t => t === v.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
isTypeOfProfessionalClicked(v: KeyValue): boolean {
|
isTypeOfProfessionalClicked(v: KeyValue): boolean {
|
||||||
return !!this.criteria.types?.find(t => t === v.value);
|
return !!this.criteria.types?.find(t => t === v.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
trackByFn(item: GeoResult): any {
|
trackByFn(item: GeoResult): any {
|
||||||
return item.id;
|
return item.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.destroy$.next();
|
this.destroy$.next();
|
||||||
this.destroy$.complete();
|
this.destroy$.complete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-test-ssr',
|
selector: 'app-test-ssr',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
template: `
|
template: `
|
||||||
<div>
|
<div>
|
||||||
<h1>SSR Test Component</h1>
|
<h1>SSR Test Component</h1>
|
||||||
<p>If you see this, SSR is working!</p>
|
<p>If you see this, SSR is working!</p>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
styles: [`
|
styles: [`
|
||||||
div {
|
div {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background: #f0f0f0;
|
background: #f0f0f0;
|
||||||
}
|
}
|
||||||
h1 { color: green; }
|
h1 { color: green; }
|
||||||
`]
|
`]
|
||||||
})
|
})
|
||||||
export class TestSsrComponent {
|
export class TestSsrComponent {
|
||||||
constructor() {
|
constructor() {
|
||||||
console.log('[SSR] TestSsrComponent constructor called');
|
console.log('[SSR] TestSsrComponent constructor called');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,46 @@
|
||||||
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
||||||
import { Component, Input, SimpleChanges, PLATFORM_ID, inject } from '@angular/core';
|
import { Component, Input, SimpleChanges, PLATFORM_ID, inject } from '@angular/core';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-tooltip',
|
selector: 'app-tooltip',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
templateUrl: './tooltip.component.html',
|
templateUrl: './tooltip.component.html',
|
||||||
})
|
})
|
||||||
export class TooltipComponent {
|
export class TooltipComponent {
|
||||||
@Input() id: string;
|
@Input() id: string;
|
||||||
@Input() text: string;
|
@Input() text: string;
|
||||||
@Input() isVisible: boolean = false;
|
@Input() isVisible: boolean = false;
|
||||||
|
|
||||||
private platformId = inject(PLATFORM_ID);
|
private platformId = inject(PLATFORM_ID);
|
||||||
private isBrowser = isPlatformBrowser(this.platformId);
|
private isBrowser = isPlatformBrowser(this.platformId);
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.initializeTooltip();
|
this.initializeTooltip();
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges) {
|
ngOnChanges(changes: SimpleChanges) {
|
||||||
if (changes['isVisible']) {
|
if (changes['isVisible']) {
|
||||||
this.updateTooltipVisibility();
|
this.updateTooltipVisibility();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private initializeTooltip() {
|
private initializeTooltip() {
|
||||||
// Flowbite is now initialized once in AppComponent
|
// Flowbite is now initialized once in AppComponent
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateTooltipVisibility() {
|
private updateTooltipVisibility() {
|
||||||
if (!this.isBrowser) return;
|
if (!this.isBrowser) return;
|
||||||
|
|
||||||
const tooltipElement = document.getElementById(this.id);
|
const tooltipElement = document.getElementById(this.id);
|
||||||
if (tooltipElement) {
|
if (tooltipElement) {
|
||||||
if (this.isVisible) {
|
if (this.isVisible) {
|
||||||
tooltipElement.classList.remove('invisible', 'opacity-0');
|
tooltipElement.classList.remove('invisible', 'opacity-0');
|
||||||
tooltipElement.classList.add('visible', 'opacity-100');
|
tooltipElement.classList.add('visible', 'opacity-100');
|
||||||
} else {
|
} else {
|
||||||
tooltipElement.classList.remove('visible', 'opacity-100');
|
tooltipElement.classList.remove('visible', 'opacity-100');
|
||||||
tooltipElement.classList.add('invisible', 'opacity-0');
|
tooltipElement.classList.add('invisible', 'opacity-0');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,44 @@
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, forwardRef, Input } from '@angular/core';
|
import { Component, forwardRef, Input } from '@angular/core';
|
||||||
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
|
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||||
import { NgxMaskDirective, NgxMaskPipe, provideNgxMask } from 'ngx-mask';
|
import { NgxMaskDirective, NgxMaskPipe, provideNgxMask } from 'ngx-mask';
|
||||||
import { BaseInputComponent } from '../base-input/base-input.component';
|
import { BaseInputComponent } from '../base-input/base-input.component';
|
||||||
import { TooltipComponent } from '../tooltip/tooltip.component';
|
import { TooltipComponent } from '../tooltip/tooltip.component';
|
||||||
import { ValidationMessagesService } from '../validation-messages.service';
|
import { ValidationMessagesService } from '../validation-messages.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-validated-input',
|
selector: 'app-validated-input',
|
||||||
templateUrl: './validated-input.component.html',
|
templateUrl: './validated-input.component.html',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, TooltipComponent, NgxMaskDirective],
|
imports: [CommonModule, FormsModule, TooltipComponent, NgxMaskDirective],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: NG_VALUE_ACCESSOR,
|
provide: NG_VALUE_ACCESSOR,
|
||||||
useExisting: forwardRef(() => ValidatedInputComponent),
|
useExisting: forwardRef(() => ValidatedInputComponent),
|
||||||
multi: true,
|
multi: true,
|
||||||
},
|
},
|
||||||
provideNgxMask(),
|
provideNgxMask(),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ValidatedInputComponent extends BaseInputComponent {
|
export class ValidatedInputComponent extends BaseInputComponent {
|
||||||
@Input() kind: 'text' | 'number' | 'email' = 'text';
|
@Input() kind: 'text' | 'number' | 'email' = 'text';
|
||||||
@Input() mask: string;
|
@Input() mask: string;
|
||||||
constructor(validationMessagesService: ValidationMessagesService) {
|
constructor(validationMessagesService: ValidationMessagesService) {
|
||||||
super(validationMessagesService);
|
super(validationMessagesService);
|
||||||
}
|
}
|
||||||
|
|
||||||
onInputChange(event: string | number): void {
|
onInputChange(event: string | number): void {
|
||||||
if (this.kind === 'number') {
|
if (this.kind === 'number') {
|
||||||
if (typeof event === 'number') {
|
if (typeof event === 'number') {
|
||||||
this.value = event;
|
this.value = event;
|
||||||
} else {
|
} else {
|
||||||
this.value = parseFloat(event);
|
this.value = parseFloat(event);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const text = event as string;
|
const text = event as string;
|
||||||
this.value = text?.length > 0 ? event : null;
|
this.value = text?.length > 0 ? event : null;
|
||||||
}
|
}
|
||||||
// this.value = event?.length > 0 ? (this.kind === 'number' ? parseFloat(event) : event) : null;
|
// this.value = event?.length > 0 ? (this.kind === 'number' ? parseFloat(event) : event) : null;
|
||||||
this.onChange(this.value);
|
this.onChange(this.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,60 @@
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, forwardRef, Input, OnInit, OnDestroy } from '@angular/core';
|
import { Component, forwardRef, Input, OnInit, OnDestroy } from '@angular/core';
|
||||||
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
|
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||||
import { NgxCurrencyDirective } from 'ngx-currency';
|
import { NgxCurrencyDirective } from 'ngx-currency';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { debounceTime, takeUntil } from 'rxjs/operators';
|
import { debounceTime, takeUntil } from 'rxjs/operators';
|
||||||
import { BaseInputComponent } from '../base-input/base-input.component';
|
import { BaseInputComponent } from '../base-input/base-input.component';
|
||||||
import { TooltipComponent } from '../tooltip/tooltip.component';
|
import { TooltipComponent } from '../tooltip/tooltip.component';
|
||||||
import { ValidationMessagesService } from '../validation-messages.service';
|
import { ValidationMessagesService } from '../validation-messages.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-validated-price',
|
selector: 'app-validated-price',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, TooltipComponent, NgxCurrencyDirective],
|
imports: [CommonModule, FormsModule, TooltipComponent, NgxCurrencyDirective],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: NG_VALUE_ACCESSOR,
|
provide: NG_VALUE_ACCESSOR,
|
||||||
useExisting: forwardRef(() => ValidatedPriceComponent),
|
useExisting: forwardRef(() => ValidatedPriceComponent),
|
||||||
multi: true,
|
multi: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
templateUrl: './validated-price.component.html',
|
templateUrl: './validated-price.component.html',
|
||||||
styles: `:host{width:100%}`,
|
styles: `:host{width:100%}`,
|
||||||
})
|
})
|
||||||
export class ValidatedPriceComponent extends BaseInputComponent implements OnInit, OnDestroy {
|
export class ValidatedPriceComponent extends BaseInputComponent implements OnInit, OnDestroy {
|
||||||
@Input() inputClasses: string;
|
@Input() inputClasses: string;
|
||||||
@Input() placeholder: string = '';
|
@Input() placeholder: string = '';
|
||||||
@Input() debounceTimeMs: number = 400; // Configurable debounce time in milliseconds
|
@Input() debounceTimeMs: number = 400; // Configurable debounce time in milliseconds
|
||||||
|
|
||||||
private inputChange$ = new Subject<any>();
|
private inputChange$ = new Subject<any>();
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
constructor(validationMessagesService: ValidationMessagesService) {
|
constructor(validationMessagesService: ValidationMessagesService) {
|
||||||
super(validationMessagesService);
|
super(validationMessagesService);
|
||||||
}
|
}
|
||||||
|
|
||||||
override ngOnInit(): void {
|
override ngOnInit(): void {
|
||||||
// Setup debounced onChange
|
// Setup debounced onChange
|
||||||
this.inputChange$
|
this.inputChange$
|
||||||
.pipe(
|
.pipe(
|
||||||
debounceTime(this.debounceTimeMs),
|
debounceTime(this.debounceTimeMs),
|
||||||
takeUntil(this.destroy$)
|
takeUntil(this.destroy$)
|
||||||
)
|
)
|
||||||
.subscribe(value => {
|
.subscribe(value => {
|
||||||
this.value = value;
|
this.value = value;
|
||||||
this.onChange(this.value);
|
this.onChange(this.value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
override ngOnDestroy(): void {
|
override ngOnDestroy(): void {
|
||||||
this.destroy$.next();
|
this.destroy$.next();
|
||||||
this.destroy$.complete();
|
this.destroy$.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
onInputChange(event: Event): void {
|
onInputChange(event: Event): void {
|
||||||
const newValue = !event ? null : event;
|
const newValue = !event ? null : event;
|
||||||
// Send signal to Subject instead of calling onChange directly
|
// Send signal to Subject instead of calling onChange directly
|
||||||
this.inputChange$.next(newValue);
|
this.inputChange$.next(newValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,90 +1,90 @@
|
||||||
import { Directive, ElementRef, Input, OnInit, Renderer2 } from '@angular/core';
|
import { Directive, ElementRef, Input, OnInit, Renderer2 } from '@angular/core';
|
||||||
|
|
||||||
@Directive({
|
@Directive({
|
||||||
selector: 'img[appLazyLoad]',
|
selector: 'img[appLazyLoad]',
|
||||||
standalone: true
|
standalone: true
|
||||||
})
|
})
|
||||||
export class LazyLoadImageDirective implements OnInit {
|
export class LazyLoadImageDirective implements OnInit {
|
||||||
@Input() appLazyLoad: string = '';
|
@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';
|
@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;
|
private observer: IntersectionObserver | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private el: ElementRef<HTMLImageElement>,
|
private el: ElementRef<HTMLImageElement>,
|
||||||
private renderer: Renderer2
|
private renderer: Renderer2
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
// Add loading="lazy" attribute for native lazy loading
|
// Add loading="lazy" attribute for native lazy loading
|
||||||
this.renderer.setAttribute(this.el.nativeElement, 'loading', 'lazy');
|
this.renderer.setAttribute(this.el.nativeElement, 'loading', 'lazy');
|
||||||
|
|
||||||
// Set placeholder while image loads
|
// Set placeholder while image loads
|
||||||
if (this.placeholder) {
|
if (this.placeholder) {
|
||||||
this.renderer.setAttribute(this.el.nativeElement, 'src', this.placeholder);
|
this.renderer.setAttribute(this.el.nativeElement, 'src', this.placeholder);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a CSS class for styling during loading
|
// Add a CSS class for styling during loading
|
||||||
this.renderer.addClass(this.el.nativeElement, 'lazy-loading');
|
this.renderer.addClass(this.el.nativeElement, 'lazy-loading');
|
||||||
|
|
||||||
// Use Intersection Observer for enhanced lazy loading
|
// Use Intersection Observer for enhanced lazy loading
|
||||||
if ('IntersectionObserver' in window) {
|
if ('IntersectionObserver' in window) {
|
||||||
this.observer = new IntersectionObserver(
|
this.observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
this.loadImage();
|
this.loadImage();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rootMargin: '50px' // Start loading 50px before image enters viewport
|
rootMargin: '50px' // Start loading 50px before image enters viewport
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
this.observer.observe(this.el.nativeElement);
|
this.observer.observe(this.el.nativeElement);
|
||||||
} else {
|
} else {
|
||||||
// Fallback for browsers without Intersection Observer
|
// Fallback for browsers without Intersection Observer
|
||||||
this.loadImage();
|
this.loadImage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadImage() {
|
private loadImage() {
|
||||||
const img = this.el.nativeElement;
|
const img = this.el.nativeElement;
|
||||||
const src = this.appLazyLoad || img.getAttribute('data-src');
|
const src = this.appLazyLoad || img.getAttribute('data-src');
|
||||||
|
|
||||||
if (src) {
|
if (src) {
|
||||||
// Create a new image to preload
|
// Create a new image to preload
|
||||||
const tempImg = new Image();
|
const tempImg = new Image();
|
||||||
|
|
||||||
tempImg.onload = () => {
|
tempImg.onload = () => {
|
||||||
this.renderer.setAttribute(img, 'src', src);
|
this.renderer.setAttribute(img, 'src', src);
|
||||||
this.renderer.removeClass(img, 'lazy-loading');
|
this.renderer.removeClass(img, 'lazy-loading');
|
||||||
this.renderer.addClass(img, 'lazy-loaded');
|
this.renderer.addClass(img, 'lazy-loaded');
|
||||||
|
|
||||||
// Disconnect observer after loading
|
// Disconnect observer after loading
|
||||||
if (this.observer) {
|
if (this.observer) {
|
||||||
this.observer.disconnect();
|
this.observer.disconnect();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
tempImg.onerror = () => {
|
tempImg.onerror = () => {
|
||||||
console.error('Failed to load image:', src);
|
console.error('Failed to load image:', src);
|
||||||
this.renderer.removeClass(img, 'lazy-loading');
|
this.renderer.removeClass(img, 'lazy-loading');
|
||||||
this.renderer.addClass(img, 'lazy-error');
|
this.renderer.addClass(img, 'lazy-error');
|
||||||
|
|
||||||
if (this.observer) {
|
if (this.observer) {
|
||||||
this.observer.disconnect();
|
this.observer.disconnect();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
tempImg.src = src;
|
tempImg.src = src;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
if (this.observer) {
|
if (this.observer) {
|
||||||
this.observer.disconnect();
|
this.observer.disconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,36 @@
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
|
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
|
||||||
import { Observable, of } from 'rxjs';
|
import { Observable, of } from 'rxjs';
|
||||||
import { catchError, tap } from 'rxjs/operators';
|
import { catchError, tap } from 'rxjs/operators';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class ListingCategoryGuard implements CanActivate {
|
export class ListingCategoryGuard implements CanActivate {
|
||||||
private apiBaseUrl = environment.apiBaseUrl;
|
private apiBaseUrl = environment.apiBaseUrl;
|
||||||
constructor(private http: HttpClient, private router: Router) {}
|
constructor(private http: HttpClient, private router: Router) {}
|
||||||
|
|
||||||
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
|
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
|
||||||
const id = route.paramMap.get('id');
|
const id = route.paramMap.get('id');
|
||||||
const url = `${this.apiBaseUrl}/bizmatch/listings/undefined/${id}`;
|
const url = `${this.apiBaseUrl}/bizmatch/listings/undefined/${id}`;
|
||||||
|
|
||||||
return this.http.get<any>(url).pipe(
|
return this.http.get<any>(url).pipe(
|
||||||
tap(response => {
|
tap(response => {
|
||||||
const category = response.listingsCategory;
|
const category = response.listingsCategory;
|
||||||
const slug = response.slug || id;
|
const slug = response.slug || id;
|
||||||
if (category === 'business') {
|
if (category === 'business') {
|
||||||
this.router.navigate(['business', slug]);
|
this.router.navigate(['business', slug]);
|
||||||
} else if (category === 'commercialProperty') {
|
} else if (category === 'commercialProperty') {
|
||||||
this.router.navigate(['commercial-property', slug]);
|
this.router.navigate(['commercial-property', slug]);
|
||||||
} else {
|
} else {
|
||||||
this.router.navigate(['not-found']);
|
this.router.navigate(['not-found']);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
catchError(() => {
|
catchError(() => {
|
||||||
return of(this.router.createUrlTree(['/not-found']));
|
return of(this.router.createUrlTree(['/not-found']));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,150 +1,150 @@
|
||||||
import { Component, inject, PLATFORM_ID } from '@angular/core';
|
import { Component, inject, PLATFORM_ID } from '@angular/core';
|
||||||
import { isPlatformBrowser } from '@angular/common';
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
import { Control, DomEvent, DomUtil, icon, Icon, latLng, Layer, Map, MapOptions, Marker, tileLayer } from 'leaflet';
|
import { Control, DomEvent, DomUtil, icon, Icon, latLng, Layer, Map, MapOptions, Marker, tileLayer } from 'leaflet';
|
||||||
import { BusinessListing, CommercialPropertyListing } from '../../../../../bizmatch-server/src/models/db.model';
|
import { BusinessListing, CommercialPropertyListing } from '../../../../../bizmatch-server/src/models/db.model';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-base-details',
|
selector: 'app-base-details',
|
||||||
template: ``,
|
template: ``,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [],
|
imports: [],
|
||||||
})
|
})
|
||||||
export abstract class BaseDetailsComponent {
|
export abstract class BaseDetailsComponent {
|
||||||
// Leaflet-Map-Einstellungen
|
// Leaflet-Map-Einstellungen
|
||||||
mapOptions: MapOptions;
|
mapOptions: MapOptions;
|
||||||
mapLayers: Layer[] = [];
|
mapLayers: Layer[] = [];
|
||||||
mapCenter: any;
|
mapCenter: any;
|
||||||
mapZoom: number = 13;
|
mapZoom: number = 13;
|
||||||
protected listing: BusinessListing | CommercialPropertyListing;
|
protected listing: BusinessListing | CommercialPropertyListing;
|
||||||
protected isBrowser: boolean;
|
protected isBrowser: boolean;
|
||||||
private platformId = inject(PLATFORM_ID);
|
private platformId = inject(PLATFORM_ID);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.isBrowser = isPlatformBrowser(this.platformId);
|
this.isBrowser = isPlatformBrowser(this.platformId);
|
||||||
// Only initialize mapOptions in browser context
|
// Only initialize mapOptions in browser context
|
||||||
if (this.isBrowser) {
|
if (this.isBrowser) {
|
||||||
this.mapOptions = {
|
this.mapOptions = {
|
||||||
layers: [
|
layers: [
|
||||||
tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
attribution: '© OpenStreetMap contributors',
|
attribution: '© OpenStreetMap contributors',
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
zoom: this.mapZoom,
|
zoom: this.mapZoom,
|
||||||
center: latLng(0, 0),
|
center: latLng(0, 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected configureMap() {
|
protected configureMap() {
|
||||||
if (!this.isBrowser) {
|
if (!this.isBrowser) {
|
||||||
return; // Skip on server
|
return; // Skip on server
|
||||||
}
|
}
|
||||||
|
|
||||||
const latitude = this.listing.location.latitude;
|
const latitude = this.listing.location.latitude;
|
||||||
const longitude = this.listing.location.longitude;
|
const longitude = this.listing.location.longitude;
|
||||||
|
|
||||||
if (latitude !== null && latitude !== undefined &&
|
if (latitude !== null && latitude !== undefined &&
|
||||||
longitude !== null && longitude !== undefined) {
|
longitude !== null && longitude !== undefined) {
|
||||||
this.mapCenter = latLng(latitude, longitude);
|
this.mapCenter = latLng(latitude, longitude);
|
||||||
|
|
||||||
const addressParts = [];
|
const addressParts = [];
|
||||||
if (this.listing.location.housenumber) addressParts.push(this.listing.location.housenumber);
|
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.street) addressParts.push(this.listing.location.street);
|
||||||
if (this.listing.location.name) addressParts.push(this.listing.location.name);
|
if (this.listing.location.name) addressParts.push(this.listing.location.name);
|
||||||
else if (this.listing.location.county) addressParts.push(this.listing.location.county);
|
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.state) addressParts.push(this.listing.location.state);
|
||||||
if (this.listing.location.zipCode) addressParts.push(this.listing.location.zipCode);
|
if (this.listing.location.zipCode) addressParts.push(this.listing.location.zipCode);
|
||||||
|
|
||||||
const fullAddress = addressParts.join(', ');
|
const fullAddress = addressParts.join(', ');
|
||||||
|
|
||||||
const marker = new Marker([latitude, longitude], {
|
const marker = new Marker([latitude, longitude], {
|
||||||
icon: icon({
|
icon: icon({
|
||||||
...Icon.Default.prototype.options,
|
...Icon.Default.prototype.options,
|
||||||
iconUrl: 'assets/leaflet/marker-icon.png',
|
iconUrl: 'assets/leaflet/marker-icon.png',
|
||||||
iconRetinaUrl: 'assets/leaflet/marker-icon-2x.png',
|
iconRetinaUrl: 'assets/leaflet/marker-icon-2x.png',
|
||||||
shadowUrl: 'assets/leaflet/marker-shadow.png',
|
shadowUrl: 'assets/leaflet/marker-shadow.png',
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (fullAddress) {
|
if (fullAddress) {
|
||||||
marker.bindPopup(`
|
marker.bindPopup(`
|
||||||
<div style="padding: 8px;">
|
<div style="padding: 8px;">
|
||||||
<strong>Location:</strong><br/>
|
<strong>Location:</strong><br/>
|
||||||
${fullAddress}
|
${fullAddress}
|
||||||
</div>
|
</div>
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.mapLayers = [
|
this.mapLayers = [
|
||||||
tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
attribution: '© OpenStreetMap contributors',
|
attribution: '© OpenStreetMap contributors',
|
||||||
}),
|
}),
|
||||||
marker
|
marker
|
||||||
];
|
];
|
||||||
this.mapOptions = {
|
this.mapOptions = {
|
||||||
...this.mapOptions,
|
...this.mapOptions,
|
||||||
center: this.mapCenter,
|
center: this.mapCenter,
|
||||||
zoom: this.mapZoom,
|
zoom: this.mapZoom,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMapReady(map: Map) {
|
onMapReady(map: Map) {
|
||||||
if (!this.isBrowser) {
|
if (!this.isBrowser) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const addressParts = [];
|
const addressParts = [];
|
||||||
if (this.listing.location.housenumber) addressParts.push(this.listing.location.housenumber);
|
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.street) addressParts.push(this.listing.location.street);
|
||||||
if (this.listing.location.name) addressParts.push(this.listing.location.name);
|
if (this.listing.location.name) addressParts.push(this.listing.location.name);
|
||||||
else if (this.listing.location.county) addressParts.push(this.listing.location.county);
|
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.state) addressParts.push(this.listing.location.state);
|
||||||
if (this.listing.location.zipCode) addressParts.push(this.listing.location.zipCode);
|
if (this.listing.location.zipCode) addressParts.push(this.listing.location.zipCode);
|
||||||
|
|
||||||
if (addressParts.length > 0) {
|
if (addressParts.length > 0) {
|
||||||
const addressControl = new Control({ position: 'topright' });
|
const addressControl = new Control({ position: 'topright' });
|
||||||
|
|
||||||
addressControl.onAdd = () => {
|
addressControl.onAdd = () => {
|
||||||
const container = DomUtil.create('div', 'address-control bg-white p-2 rounded shadow');
|
const container = DomUtil.create('div', 'address-control bg-white p-2 rounded shadow');
|
||||||
const address = addressParts.join(', ');
|
const address = addressParts.join(', ');
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div style="max-width: 250px;">
|
<div style="max-width: 250px;">
|
||||||
${address}<br/>
|
${address}<br/>
|
||||||
<a href="#" id="view-full-map" style="color: #2563eb; text-decoration: underline;">View larger map</a>
|
<a href="#" id="view-full-map" style="color: #2563eb; text-decoration: underline;">View larger map</a>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
DomEvent.disableClickPropagation(container);
|
DomEvent.disableClickPropagation(container);
|
||||||
|
|
||||||
const link = container.querySelector('#view-full-map') as HTMLElement;
|
const link = container.querySelector('#view-full-map') as HTMLElement;
|
||||||
if (link) {
|
if (link) {
|
||||||
DomEvent.on(link, 'click', (e: Event) => {
|
DomEvent.on(link, 'click', (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.openFullMap();
|
this.openFullMap();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return container;
|
return container;
|
||||||
};
|
};
|
||||||
|
|
||||||
addressControl.addTo(map);
|
addressControl.addTo(map);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
openFullMap() {
|
openFullMap() {
|
||||||
if (!this.isBrowser) {
|
if (!this.isBrowser) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const latitude = this.listing.location.latitude;
|
const latitude = this.listing.location.latitude;
|
||||||
const longitude = this.listing.location.longitude;
|
const longitude = this.listing.location.longitude;
|
||||||
const url = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=15/${latitude}/${longitude}`;
|
const url = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=15/${latitude}/${longitude}`;
|
||||||
|
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,223 +1,223 @@
|
||||||
<div class="container mx-auto p-4">
|
<div class="container mx-auto p-4">
|
||||||
<!-- Breadcrumbs for SEO and Navigation -->
|
<!-- Breadcrumbs for SEO and Navigation -->
|
||||||
@if(breadcrumbs.length > 0) {
|
@if(breadcrumbs.length > 0) {
|
||||||
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
|
<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">
|
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg overflow-hidden relative">
|
||||||
<button (click)="historyService.goBack()"
|
<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">
|
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>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
@if(listing){
|
@if(listing){
|
||||||
<div class="p-6 flex flex-col lg:flex-row">
|
<div class="p-6 flex flex-col lg:flex-row">
|
||||||
<!-- Left column -->
|
<!-- Left column -->
|
||||||
<div class="w-full lg:w-1/2 pr-0 lg:pr-6">
|
<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>
|
<h1 class="text-2xl font-bold mb-4 break-words">{{ listing.title }}</h1>
|
||||||
<p class="mb-4 break-words" [innerHTML]="description"></p>
|
<p class="mb-4 break-words" [innerHTML]="description"></p>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div *ngFor="let detail of listingDetails; let i = index" class="flex flex-col sm:flex-row"
|
<div *ngFor="let detail of listingDetails; let i = index" class="flex flex-col sm:flex-row"
|
||||||
[ngClass]="{ 'bg-neutral-100': i % 2 === 0 }">
|
[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-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 class="w-full sm:w-2/3 p-2 break-words" *ngIf="!detail.isHtml && !detail.isListingBy">{{ detail.value
|
||||||
}}</div>
|
}}</div>
|
||||||
|
|
||||||
<div class="w-full sm:w-2/3 p-2 flex space-x-2 break-words" [innerHTML]="detail.value"
|
<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>
|
*ngIf="detail.isHtml && !detail.isListingBy"></div>
|
||||||
|
|
||||||
<div class="w-full sm:w-2/3 p-2 flex space-x-2" *ngIf="detail.isListingBy && listingUser">
|
<div class="w-full sm:w-2/3 p-2 flex space-x-2" *ngIf="detail.isListingBy && listingUser">
|
||||||
<a routerLink="/details-user/{{ listingUser.id }}"
|
<a routerLink="/details-user/{{ listingUser.id }}"
|
||||||
class="text-primary-600 dark:text-primary-500 hover:underline">{{ listingUser.firstname }} {{
|
class="text-primary-600 dark:text-primary-500 hover:underline">{{ listingUser.firstname }} {{
|
||||||
listingUser.lastname }}</a>
|
listingUser.lastname }}</a>
|
||||||
<div class="relative w-[100px] h-[30px] mr-5 lg:mb-0" *ngIf="listing.imageName">
|
<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
|
<img ngSrc="{{ env.imageBaseUrl }}/pictures/logo/{{ listing.imageName }}.avif?_ts={{ ts }}" fill
|
||||||
class="object-contain"
|
class="object-contain"
|
||||||
alt="Business logo for {{ listingUser.firstname }} {{ listingUser.lastname }}" />
|
alt="Business logo for {{ listingUser.firstname }} {{ listingUser.lastname }}" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="py-4 print:hidden">
|
<div class="py-4 print:hidden">
|
||||||
@if(listing && listingUser && (listingUser?.email===user?.email || (authService.isAdmin() | async))){
|
@if(listing && listingUser && (listingUser?.email===user?.email || (authService.isAdmin() | async))){
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<button class="share share-edit text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
<button class="share share-edit text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||||
[routerLink]="['/editBusinessListing', listing.id]">
|
[routerLink]="['/editBusinessListing', listing.id]">
|
||||||
<i class="fa-regular fa-pen-to-square"></i>
|
<i class="fa-regular fa-pen-to-square"></i>
|
||||||
<span class="ml-2">Edit</span>
|
<span class="ml-2">Edit</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
} @if(user){
|
} @if(user){
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<button class="share share-save text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
<button class="share share-save text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||||
(click)="toggleFavorite()">
|
(click)="toggleFavorite()">
|
||||||
<i class="fa-regular fa-heart"></i>
|
<i class="fa-regular fa-heart"></i>
|
||||||
@if(listing.favoritesForUser.includes(user.email)){
|
@if(listing.favoritesForUser.includes(user.email)){
|
||||||
<span class="ml-2">Saved ...</span>
|
<span class="ml-2">Saved ...</span>
|
||||||
}@else {
|
}@else {
|
||||||
<span class="ml-2">Save</span>
|
<span class="ml-2">Save</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<share-button button="print" showText="true" (click)="createEvent('print')"></share-button>
|
<share-button button="print" showText="true" (click)="createEvent('print')"></share-button>
|
||||||
<!-- <share-button button="email" showText="true"></share-button> -->
|
<!-- <share-button button="email" showText="true"></share-button> -->
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<button class="share share-email text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
<button class="share share-email text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||||
(click)="showShareByEMail()">
|
(click)="showShareByEMail()">
|
||||||
<i class="fa-solid fa-envelope"></i>
|
<i class="fa-solid fa-envelope"></i>
|
||||||
<span class="ml-2">Email</span>
|
<span class="ml-2">Email</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="share share-facebook text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
class="share share-facebook text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||||
(click)="shareToFacebook()">
|
(click)="shareToFacebook()">
|
||||||
<i class="fab fa-facebook"></i>
|
<i class="fab fa-facebook"></i>
|
||||||
<span class="ml-2">Facebook</span>
|
<span class="ml-2">Facebook</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="share share-twitter text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
class="share share-twitter text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||||
(click)="shareToTwitter()">
|
(click)="shareToTwitter()">
|
||||||
<i class="fab fa-x-twitter"></i>
|
<i class="fab fa-x-twitter"></i>
|
||||||
<span class="ml-2">X</span>
|
<span class="ml-2">X</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="share share-linkedin text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
class="share share-linkedin text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||||
(click)="shareToLinkedIn()">
|
(click)="shareToLinkedIn()">
|
||||||
<i class="fab fa-linkedin"></i>
|
<i class="fab fa-linkedin"></i>
|
||||||
<span class="ml-2">LinkedIn</span>
|
<span class="ml-2">LinkedIn</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Karte hinzufügen, wenn Straße vorhanden ist -->
|
<!-- Karte hinzufügen, wenn Straße vorhanden ist -->
|
||||||
<div *ngIf="listing.location.latitude && listing.location.longitude" class="mt-6">
|
<div *ngIf="listing.location.latitude && listing.location.longitude" class="mt-6">
|
||||||
<h2 class="text-xl font-semibold mb-2">Location Map</h2>
|
<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: 300px" leaflet [leafletOptions]="mapOptions" [leafletLayers]="mapLayers" [leafletCenter]="mapCenter" [leafletZoom]="mapZoom"></div> -->
|
||||||
<div style="height: 400px" leaflet [leafletOptions]="mapOptions" [leafletLayers]="mapLayers"
|
<div style="height: 400px" leaflet [leafletOptions]="mapOptions" [leafletLayers]="mapLayers"
|
||||||
[leafletCenter]="mapCenter" [leafletZoom]="mapZoom" (leafletMapReady)="onMapReady($event)"></div>
|
[leafletCenter]="mapCenter" [leafletZoom]="mapZoom" (leafletMapReady)="onMapReady($event)"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right column -->
|
<!-- Right column -->
|
||||||
<div class="w-full lg:w-1/2 mt-6 lg:mt-0 print:hidden">
|
<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>
|
<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>
|
<p class="text-sm mb-4">Please include your contact info below</p>
|
||||||
<form class="space-y-4">
|
<form class="space-y-4">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-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 Name" name="name" [(ngModel)]="mailinfo.sender.name"></app-validated-input>
|
||||||
<app-validated-input label="Your Email" name="email" [(ngModel)]="mailinfo.sender.email"
|
<app-validated-input label="Your Email" name="email" [(ngModel)]="mailinfo.sender.email"
|
||||||
kind="email"></app-validated-input>
|
kind="email"></app-validated-input>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<app-validated-input label="Phone Number" name="phoneNumber" [(ngModel)]="mailinfo.sender.phoneNumber"
|
<app-validated-input label="Phone Number" name="phoneNumber" [(ngModel)]="mailinfo.sender.phoneNumber"
|
||||||
mask="(000) 000-0000"></app-validated-input>
|
mask="(000) 000-0000"></app-validated-input>
|
||||||
<!-- <app-validated-input label="Country/State" name="state" [(ngModel)]="mailinfo.sender.state"></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"
|
<app-validated-ng-select label="State" name="state" [(ngModel)]="mailinfo.sender.state"
|
||||||
[items]="selectOptions?.states"></app-validated-ng-select>
|
[items]="selectOptions?.states"></app-validated-ng-select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<app-validated-textarea label="Questions/Comments" name="comments"
|
<app-validated-textarea label="Questions/Comments" name="comments"
|
||||||
[(ngModel)]="mailinfo.sender.comments"></app-validated-textarea>
|
[(ngModel)]="mailinfo.sender.comments"></app-validated-textarea>
|
||||||
</div>
|
</div>
|
||||||
<button (click)="mail()"
|
<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>
|
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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- FAQ Section for AEO (Answer Engine Optimization) -->
|
<!-- FAQ Section for AEO (Answer Engine Optimization) -->
|
||||||
@if(businessFAQs && businessFAQs.length > 0) {
|
@if(businessFAQs && businessFAQs.length > 0) {
|
||||||
<div class="container mx-auto p-4 mt-8">
|
<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">
|
<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>
|
<h2 class="text-2xl font-bold mb-6 text-gray-900">Frequently Asked Questions</h2>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@for (faq of businessFAQs; track $index) {
|
@for (faq of businessFAQs; track $index) {
|
||||||
<details
|
<details
|
||||||
class="group border border-gray-200 rounded-lg overflow-hidden hover:border-primary-300 transition-colors">
|
class="group border border-gray-200 rounded-lg overflow-hidden hover:border-primary-300 transition-colors">
|
||||||
<summary
|
<summary
|
||||||
class="flex items-center justify-between cursor-pointer p-4 bg-gray-50 hover:bg-gray-100 transition-colors">
|
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>
|
<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"
|
<svg class="w-5 h-5 text-gray-600 group-open:rotate-180 transition-transform" fill="none"
|
||||||
stroke="currentColor" viewBox="0 0 24 24">
|
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>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="p-4 bg-white border-t border-gray-200">
|
<div class="p-4 bg-white border-t border-gray-200">
|
||||||
<p class="text-gray-700 leading-relaxed">{{ faq.answer }}</p>
|
<p class="text-gray-700 leading-relaxed">{{ faq.answer }}</p>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-6 p-4 bg-primary-50 border-l-4 border-primary-500 rounded">
|
<div class="mt-6 p-4 bg-primary-50 border-l-4 border-primary-500 rounded">
|
||||||
<p class="text-sm text-gray-700">
|
<p class="text-sm text-gray-700">
|
||||||
<strong class="text-primary-700">Have more questions?</strong> Contact the seller directly using the form
|
<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.
|
above or reach out to our support team for assistance.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Related Listings Section for SEO Internal Linking -->
|
<!-- Related Listings Section for SEO Internal Linking -->
|
||||||
@if(relatedListings && relatedListings.length > 0) {
|
@if(relatedListings && relatedListings.length > 0) {
|
||||||
<div class="container mx-auto p-4 mt-8">
|
<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">
|
<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>
|
<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">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
@for (related of relatedListings; track related.id) {
|
@for (related of relatedListings; track related.id) {
|
||||||
<a [routerLink]="['/business', related.slug || related.id]" class="block group">
|
<a [routerLink]="['/business', related.slug || related.id]" class="block group">
|
||||||
<div
|
<div
|
||||||
class="bg-white rounded-lg border border-gray-200 overflow-hidden hover:shadow-xl transition-all duration-300 hover:scale-[1.02]">
|
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="p-4">
|
||||||
<div class="flex items-center mb-3">
|
<div class="flex items-center mb-3">
|
||||||
<i [class]="selectOptions.getIconAndTextColorType(related.type)" class="mr-2 text-lg"></i>
|
<i [class]="selectOptions.getIconAndTextColorType(related.type)" class="mr-2 text-lg"></i>
|
||||||
<span [class]="selectOptions.getTextColorType(related.type)" class="font-semibold text-sm">{{
|
<span [class]="selectOptions.getTextColorType(related.type)" class="font-semibold text-sm">{{
|
||||||
selectOptions.getBusiness(related.type) }}</span>
|
selectOptions.getBusiness(related.type) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<h3
|
<h3
|
||||||
class="text-lg font-bold mb-2 text-gray-900 group-hover:text-primary-600 transition-colors line-clamp-2">
|
class="text-lg font-bold mb-2 text-gray-900 group-hover:text-primary-600 transition-colors line-clamp-2">
|
||||||
{{ related.title }}</h3>
|
{{ related.title }}</h3>
|
||||||
<div class="space-y-1 text-sm text-gray-600">
|
<div class="space-y-1 text-sm text-gray-600">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="font-medium">Price:</span>
|
<span class="font-medium">Price:</span>
|
||||||
<span class="font-bold text-primary-600">${{ related.price?.toLocaleString() || 'Contact' }}</span>
|
<span class="font-bold text-primary-600">${{ related.price?.toLocaleString() || 'Contact' }}</span>
|
||||||
</div>
|
</div>
|
||||||
@if(related.salesRevenue) {
|
@if(related.salesRevenue) {
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="font-medium">Revenue:</span>
|
<span class="font-medium">Revenue:</span>
|
||||||
<span>${{ related.salesRevenue?.toLocaleString() }}</span>
|
<span>${{ related.salesRevenue?.toLocaleString() }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="font-medium">Location:</span>
|
<span class="font-medium">Location:</span>
|
||||||
<span>{{ related.location.name || related.location.county }}, {{
|
<span>{{ related.location.name || related.location.county }}, {{
|
||||||
selectOptions.getState(related.location.state) }}</span>
|
selectOptions.getState(related.location.state) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<span
|
<span
|
||||||
class="inline-block bg-primary-100 text-primary-800 text-xs font-medium px-2.5 py-0.5 rounded">View
|
class="inline-block bg-primary-100 text-primary-800 text-xs font-medium px-2.5 py-0.5 rounded">View
|
||||||
Details →</span>
|
Details →</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,231 +1,231 @@
|
||||||
<div class="container mx-auto p-4">
|
<div class="container mx-auto p-4">
|
||||||
<!-- Breadcrumbs for SEO and Navigation -->
|
<!-- Breadcrumbs for SEO and Navigation -->
|
||||||
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
|
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
|
||||||
|
|
||||||
<div class="bg-white drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg rounded-lg overflow-hidden">
|
<div class="bg-white drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg rounded-lg overflow-hidden">
|
||||||
@if(listing){
|
@if(listing){
|
||||||
<div class="p-6 relative">
|
<div class="p-6 relative">
|
||||||
<h1 class="text-3xl font-bold mb-4 break-words">{{ listing?.title }}</h1>
|
<h1 class="text-3xl font-bold mb-4 break-words">{{ listing?.title }}</h1>
|
||||||
<button (click)="historyService.goBack()"
|
<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">
|
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>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="flex flex-col lg:flex-row">
|
<div class="flex flex-col lg:flex-row">
|
||||||
<div class="w-full lg:w-1/2 pr-0 lg:pr-4">
|
<div class="w-full lg:w-1/2 pr-0 lg:pr-4">
|
||||||
<p class="mb-4 break-words" [innerHTML]="description"></p>
|
<p class="mb-4 break-words" [innerHTML]="description"></p>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div *ngFor="let detail of propertyDetails; let i = index" class="flex flex-col sm:flex-row"
|
<div *ngFor="let detail of propertyDetails; let i = index" class="flex flex-col sm:flex-row"
|
||||||
[ngClass]="{ 'bg-neutral-100': i % 2 === 0 }">
|
[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-1/3 font-semibold p-2">{{ detail.label }}</div>
|
||||||
|
|
||||||
<!-- Standard Text -->
|
<!-- Standard Text -->
|
||||||
<div class="w-full sm:w-2/3 p-2 break-words" *ngIf="!detail.isHtml && !detail.isListingBy">{{ detail.value
|
<div class="w-full sm:w-2/3 p-2 break-words" *ngIf="!detail.isHtml && !detail.isListingBy">{{ detail.value
|
||||||
}}</div>
|
}}</div>
|
||||||
|
|
||||||
<!-- HTML Content (nicht für RouterLink) -->
|
<!-- 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"
|
<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>
|
*ngIf="detail.isHtml && !detail.isListingBy"></div>
|
||||||
|
|
||||||
<!-- Speziell für Listing By mit RouterLink -->
|
<!-- 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">
|
<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]"
|
<a [routerLink]="['/details-user', detail.user.id]"
|
||||||
class="text-primary-600 dark:text-primary-500 hover:underline"> {{ detail.user.firstname }} {{
|
class="text-primary-600 dark:text-primary-500 hover:underline"> {{ detail.user.firstname }} {{
|
||||||
detail.user.lastname }} </a>
|
detail.user.lastname }} </a>
|
||||||
<div class="relative w-[100px] h-[30px] mr-5 lg:mb-0" *ngIf="detail.user.hasCompanyLogo">
|
<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"
|
<img [ngSrc]="detail.imageBaseUrl + '/pictures/logo/' + detail.imagePath + '.avif?_ts=' + detail.ts"
|
||||||
fill class="object-contain"
|
fill class="object-contain"
|
||||||
alt="Company logo for {{ detail.user.firstname }} {{ detail.user.lastname }}" />
|
alt="Company logo for {{ detail.user.firstname }} {{ detail.user.lastname }}" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="py-4 print:hidden">
|
<div class="py-4 print:hidden">
|
||||||
@if(listing && listingUser && (listingUser?.email===user?.email || (authService.isAdmin() | async))){
|
@if(listing && listingUser && (listingUser?.email===user?.email || (authService.isAdmin() | async))){
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<button class="share share-edit text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
<button class="share share-edit text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||||
[routerLink]="['/editCommercialPropertyListing', listing.id]">
|
[routerLink]="['/editCommercialPropertyListing', listing.id]">
|
||||||
<i class="fa-regular fa-pen-to-square"></i>
|
<i class="fa-regular fa-pen-to-square"></i>
|
||||||
<span class="ml-2">Edit</span>
|
<span class="ml-2">Edit</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
} @if(user){
|
} @if(user){
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<button class="share share-save text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
<button class="share share-save text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||||
(click)="toggleFavorite()">
|
(click)="toggleFavorite()">
|
||||||
<i class="fa-regular fa-heart"></i>
|
<i class="fa-regular fa-heart"></i>
|
||||||
@if(listing.favoritesForUser.includes(user.email)){
|
@if(listing.favoritesForUser.includes(user.email)){
|
||||||
<span class="ml-2">Saved ...</span>
|
<span class="ml-2">Saved ...</span>
|
||||||
}@else {
|
}@else {
|
||||||
<span class="ml-2">Save</span>
|
<span class="ml-2">Save</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<share-button button="print" showText="true" (click)="createEvent('print')"></share-button>
|
<share-button button="print" showText="true" (click)="createEvent('print')"></share-button>
|
||||||
<!-- <share-button button="email" showText="true"></share-button> -->
|
<!-- <share-button button="email" showText="true"></share-button> -->
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<button class="share share-email text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
<button class="share share-email text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||||
(click)="showShareByEMail()">
|
(click)="showShareByEMail()">
|
||||||
<i class="fa-solid fa-envelope"></i>
|
<i class="fa-solid fa-envelope"></i>
|
||||||
<span class="ml-2">Email</span>
|
<span class="ml-2">Email</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="share share-facebook text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
class="share share-facebook text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||||
(click)="shareToFacebook()">
|
(click)="shareToFacebook()">
|
||||||
<i class="fab fa-facebook"></i>
|
<i class="fab fa-facebook"></i>
|
||||||
<span class="ml-2">Facebook</span>
|
<span class="ml-2">Facebook</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="share share-twitter text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
class="share share-twitter text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||||
(click)="shareToTwitter()">
|
(click)="shareToTwitter()">
|
||||||
<i class="fab fa-x-twitter"></i>
|
<i class="fab fa-x-twitter"></i>
|
||||||
<span class="ml-2">X</span>
|
<span class="ml-2">X</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="share share-linkedin text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
class="share share-linkedin text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||||
(click)="shareToLinkedIn()">
|
(click)="shareToLinkedIn()">
|
||||||
<i class="fab fa-linkedin"></i>
|
<i class="fab fa-linkedin"></i>
|
||||||
<span class="ml-2">LinkedIn</span>
|
<span class="ml-2">LinkedIn</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Karte hinzufügen, wenn Straße vorhanden ist -->
|
<!-- Karte hinzufügen, wenn Straße vorhanden ist -->
|
||||||
<div *ngIf="listing.location.latitude && listing.location.longitude" class="mt-6">
|
<div *ngIf="listing.location.latitude && listing.location.longitude" class="mt-6">
|
||||||
<h2 class="text-lg font-semibold mb-2">Location Map</h2>
|
<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: 300px" leaflet [leafletOptions]="mapOptions" [leafletLayers]="mapLayers" [leafletCenter]="mapCenter" [leafletZoom]="mapZoom"></div> -->
|
||||||
<div style="height: 400px" leaflet [leafletOptions]="mapOptions" [leafletLayers]="mapLayers"
|
<div style="height: 400px" leaflet [leafletOptions]="mapOptions" [leafletLayers]="mapLayers"
|
||||||
[leafletCenter]="mapCenter" [leafletZoom]="mapZoom" (leafletMapReady)="onMapReady($event)"></div>
|
[leafletCenter]="mapCenter" [leafletZoom]="mapZoom" (leafletMapReady)="onMapReady($event)"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full lg:w-1/2 mt-6 lg:mt-0">
|
<div class="w-full lg:w-1/2 mt-6 lg:mt-0">
|
||||||
@if(this.images.length>0){
|
@if(this.images.length>0){
|
||||||
<div class="block print:hidden">
|
<div class="block print:hidden">
|
||||||
<gallery [items]="images"></gallery>
|
<gallery [items]="images"></gallery>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="print:hidden" [ngClass]="{ 'mt-6': this.images.length > 0 }">
|
<div class="print:hidden" [ngClass]="{ 'mt-6': this.images.length > 0 }">
|
||||||
@if(this.images.length>0){
|
@if(this.images.length>0){
|
||||||
<h2 class="text-xl font-semibold">Contact the Author of this Listing</h2>
|
<h2 class="text-xl font-semibold">Contact the Author of this Listing</h2>
|
||||||
}@else {
|
}@else {
|
||||||
<div class="text-2xl font-bold mb-4">Contact the Author of this Listing</div>
|
<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>
|
<p class="text-sm text-neutral-600 mb-4">Please include your contact info below</p>
|
||||||
<form class="space-y-4">
|
<form class="space-y-4">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<app-validated-input label="Your Name" name="name"
|
<app-validated-input label="Your Name" name="name"
|
||||||
[(ngModel)]="mailinfo.sender.name"></app-validated-input>
|
[(ngModel)]="mailinfo.sender.name"></app-validated-input>
|
||||||
<app-validated-input label="Your Email" name="email" [(ngModel)]="mailinfo.sender.email"
|
<app-validated-input label="Your Email" name="email" [(ngModel)]="mailinfo.sender.email"
|
||||||
kind="email"></app-validated-input>
|
kind="email"></app-validated-input>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<app-validated-input label="Phone Number" name="phoneNumber" [(ngModel)]="mailinfo.sender.phoneNumber"
|
<app-validated-input label="Phone Number" name="phoneNumber" [(ngModel)]="mailinfo.sender.phoneNumber"
|
||||||
mask="(000) 000-0000"></app-validated-input>
|
mask="(000) 000-0000"></app-validated-input>
|
||||||
<!-- <app-validated-input label="Country/State" name="state" [(ngModel)]="mailinfo.sender.state"></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"
|
<app-validated-ng-select label="State" name="state" [(ngModel)]="mailinfo.sender.state"
|
||||||
[items]="selectOptions?.states"></app-validated-ng-select>
|
[items]="selectOptions?.states"></app-validated-ng-select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<app-validated-textarea label="Questions/Comments" name="comments"
|
<app-validated-textarea label="Questions/Comments" name="comments"
|
||||||
[(ngModel)]="mailinfo.sender.comments"></app-validated-textarea>
|
[(ngModel)]="mailinfo.sender.comments"></app-validated-textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<button (click)="mail()"
|
<button (click)="mail()"
|
||||||
class="bg-primary-500 text-white px-4 py-2 rounded hover:bg-primary-600">Submit</button>
|
class="bg-primary-500 text-white px-4 py-2 rounded hover:bg-primary-600">Submit</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- FAQ Section for AEO (Answer Engine Optimization) -->
|
<!-- FAQ Section for AEO (Answer Engine Optimization) -->
|
||||||
@if(propertyFAQs && propertyFAQs.length > 0) {
|
@if(propertyFAQs && propertyFAQs.length > 0) {
|
||||||
<div class="container mx-auto p-4 mt-8">
|
<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">
|
<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>
|
<h2 class="text-2xl font-bold mb-6 text-gray-900">Frequently Asked Questions</h2>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@for (faq of propertyFAQs; track $index) {
|
@for (faq of propertyFAQs; track $index) {
|
||||||
<details
|
<details
|
||||||
class="group border border-gray-200 rounded-lg overflow-hidden hover:border-primary-300 transition-colors">
|
class="group border border-gray-200 rounded-lg overflow-hidden hover:border-primary-300 transition-colors">
|
||||||
<summary
|
<summary
|
||||||
class="flex items-center justify-between cursor-pointer p-4 bg-gray-50 hover:bg-gray-100 transition-colors">
|
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>
|
<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"
|
<svg class="w-5 h-5 text-gray-600 group-open:rotate-180 transition-transform" fill="none"
|
||||||
stroke="currentColor" viewBox="0 0 24 24">
|
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>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="p-4 bg-white border-t border-gray-200">
|
<div class="p-4 bg-white border-t border-gray-200">
|
||||||
<p class="text-gray-700 leading-relaxed">{{ faq.answer }}</p>
|
<p class="text-gray-700 leading-relaxed">{{ faq.answer }}</p>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-6 p-4 bg-primary-50 border-l-4 border-primary-500 rounded">
|
<div class="mt-6 p-4 bg-primary-50 border-l-4 border-primary-500 rounded">
|
||||||
<p class="text-sm text-gray-700">
|
<p class="text-sm text-gray-700">
|
||||||
<strong class="text-primary-700">Have more questions?</strong> Contact the seller directly using the form
|
<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.
|
above or reach out to our support team for assistance.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Related Listings Section for SEO Internal Linking -->
|
<!-- Related Listings Section for SEO Internal Linking -->
|
||||||
@if(relatedListings && relatedListings.length > 0) {
|
@if(relatedListings && relatedListings.length > 0) {
|
||||||
<div class="container mx-auto p-4 mt-8">
|
<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">
|
<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>
|
<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">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
@for (related of relatedListings; track related.id) {
|
@for (related of relatedListings; track related.id) {
|
||||||
<a [routerLink]="['/commercial-property', related.slug || related.id]" class="block group">
|
<a [routerLink]="['/commercial-property', related.slug || related.id]" class="block group">
|
||||||
<div
|
<div
|
||||||
class="bg-white rounded-lg border border-gray-200 overflow-hidden hover:shadow-xl transition-all duration-300 hover:scale-[1.02]">
|
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="p-4">
|
||||||
<div class="flex items-center mb-3">
|
<div class="flex items-center mb-3">
|
||||||
<i [class]="selectOptions.getIconAndTextColorType(related.type)" class="mr-2 text-lg"></i>
|
<i [class]="selectOptions.getIconAndTextColorType(related.type)" class="mr-2 text-lg"></i>
|
||||||
<span [class]="selectOptions.getTextColorType(related.type)" class="font-semibold text-sm">{{
|
<span [class]="selectOptions.getTextColorType(related.type)" class="font-semibold text-sm">{{
|
||||||
selectOptions.getCommercialProperty(related.type) }}</span>
|
selectOptions.getCommercialProperty(related.type) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<h3
|
<h3
|
||||||
class="text-lg font-bold mb-2 text-gray-900 group-hover:text-primary-600 transition-colors line-clamp-2">
|
class="text-lg font-bold mb-2 text-gray-900 group-hover:text-primary-600 transition-colors line-clamp-2">
|
||||||
{{ related.title }}</h3>
|
{{ related.title }}</h3>
|
||||||
<div class="space-y-1 text-sm text-gray-600">
|
<div class="space-y-1 text-sm text-gray-600">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="font-medium">Price:</span>
|
<span class="font-medium">Price:</span>
|
||||||
<span class="font-bold text-primary-600">${{ related.price?.toLocaleString() || 'Contact' }}</span>
|
<span class="font-bold text-primary-600">${{ related.price?.toLocaleString() || 'Contact' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="font-medium">Location:</span>
|
<span class="font-medium">Location:</span>
|
||||||
<span>{{ related.location.name || related.location.county }}, {{
|
<span>{{ related.location.name || related.location.county }}, {{
|
||||||
selectOptions.getState(related.location.state) }}</span>
|
selectOptions.getState(related.location.state) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<span
|
<span
|
||||||
class="inline-block bg-primary-100 text-primary-800 text-xs font-medium px-2.5 py-0.5 rounded">View
|
class="inline-block bg-primary-100 text-primary-800 text-xs font-medium px-2.5 py-0.5 rounded">View
|
||||||
Details →</span>
|
Details →</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,388 +1,388 @@
|
||||||
import { ChangeDetectorRef, Component, NgZone } from '@angular/core';
|
import { ChangeDetectorRef, Component, NgZone } from '@angular/core';
|
||||||
import { NgOptimizedImage } from '@angular/common';
|
import { NgOptimizedImage } from '@angular/common';
|
||||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { LeafletModule } from '@bluehalo/ngx-leaflet';
|
import { LeafletModule } from '@bluehalo/ngx-leaflet';
|
||||||
import { faTimes } from '@fortawesome/free-solid-svg-icons';
|
import { faTimes } from '@fortawesome/free-solid-svg-icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { GalleryModule, ImageItem } from 'ng-gallery';
|
import { GalleryModule, ImageItem } from 'ng-gallery';
|
||||||
import { lastValueFrom } from 'rxjs';
|
import { lastValueFrom } from 'rxjs';
|
||||||
import { CommercialPropertyListing, EventTypeEnum, ShareByEMail, User } from '../../../../../../bizmatch-server/src/models/db.model';
|
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 { CommercialPropertyListingCriteria, ErrorResponse, KeycloakUser, MailInfo } from '../../../../../../bizmatch-server/src/models/main.model';
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
import { EMailService } from '../../../components/email/email.service';
|
import { EMailService } from '../../../components/email/email.service';
|
||||||
import { MessageService } from '../../../components/message/message.service';
|
import { MessageService } from '../../../components/message/message.service';
|
||||||
import { ValidatedInputComponent } from '../../../components/validated-input/validated-input.component';
|
import { ValidatedInputComponent } from '../../../components/validated-input/validated-input.component';
|
||||||
import { ValidatedNgSelectComponent } from '../../../components/validated-ng-select/validated-ng-select.component';
|
import { ValidatedNgSelectComponent } from '../../../components/validated-ng-select/validated-ng-select.component';
|
||||||
import { ValidatedTextareaComponent } from '../../../components/validated-textarea/validated-textarea.component';
|
import { ValidatedTextareaComponent } from '../../../components/validated-textarea/validated-textarea.component';
|
||||||
import { ValidationMessagesService } from '../../../components/validation-messages.service';
|
import { ValidationMessagesService } from '../../../components/validation-messages.service';
|
||||||
import { AuditService } from '../../../services/audit.service';
|
import { AuditService } from '../../../services/audit.service';
|
||||||
import { AuthService } from '../../../services/auth.service';
|
import { AuthService } from '../../../services/auth.service';
|
||||||
import { HistoryService } from '../../../services/history.service';
|
import { HistoryService } from '../../../services/history.service';
|
||||||
import { ImageService } from '../../../services/image.service';
|
import { ImageService } from '../../../services/image.service';
|
||||||
import { ListingsService } from '../../../services/listings.service';
|
import { ListingsService } from '../../../services/listings.service';
|
||||||
import { MailService } from '../../../services/mail.service';
|
import { MailService } from '../../../services/mail.service';
|
||||||
import { SelectOptionsService } from '../../../services/select-options.service';
|
import { SelectOptionsService } from '../../../services/select-options.service';
|
||||||
import { SeoService } from '../../../services/seo.service';
|
import { SeoService } from '../../../services/seo.service';
|
||||||
import { UserService } from '../../../services/user.service';
|
import { UserService } from '../../../services/user.service';
|
||||||
import { SharedModule } from '../../../shared/shared/shared.module';
|
import { SharedModule } from '../../../shared/shared/shared.module';
|
||||||
import { createMailInfo, map2User } from '../../../utils/utils';
|
import { createMailInfo, map2User } from '../../../utils/utils';
|
||||||
import { BaseDetailsComponent } from '../base-details.component';
|
import { BaseDetailsComponent } from '../base-details.component';
|
||||||
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
|
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
|
||||||
import { ShareButton } from 'ngx-sharebuttons/button';
|
import { ShareButton } from 'ngx-sharebuttons/button';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-details-commercial-property-listing',
|
selector: 'app-details-commercial-property-listing',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [SharedModule, ValidatedInputComponent, ValidatedTextareaComponent, ValidatedNgSelectComponent, GalleryModule, LeafletModule, BreadcrumbsComponent, ShareButton, NgOptimizedImage],
|
imports: [SharedModule, ValidatedInputComponent, ValidatedTextareaComponent, ValidatedNgSelectComponent, GalleryModule, LeafletModule, BreadcrumbsComponent, ShareButton, NgOptimizedImage],
|
||||||
providers: [],
|
providers: [],
|
||||||
templateUrl: './details-commercial-property-listing.component.html',
|
templateUrl: './details-commercial-property-listing.component.html',
|
||||||
styleUrl: '../details.scss',
|
styleUrl: '../details.scss',
|
||||||
})
|
})
|
||||||
export class DetailsCommercialPropertyListingComponent extends BaseDetailsComponent {
|
export class DetailsCommercialPropertyListingComponent extends BaseDetailsComponent {
|
||||||
responsiveOptions = [
|
responsiveOptions = [
|
||||||
{
|
{
|
||||||
breakpoint: '1199px',
|
breakpoint: '1199px',
|
||||||
numVisible: 1,
|
numVisible: 1,
|
||||||
numScroll: 1,
|
numScroll: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
breakpoint: '991px',
|
breakpoint: '991px',
|
||||||
numVisible: 2,
|
numVisible: 2,
|
||||||
numScroll: 1,
|
numScroll: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
breakpoint: '767px',
|
breakpoint: '767px',
|
||||||
numVisible: 1,
|
numVisible: 1,
|
||||||
numScroll: 1,
|
numScroll: 1,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
private id: string | undefined = this.activatedRoute.snapshot.params['slug'] as string | undefined;
|
private id: string | undefined = this.activatedRoute.snapshot.params['slug'] as string | undefined;
|
||||||
override listing: CommercialPropertyListing;
|
override listing: CommercialPropertyListing;
|
||||||
criteria: CommercialPropertyListingCriteria;
|
criteria: CommercialPropertyListingCriteria;
|
||||||
mailinfo: MailInfo;
|
mailinfo: MailInfo;
|
||||||
environment = environment;
|
environment = environment;
|
||||||
keycloakUser: KeycloakUser;
|
keycloakUser: KeycloakUser;
|
||||||
user: User;
|
user: User;
|
||||||
listingUser: User;
|
listingUser: User;
|
||||||
description: SafeHtml;
|
description: SafeHtml;
|
||||||
ts = new Date().getTime();
|
ts = new Date().getTime();
|
||||||
env = environment;
|
env = environment;
|
||||||
errorResponse: ErrorResponse;
|
errorResponse: ErrorResponse;
|
||||||
faTimes = faTimes;
|
faTimes = faTimes;
|
||||||
propertyDetails = [];
|
propertyDetails = [];
|
||||||
images: Array<ImageItem> = [];
|
images: Array<ImageItem> = [];
|
||||||
relatedListings: CommercialPropertyListing[] = [];
|
relatedListings: CommercialPropertyListing[] = [];
|
||||||
breadcrumbs: BreadcrumbItem[] = [];
|
breadcrumbs: BreadcrumbItem[] = [];
|
||||||
propertyFAQs: Array<{ question: string; answer: string }> = [];
|
propertyFAQs: Array<{ question: string; answer: string }> = [];
|
||||||
constructor(
|
constructor(
|
||||||
private activatedRoute: ActivatedRoute,
|
private activatedRoute: ActivatedRoute,
|
||||||
private listingsService: ListingsService,
|
private listingsService: ListingsService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
public selectOptions: SelectOptionsService,
|
public selectOptions: SelectOptionsService,
|
||||||
private mailService: MailService,
|
private mailService: MailService,
|
||||||
private sanitizer: DomSanitizer,
|
private sanitizer: DomSanitizer,
|
||||||
public historyService: HistoryService,
|
public historyService: HistoryService,
|
||||||
private imageService: ImageService,
|
private imageService: ImageService,
|
||||||
private ngZone: NgZone,
|
private ngZone: NgZone,
|
||||||
private validationMessagesService: ValidationMessagesService,
|
private validationMessagesService: ValidationMessagesService,
|
||||||
private messageService: MessageService,
|
private messageService: MessageService,
|
||||||
private auditService: AuditService,
|
private auditService: AuditService,
|
||||||
private emailService: EMailService,
|
private emailService: EMailService,
|
||||||
public authService: AuthService,
|
public authService: AuthService,
|
||||||
private seoService: SeoService,
|
private seoService: SeoService,
|
||||||
private cdref: ChangeDetectorRef,
|
private cdref: ChangeDetectorRef,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.mailinfo = { sender: { name: '', email: '', phoneNumber: '', state: '', comments: '' }, email: '', url: environment.mailinfoUrl };
|
this.mailinfo = { sender: { name: '', email: '', phoneNumber: '', state: '', comments: '' }, email: '', url: environment.mailinfoUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
// Initialize default breadcrumbs first
|
// Initialize default breadcrumbs first
|
||||||
this.breadcrumbs = [
|
this.breadcrumbs = [
|
||||||
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
|
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
|
||||||
{ label: 'Commercial Properties', url: '/commercialPropertyListings' }
|
{ label: 'Commercial Properties', url: '/commercialPropertyListings' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const token = await this.authService.getToken();
|
const token = await this.authService.getToken();
|
||||||
this.keycloakUser = map2User(token);
|
this.keycloakUser = map2User(token);
|
||||||
if (this.keycloakUser) {
|
if (this.keycloakUser) {
|
||||||
this.user = await this.userService.getByMail(this.keycloakUser.email);
|
this.user = await this.userService.getByMail(this.keycloakUser.email);
|
||||||
this.mailinfo = createMailInfo(this.user);
|
this.mailinfo = createMailInfo(this.user);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
this.listing = (await lastValueFrom(this.listingsService.getListingById(this.id, 'commercialProperty'))) as CommercialPropertyListing;
|
this.listing = (await lastValueFrom(this.listingsService.getListingById(this.id, 'commercialProperty'))) as CommercialPropertyListing;
|
||||||
this.auditService.createEvent(this.listing.id, 'view', this.user?.email);
|
this.auditService.createEvent(this.listing.id, 'view', this.user?.email);
|
||||||
this.listingUser = await this.userService.getByMail(this.listing.email);
|
this.listingUser = await this.userService.getByMail(this.listing.email);
|
||||||
this.description = this.sanitizer.bypassSecurityTrustHtml(this.listing.description);
|
this.description = this.sanitizer.bypassSecurityTrustHtml(this.listing.description);
|
||||||
import('flowbite').then(flowbite => {
|
import('flowbite').then(flowbite => {
|
||||||
flowbite.initCarousels();
|
flowbite.initCarousels();
|
||||||
});
|
});
|
||||||
this.propertyDetails = [
|
this.propertyDetails = [
|
||||||
{ label: 'Property Category', value: this.selectOptions.getCommercialProperty(this.listing.type) },
|
{ label: 'Property Category', value: this.selectOptions.getCommercialProperty(this.listing.type) },
|
||||||
{ label: 'Located in', value: this.selectOptions.getState(this.listing.location.state) },
|
{ 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: 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: 'Asking Price:', value: `$${this.listing.price?.toLocaleString()}` },
|
||||||
{ label: 'Listed since', value: `${this.dateInserted()} - ${this.getDaysListed()} days` },
|
{ label: 'Listed since', value: `${this.dateInserted()} - ${this.getDaysListed()} days` },
|
||||||
{
|
{
|
||||||
label: 'Listing by',
|
label: 'Listing by',
|
||||||
value: null, // Wird nicht verwendet
|
value: null, // Wird nicht verwendet
|
||||||
isHtml: true,
|
isHtml: true,
|
||||||
isListingBy: true, // Flag für den speziellen Fall
|
isListingBy: true, // Flag für den speziellen Fall
|
||||||
user: this.listingUser, // Übergebe das User-Objekt
|
user: this.listingUser, // Übergebe das User-Objekt
|
||||||
imagePath: this.listing.imagePath,
|
imagePath: this.listing.imagePath,
|
||||||
imageBaseUrl: this.env.imageBaseUrl,
|
imageBaseUrl: this.env.imageBaseUrl,
|
||||||
ts: this.ts,
|
ts: this.ts,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
if (this.listing.draft) {
|
if (this.listing.draft) {
|
||||||
this.propertyDetails.push({ label: 'Draft', value: this.listing.draft ? 'Yes' : 'No' });
|
this.propertyDetails.push({ label: 'Draft', value: this.listing.draft ? 'Yes' : 'No' });
|
||||||
}
|
}
|
||||||
if (this.listing.imageOrder && Array.isArray(this.listing.imageOrder)) {
|
if (this.listing.imageOrder && Array.isArray(this.listing.imageOrder)) {
|
||||||
this.listing.imageOrder.forEach(image => {
|
this.listing.imageOrder.forEach(image => {
|
||||||
const imageURL = `${this.env.imageBaseUrl}/pictures/property/${this.listing.imagePath}/${this.listing.serialId}/${image}`;
|
const imageURL = `${this.env.imageBaseUrl}/pictures/property/${this.listing.imagePath}/${this.listing.serialId}/${image}`;
|
||||||
this.images.push(new ImageItem({ src: imageURL, thumb: imageURL }));
|
this.images.push(new ImageItem({ src: imageURL, thumb: imageURL }));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (this.listing.location.latitude && this.listing.location.longitude) {
|
if (this.listing.location.latitude && this.listing.location.longitude) {
|
||||||
this.configureMap();
|
this.configureMap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update SEO meta tags for commercial property
|
// Update SEO meta tags for commercial property
|
||||||
const propertyData = {
|
const propertyData = {
|
||||||
id: this.listing.id,
|
id: this.listing.id,
|
||||||
propertyType: this.selectOptions.getCommercialProperty(this.listing.type),
|
propertyType: this.selectOptions.getCommercialProperty(this.listing.type),
|
||||||
propertyDescription: this.listing.description?.replace(/<[^>]*>/g, '').substring(0, 200) || '',
|
propertyDescription: this.listing.description?.replace(/<[^>]*>/g, '').substring(0, 200) || '',
|
||||||
askingPrice: this.listing.price,
|
askingPrice: this.listing.price,
|
||||||
city: this.listing.location.name || this.listing.location.county || '',
|
city: this.listing.location.name || this.listing.location.county || '',
|
||||||
state: this.listing.location.state,
|
state: this.listing.location.state,
|
||||||
address: this.listing.location.street || '',
|
address: this.listing.location.street || '',
|
||||||
zip: this.listing.location.zipCode || '',
|
zip: this.listing.location.zipCode || '',
|
||||||
latitude: this.listing.location.latitude,
|
latitude: this.listing.location.latitude,
|
||||||
longitude: this.listing.location.longitude,
|
longitude: this.listing.location.longitude,
|
||||||
squareFootage: (this.listing as any).squareFeet,
|
squareFootage: (this.listing as any).squareFeet,
|
||||||
yearBuilt: (this.listing as any).yearBuilt,
|
yearBuilt: (this.listing as any).yearBuilt,
|
||||||
images: this.listing.imageOrder?.length > 0
|
images: this.listing.imageOrder?.length > 0
|
||||||
? this.listing.imageOrder.map(img =>
|
? this.listing.imageOrder.map(img =>
|
||||||
`${this.env.imageBaseUrl}/pictures/property/${this.listing.imagePath}/${this.listing.serialId}/${img}`)
|
`${this.env.imageBaseUrl}/pictures/property/${this.listing.imagePath}/${this.listing.serialId}/${img}`)
|
||||||
: []
|
: []
|
||||||
};
|
};
|
||||||
this.seoService.updateCommercialPropertyMeta(propertyData);
|
this.seoService.updateCommercialPropertyMeta(propertyData);
|
||||||
|
|
||||||
// Add RealEstateListing structured data
|
// Add RealEstateListing structured data
|
||||||
const realEstateSchema = this.seoService.generateRealEstateListingSchema(propertyData);
|
const realEstateSchema = this.seoService.generateRealEstateListingSchema(propertyData);
|
||||||
const breadcrumbSchema = this.seoService.generateBreadcrumbSchema([
|
const breadcrumbSchema = this.seoService.generateBreadcrumbSchema([
|
||||||
{ name: 'Home', url: '/' },
|
{ name: 'Home', url: '/' },
|
||||||
{ name: 'Commercial Properties', url: '/commercialPropertyListings' },
|
{ name: 'Commercial Properties', url: '/commercialPropertyListings' },
|
||||||
{ name: propertyData.propertyType, url: `/details-commercial-property/${this.listing.id}` }
|
{ name: propertyData.propertyType, url: `/details-commercial-property/${this.listing.id}` }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Generate FAQ for AEO (Answer Engine Optimization)
|
// Generate FAQ for AEO (Answer Engine Optimization)
|
||||||
this.propertyFAQs = this.generatePropertyFAQ();
|
this.propertyFAQs = this.generatePropertyFAQ();
|
||||||
const faqSchema = this.seoService.generateFAQPageSchema(this.propertyFAQs);
|
const faqSchema = this.seoService.generateFAQPageSchema(this.propertyFAQs);
|
||||||
|
|
||||||
// Inject all schemas including FAQ
|
// Inject all schemas including FAQ
|
||||||
this.seoService.injectMultipleSchemas([realEstateSchema, breadcrumbSchema, faqSchema]);
|
this.seoService.injectMultipleSchemas([realEstateSchema, breadcrumbSchema, faqSchema]);
|
||||||
|
|
||||||
// Generate breadcrumbs for navigation
|
// Generate breadcrumbs for navigation
|
||||||
this.breadcrumbs = [
|
this.breadcrumbs = [
|
||||||
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
|
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
|
||||||
{ label: 'Commercial Properties', url: '/commercialPropertyListings' },
|
{ label: 'Commercial Properties', url: '/commercialPropertyListings' },
|
||||||
{ label: propertyData.propertyType, url: '/commercialPropertyListings' },
|
{ label: propertyData.propertyType, url: '/commercialPropertyListings' },
|
||||||
{ label: this.listing.title }
|
{ label: this.listing.title }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Load related listings for internal linking (SEO improvement)
|
// Load related listings for internal linking (SEO improvement)
|
||||||
this.loadRelatedListings();
|
this.loadRelatedListings();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Set default breadcrumbs even on error
|
// Set default breadcrumbs even on error
|
||||||
this.breadcrumbs = [
|
this.breadcrumbs = [
|
||||||
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
|
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
|
||||||
{ label: 'Commercial Properties', url: '/commercialPropertyListings' }
|
{ label: 'Commercial Properties', url: '/commercialPropertyListings' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const errorMessage = error?.error?.message || error?.message || 'An error occurred while loading the listing';
|
const errorMessage = error?.error?.message || error?.message || 'An error occurred while loading the listing';
|
||||||
this.auditService.log({ severity: 'error', text: errorMessage });
|
this.auditService.log({ severity: 'error', text: errorMessage });
|
||||||
this.router.navigate(['notfound']);
|
this.router.navigate(['notfound']);
|
||||||
}
|
}
|
||||||
|
|
||||||
//this.initFlowbite();
|
//this.initFlowbite();
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Load related commercial property listings based on same category, location, and price range
|
* Load related commercial property listings based on same category, location, and price range
|
||||||
* Improves SEO through internal linking
|
* Improves SEO through internal linking
|
||||||
*/
|
*/
|
||||||
private async loadRelatedListings() {
|
private async loadRelatedListings() {
|
||||||
try {
|
try {
|
||||||
this.relatedListings = (await this.listingsService.getRelatedListings(this.listing, 'commercialProperty', 3)) as CommercialPropertyListing[];
|
this.relatedListings = (await this.listingsService.getRelatedListings(this.listing, 'commercialProperty', 3)) as CommercialPropertyListing[];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading related listings:', error);
|
console.error('Error loading related listings:', error);
|
||||||
this.relatedListings = [];
|
this.relatedListings = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate dynamic FAQ based on commercial property listing data
|
* Generate dynamic FAQ based on commercial property listing data
|
||||||
* Provides AEO (Answer Engine Optimization) content
|
* Provides AEO (Answer Engine Optimization) content
|
||||||
*/
|
*/
|
||||||
private generatePropertyFAQ(): Array<{ question: string; answer: string }> {
|
private generatePropertyFAQ(): Array<{ question: string; answer: string }> {
|
||||||
const faqs: Array<{ question: string; answer: string }> = [];
|
const faqs: Array<{ question: string; answer: string }> = [];
|
||||||
|
|
||||||
// FAQ 1: What type of property is this?
|
// FAQ 1: What type of property is this?
|
||||||
faqs.push({
|
faqs.push({
|
||||||
question: 'What type of commercial property is this?',
|
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)}.`
|
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?
|
// FAQ 2: What is the asking price?
|
||||||
if (this.listing.price) {
|
if (this.listing.price) {
|
||||||
faqs.push({
|
faqs.push({
|
||||||
question: 'What is the asking price for this property?',
|
question: 'What is the asking price for this property?',
|
||||||
answer: `The asking price for this commercial property is $${this.listing.price.toLocaleString()}.`
|
answer: `The asking price for this commercial property is $${this.listing.price.toLocaleString()}.`
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
faqs.push({
|
faqs.push({
|
||||||
question: 'What is the asking price for this property?',
|
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.'
|
answer: 'The asking price is available upon request. Please contact the seller for detailed pricing information.'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// FAQ 3: Where is the property located?
|
// FAQ 3: Where is the property located?
|
||||||
faqs.push({
|
faqs.push({
|
||||||
question: 'Where is this commercial property located?',
|
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.' : ''}`
|
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?
|
// FAQ 4: How long has the property been listed?
|
||||||
const daysListed = this.getDaysListed();
|
const daysListed = this.getDaysListed();
|
||||||
faqs.push({
|
faqs.push({
|
||||||
question: 'How long has this property been on the market?',
|
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'}.`
|
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?
|
// FAQ 5: How can I schedule a viewing?
|
||||||
faqs.push({
|
faqs.push({
|
||||||
question: 'How can I schedule a property viewing?',
|
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.'
|
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?
|
// FAQ 6: What is the zoning for this property?
|
||||||
faqs.push({
|
faqs.push({
|
||||||
question: 'What is this property suitable for?',
|
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.`
|
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;
|
return faqs;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
|
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
|
||||||
this.seoService.clearStructuredData(); // Clean up SEO structured data
|
this.seoService.clearStructuredData(); // Clean up SEO structured data
|
||||||
}
|
}
|
||||||
private initFlowbite() {
|
private initFlowbite() {
|
||||||
this.ngZone.runOutsideAngular(() => {
|
this.ngZone.runOutsideAngular(() => {
|
||||||
import('flowbite')
|
import('flowbite')
|
||||||
.then(flowbite => {
|
.then(flowbite => {
|
||||||
flowbite.initCarousels();
|
flowbite.initCarousels();
|
||||||
})
|
})
|
||||||
.catch(error => console.error('Error initializing Flowbite:', error));
|
.catch(error => console.error('Error initializing Flowbite:', error));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async mail() {
|
async mail() {
|
||||||
try {
|
try {
|
||||||
this.mailinfo.email = this.listingUser.email;
|
this.mailinfo.email = this.listingUser.email;
|
||||||
this.mailinfo.listing = this.listing;
|
this.mailinfo.listing = this.listing;
|
||||||
await this.mailService.mail(this.mailinfo);
|
await this.mailService.mail(this.mailinfo);
|
||||||
this.validationMessagesService.clearMessages();
|
this.validationMessagesService.clearMessages();
|
||||||
this.auditService.createEvent(this.listing.id, 'contact', this.user?.email, this.mailinfo.sender);
|
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.messageService.addMessage({ severity: 'success', text: 'Your message has been sent to the creator of the listing', duration: 3000 });
|
||||||
this.mailinfo = createMailInfo(this.user);
|
this.mailinfo = createMailInfo(this.user);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.messageService.addMessage({
|
this.messageService.addMessage({
|
||||||
severity: 'danger',
|
severity: 'danger',
|
||||||
text: 'An error occurred while sending the request - Please check your inputs',
|
text: 'An error occurred while sending the request - Please check your inputs',
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
if (error.error && Array.isArray(error.error?.message)) {
|
if (error.error && Array.isArray(error.error?.message)) {
|
||||||
this.validationMessagesService.updateMessages(error.error.message);
|
this.validationMessagesService.updateMessages(error.error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
containsError(fieldname: string) {
|
containsError(fieldname: string) {
|
||||||
return this.errorResponse?.fields.map(f => f.fieldname).includes(fieldname);
|
return this.errorResponse?.fields.map(f => f.fieldname).includes(fieldname);
|
||||||
}
|
}
|
||||||
getImageIndices(): number[] {
|
getImageIndices(): number[] {
|
||||||
return this.listing && this.listing.imageOrder ? this.listing.imageOrder.slice(1).map((e, i) => i + 1) : [];
|
return this.listing && this.listing.imageOrder ? this.listing.imageOrder.slice(1).map((e, i) => i + 1) : [];
|
||||||
}
|
}
|
||||||
async toggleFavorite() {
|
async toggleFavorite() {
|
||||||
try {
|
try {
|
||||||
const isFavorited = this.listing.favoritesForUser.includes(this.user.email);
|
const isFavorited = this.listing.favoritesForUser.includes(this.user.email);
|
||||||
|
|
||||||
if (isFavorited) {
|
if (isFavorited) {
|
||||||
// Remove from favorites
|
// Remove from favorites
|
||||||
await this.listingsService.removeFavorite(this.listing.id, 'commercialProperty');
|
await this.listingsService.removeFavorite(this.listing.id, 'commercialProperty');
|
||||||
this.listing.favoritesForUser = this.listing.favoritesForUser.filter(
|
this.listing.favoritesForUser = this.listing.favoritesForUser.filter(
|
||||||
email => email !== this.user.email
|
email => email !== this.user.email
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Add to favorites
|
// Add to favorites
|
||||||
await this.listingsService.addToFavorites(this.listing.id, 'commercialProperty');
|
await this.listingsService.addToFavorites(this.listing.id, 'commercialProperty');
|
||||||
this.listing.favoritesForUser.push(this.user.email);
|
this.listing.favoritesForUser.push(this.user.email);
|
||||||
this.auditService.createEvent(this.listing.id, 'favorite', this.user?.email);
|
this.auditService.createEvent(this.listing.id, 'favorite', this.user?.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.cdref.detectChanges();
|
this.cdref.detectChanges();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error toggling favorite:', error);
|
console.error('Error toggling favorite:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async showShareByEMail() {
|
async showShareByEMail() {
|
||||||
const result = await this.emailService.showShareByEMail({
|
const result = await this.emailService.showShareByEMail({
|
||||||
yourEmail: this.user ? this.user.email : '',
|
yourEmail: this.user ? this.user.email : '',
|
||||||
yourName: this.user ? `${this.user.firstname} ${this.user.lastname}` : '',
|
yourName: this.user ? `${this.user.firstname} ${this.user.lastname}` : '',
|
||||||
recipientEmail: '',
|
recipientEmail: '',
|
||||||
url: environment.mailinfoUrl,
|
url: environment.mailinfoUrl,
|
||||||
listingTitle: this.listing.title,
|
listingTitle: this.listing.title,
|
||||||
id: this.listing.id,
|
id: this.listing.id,
|
||||||
type: 'commercialProperty',
|
type: 'commercialProperty',
|
||||||
});
|
});
|
||||||
if (result) {
|
if (result) {
|
||||||
this.auditService.createEvent(this.listing.id, 'email', this.user?.email, <ShareByEMail>result);
|
this.auditService.createEvent(this.listing.id, 'email', this.user?.email, <ShareByEMail>result);
|
||||||
this.messageService.addMessage({
|
this.messageService.addMessage({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
text: 'Your Email has beend sent',
|
text: 'Your Email has beend sent',
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
createEvent(eventType: EventTypeEnum) {
|
createEvent(eventType: EventTypeEnum) {
|
||||||
this.auditService.createEvent(this.listing.id, eventType, this.user?.email);
|
this.auditService.createEvent(this.listing.id, eventType, this.user?.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
shareToFacebook() {
|
shareToFacebook() {
|
||||||
const url = encodeURIComponent(window.location.href);
|
const url = encodeURIComponent(window.location.href);
|
||||||
window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`, '_blank', 'width=600,height=400');
|
window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`, '_blank', 'width=600,height=400');
|
||||||
this.createEvent('facebook');
|
this.createEvent('facebook');
|
||||||
}
|
}
|
||||||
|
|
||||||
shareToTwitter() {
|
shareToTwitter() {
|
||||||
const url = encodeURIComponent(window.location.href);
|
const url = encodeURIComponent(window.location.href);
|
||||||
const text = encodeURIComponent(this.listing?.title || 'Check out this commercial property');
|
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');
|
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank', 'width=600,height=400');
|
||||||
this.createEvent('x');
|
this.createEvent('x');
|
||||||
}
|
}
|
||||||
|
|
||||||
shareToLinkedIn() {
|
shareToLinkedIn() {
|
||||||
const url = encodeURIComponent(window.location.href);
|
const url = encodeURIComponent(window.location.href);
|
||||||
window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${url}`, '_blank', 'width=600,height=400');
|
window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${url}`, '_blank', 'width=600,height=400');
|
||||||
this.createEvent('linkedin');
|
this.createEvent('linkedin');
|
||||||
}
|
}
|
||||||
|
|
||||||
getDaysListed() {
|
getDaysListed() {
|
||||||
return dayjs().diff(this.listing.created, 'day');
|
return dayjs().diff(this.listing.created, 'day');
|
||||||
}
|
}
|
||||||
dateInserted() {
|
dateInserted() {
|
||||||
return dayjs(this.listing.created).format('DD/MM/YYYY');
|
return dayjs(this.listing.created).format('DD/MM/YYYY');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,221 +1,221 @@
|
||||||
<div class="container mx-auto p-4">
|
<div class="container mx-auto p-4">
|
||||||
<!-- Breadcrumbs -->
|
<!-- Breadcrumbs -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
|
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if(user){
|
@if(user){
|
||||||
<div class="bg-white drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg rounded-lg overflow-hidden">
|
<div class="bg-white drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg rounded-lg overflow-hidden">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between p-4 border-b relative">
|
<div class="flex items-center justify-between p-4 border-b relative">
|
||||||
<div class="flex items-center space-x-4">
|
<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" /> -->
|
<!-- <img src="https://placehold.co/80x80" alt="Profile picture of Avery Brown smiling" class="w-20 h-20 rounded-full" /> -->
|
||||||
@if(user.hasProfile){
|
@if(user.hasProfile){
|
||||||
<img ngSrc="{{ env.imageBaseUrl }}/pictures//profile/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}"
|
<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
|
class="w-20 h-20 rounded-full object-cover" width="80" height="80" priority
|
||||||
alt="Profile picture of {{ user.firstname }} {{ user.lastname }}" />
|
alt="Profile picture of {{ user.firstname }} {{ user.lastname }}" />
|
||||||
} @else {
|
} @else {
|
||||||
<img ngSrc="assets/images/person_placeholder.jpg" class="w-20 h-20 rounded-full" width="80" height="80" priority
|
<img ngSrc="assets/images/person_placeholder.jpg" class="w-20 h-20 rounded-full" width="80" height="80" priority
|
||||||
alt="Default profile picture" />
|
alt="Default profile picture" />
|
||||||
}
|
}
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold flex items-center">
|
<h1 class="text-2xl font-bold flex items-center">
|
||||||
{{ user.firstname }} {{ user.lastname }}
|
{{ user.firstname }} {{ user.lastname }}
|
||||||
<span class="text-yellow-400 ml-2">★</span>
|
<span class="text-yellow-400 ml-2">★</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-neutral-600">
|
<p class="text-neutral-600">
|
||||||
Company
|
Company
|
||||||
<span class="mx-1">-</span>
|
<span class="mx-1">-</span>
|
||||||
{{ user.companyName }}
|
{{ user.companyName }}
|
||||||
<span class="mx-2">|</span>
|
<span class="mx-2">|</span>
|
||||||
For Sale
|
For Sale
|
||||||
<span class="mx-1">-</span>
|
<span class="mx-1">-</span>
|
||||||
<!-- <i class="fas fa-building text-red-500"></i> -->
|
<!-- <i class="fas fa-building text-red-500"></i> -->
|
||||||
<span>{{ businessListings?.length + commercialPropListings?.length }}</span>
|
<span>{{ businessListings?.length + commercialPropListings?.length }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@if(user.hasCompanyLogo){
|
@if(user.hasCompanyLogo){
|
||||||
<div class="relative w-14 h-14">
|
<div class="relative w-14 h-14">
|
||||||
<img ngSrc="{{ env.imageBaseUrl }}/pictures/logo/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}" fill
|
<img ngSrc="{{ env.imageBaseUrl }}/pictures/logo/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}" fill
|
||||||
class="object-contain" alt="Company logo of {{ user.companyName }}" />
|
class="object-contain" alt="Company logo of {{ user.companyName }}" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<!-- <img src="https://placehold.co/45x60" class="w-11 h-14" /> -->
|
<!-- <img src="https://placehold.co/45x60" class="w-11 h-14" /> -->
|
||||||
</div>
|
</div>
|
||||||
<button (click)="historyService.goBack()"
|
<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">
|
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>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<p class="p-4 text-neutral-700 break-words">{{ user.description }}</p>
|
<p class="p-4 text-neutral-700 break-words">{{ user.description }}</p>
|
||||||
|
|
||||||
<!-- Like and Share Action Buttons -->
|
<!-- Like and Share Action Buttons -->
|
||||||
<div class="py-4 px-4 print:hidden">
|
<div class="py-4 px-4 print:hidden">
|
||||||
@if(user && keycloakUser && (user?.email===keycloakUser?.email || (authService.isAdmin() | async))){
|
@if(user && keycloakUser && (user?.email===keycloakUser?.email || (authService.isAdmin() | async))){
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<button class="share share-edit text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
<button class="share share-edit text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||||
[routerLink]="['/account', user.id]">
|
[routerLink]="['/account', user.id]">
|
||||||
<i class="fa-regular fa-pen-to-square"></i>
|
<i class="fa-regular fa-pen-to-square"></i>
|
||||||
<span class="ml-2">Edit</span>
|
<span class="ml-2">Edit</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="inline">
|
<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"
|
<button type="button" class="share share-save text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||||
(click)="toggleFavorite()">
|
(click)="toggleFavorite()">
|
||||||
<i class="fa-regular fa-heart"></i>
|
<i class="fa-regular fa-heart"></i>
|
||||||
@if(isAlreadyFavorite()){
|
@if(isAlreadyFavorite()){
|
||||||
<span class="ml-2">Saved ...</span>
|
<span class="ml-2">Saved ...</span>
|
||||||
}@else {
|
}@else {
|
||||||
<span class="ml-2">Save</span>
|
<span class="ml-2">Save</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<share-button button="print" showText="true" (click)="createEvent('print')"></share-button>
|
<share-button button="print" showText="true" (click)="createEvent('print')"></share-button>
|
||||||
|
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<button class="share share-email text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
<button class="share share-email text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||||
(click)="showShareByEMail()">
|
(click)="showShareByEMail()">
|
||||||
<i class="fa-solid fa-envelope"></i>
|
<i class="fa-solid fa-envelope"></i>
|
||||||
<span class="ml-2">Email</span>
|
<span class="ml-2">Email</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="share share-facebook text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
class="share share-facebook text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||||
(click)="shareToFacebook()">
|
(click)="shareToFacebook()">
|
||||||
<i class="fab fa-facebook"></i>
|
<i class="fab fa-facebook"></i>
|
||||||
<span class="ml-2">Facebook</span>
|
<span class="ml-2">Facebook</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="share share-twitter text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
class="share share-twitter text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||||
(click)="shareToTwitter()">
|
(click)="shareToTwitter()">
|
||||||
<i class="fab fa-x-twitter"></i>
|
<i class="fab fa-x-twitter"></i>
|
||||||
<span class="ml-2">X</span>
|
<span class="ml-2">X</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="inline">
|
<div class="inline">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="share share-linkedin text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
class="share share-linkedin text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||||
(click)="shareToLinkedIn()">
|
(click)="shareToLinkedIn()">
|
||||||
<i class="fab fa-linkedin"></i>
|
<i class="fab fa-linkedin"></i>
|
||||||
<span class="ml-2">LinkedIn</span>
|
<span class="ml-2">LinkedIn</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Company Profile -->
|
<!-- Company Profile -->
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<h2 class="text-xl font-semibold mb-4">Company Profile</h2>
|
<h2 class="text-xl font-semibold mb-4">Company Profile</h2>
|
||||||
<p class="text-neutral-700 mb-4 break-words" [innerHTML]="companyOverview"></p>
|
<p class="text-neutral-700 mb-4 break-words" [innerHTML]="companyOverview"></p>
|
||||||
|
|
||||||
<!-- Profile Details -->
|
<!-- Profile Details -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center bg-neutral-100">
|
<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="font-semibold w-40 p-2">Name</span>
|
||||||
<span class="p-2 flex-grow">{{ user.firstname }} {{ user.lastname }}</span>
|
<span class="p-2 flex-grow">{{ user.firstname }} {{ user.lastname }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center">
|
<div class="flex flex-col sm:flex-row sm:items-center">
|
||||||
<span class="font-semibold w-40 p-2">EMail Address</span>
|
<span class="font-semibold w-40 p-2">EMail Address</span>
|
||||||
<span class="p-2 flex-grow">{{ user.email }}</span>
|
<span class="p-2 flex-grow">{{ user.email }}</span>
|
||||||
</div>
|
</div>
|
||||||
@if(user.customerType==='professional'){
|
@if(user.customerType==='professional'){
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center bg-neutral-100">
|
<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="font-semibold w-40 p-2">Phone Number</span>
|
||||||
<span class="p-2 flex-grow">{{ formatPhoneNumber(user.phoneNumber) }}</span>
|
<span class="p-2 flex-grow">{{ formatPhoneNumber(user.phoneNumber) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center">
|
<div class="flex flex-col sm:flex-row sm:items-center">
|
||||||
<span class="font-semibold w-40 p-2">Company Location</span>
|
<span class="font-semibold w-40 p-2">Company Location</span>
|
||||||
<span class="p-2 flex-grow">{{ user.location?.name }} - {{ user.location?.state }}</span>
|
<span class="p-2 flex-grow">{{ user.location?.name }} - {{ user.location?.state }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center bg-neutral-100">
|
<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="font-semibold w-40 p-2">Professional Type</span>
|
||||||
<span class="p-2 flex-grow">{{ selectOptions.getCustomerSubType(user.customerSubType) }}</span>
|
<span class="p-2 flex-grow">{{ selectOptions.getCustomerSubType(user.customerSubType) }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if(user.customerType==='professional'){
|
@if(user.customerType==='professional'){
|
||||||
<!-- Services -->
|
<!-- Services -->
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<h3 class="font-semibold mb-2">Services we offer</h3>
|
<h3 class="font-semibold mb-2">Services we offer</h3>
|
||||||
<p class="text-neutral-700 mb-4 break-words" [innerHTML]="offeredServices"></p>
|
<p class="text-neutral-700 mb-4 break-words" [innerHTML]="offeredServices"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Areas Served -->
|
<!-- Areas Served -->
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<h3 class="font-semibold mb-2">Areas (Counties) we serve</h3>
|
<h3 class="font-semibold mb-2">Areas (Counties) we serve</h3>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
@for (area of user.areasServed; track area) {
|
@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 ?
|
<span class="bg-primary-100 text-primary-800 px-2 py-1 rounded-full text-sm">{{ area.county }}{{ area.county ?
|
||||||
'-' : '' }}{{ area.state }}</span>
|
'-' : '' }}{{ area.state }}</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Licensed In -->
|
<!-- Licensed In -->
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<h3 class="font-semibold mb-2">Licensed In</h3>
|
<h3 class="font-semibold mb-2">Licensed In</h3>
|
||||||
@for (license of user.licensedIn; track license) {
|
@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 }}-{{
|
<span class="bg-success-100 text-success-800 px-2 py-1 rounded-full text-sm">{{ license.registerNo }}-{{
|
||||||
license.state }}</span>
|
license.state }}</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Business Listings -->
|
<!-- Business Listings -->
|
||||||
|
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
@if(businessListings?.length>0){
|
@if(businessListings?.length>0){
|
||||||
<h2 class="text-xl font-semibold mb-4">My Business Listings For Sale</h2>
|
<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">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@for (listing of businessListings; track listing) {
|
@for (listing of businessListings; track listing) {
|
||||||
<div class="border rounded-lg p-4 hover:cursor-pointer"
|
<div class="border rounded-lg p-4 hover:cursor-pointer"
|
||||||
[routerLink]="['/business', listing.slug || listing.id]">
|
[routerLink]="['/business', listing.slug || listing.id]">
|
||||||
<div class="flex items-center mb-2">
|
<div class="flex items-center mb-2">
|
||||||
<i [class]="selectOptions.getIconAndTextColorType(listing.type)" class="mr-2"></i>
|
<i [class]="selectOptions.getIconAndTextColorType(listing.type)" class="mr-2"></i>
|
||||||
<span class="font-medium">{{ selectOptions.getBusiness(listing.type) }}</span>
|
<span class="font-medium">{{ selectOptions.getBusiness(listing.type) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-neutral-700">{{ listing.title }}</p>
|
<p class="text-neutral-700">{{ listing.title }}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<!-- Commercial Property Listings -->
|
<!-- Commercial Property Listings -->
|
||||||
@if(commercialPropListings?.length>0){
|
@if(commercialPropListings?.length>0){
|
||||||
<h2 class="text-xl font-semibold mb-4">My Commercial Property Listings For Sale</h2>
|
<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">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@for (listing of commercialPropListings; track listing) {
|
@for (listing of commercialPropListings; track listing) {
|
||||||
<div class="border rounded-lg p-4 hover:cursor-pointer"
|
<div class="border rounded-lg p-4 hover:cursor-pointer"
|
||||||
[routerLink]="['/commercial-property', listing.slug || listing.id]">
|
[routerLink]="['/commercial-property', listing.slug || listing.id]">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
@if (listing.imageOrder?.length>0){
|
@if (listing.imageOrder?.length>0){
|
||||||
<img
|
<img
|
||||||
ngSrc="{{ env.imageBaseUrl }}/pictures/property/{{ listing.imagePath }}/{{ listing.serialId }}/{{ listing.imageOrder[0] }}?_ts={{ ts }}"
|
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"
|
class="w-12 h-12 object-cover rounded" width="48" height="48"
|
||||||
alt="Property image for {{ listing.title }}" />
|
alt="Property image for {{ listing.title }}" />
|
||||||
} @else {
|
} @else {
|
||||||
<img ngSrc="assets/images/placeholder_properties.jpg" class="w-12 h-12 object-cover rounded" width="48"
|
<img ngSrc="assets/images/placeholder_properties.jpg" class="w-12 h-12 object-cover rounded" width="48"
|
||||||
height="48" alt="Property placeholder image" />
|
height="48" alt="Property placeholder image" />
|
||||||
}
|
}
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium">{{ selectOptions.getCommercialProperty(listing.type) }}</p>
|
<p class="font-medium">{{ selectOptions.getCommercialProperty(listing.type) }}</p>
|
||||||
<p class="text-neutral-700">{{ listing.title }}</p>
|
<p class="text-neutral-700">{{ listing.title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,169 +1,169 @@
|
||||||
import { ChangeDetectorRef, Component } from '@angular/core';
|
import { ChangeDetectorRef, Component } from '@angular/core';
|
||||||
import { NgOptimizedImage } from '@angular/common';
|
import { NgOptimizedImage } from '@angular/common';
|
||||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { BusinessListing, CommercialPropertyListing, User, ShareByEMail, EventTypeEnum } from '../../../../../../bizmatch-server/src/models/db.model';
|
import { BusinessListing, CommercialPropertyListing, User, ShareByEMail, EventTypeEnum } from '../../../../../../bizmatch-server/src/models/db.model';
|
||||||
import { KeycloakUser, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
|
import { KeycloakUser, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
|
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
|
||||||
import { AuthService } from '../../../services/auth.service';
|
import { AuthService } from '../../../services/auth.service';
|
||||||
import { AuditService } from '../../../services/audit.service';
|
import { AuditService } from '../../../services/audit.service';
|
||||||
import { EMailService } from '../../../components/email/email.service';
|
import { EMailService } from '../../../components/email/email.service';
|
||||||
import { MessageService } from '../../../components/message/message.service';
|
import { MessageService } from '../../../components/message/message.service';
|
||||||
import { HistoryService } from '../../../services/history.service';
|
import { HistoryService } from '../../../services/history.service';
|
||||||
import { ImageService } from '../../../services/image.service';
|
import { ImageService } from '../../../services/image.service';
|
||||||
import { ListingsService } from '../../../services/listings.service';
|
import { ListingsService } from '../../../services/listings.service';
|
||||||
import { SelectOptionsService } from '../../../services/select-options.service';
|
import { SelectOptionsService } from '../../../services/select-options.service';
|
||||||
import { UserService } from '../../../services/user.service';
|
import { UserService } from '../../../services/user.service';
|
||||||
import { SharedModule } from '../../../shared/shared/shared.module';
|
import { SharedModule } from '../../../shared/shared/shared.module';
|
||||||
import { formatPhoneNumber, map2User } from '../../../utils/utils';
|
import { formatPhoneNumber, map2User } from '../../../utils/utils';
|
||||||
import { ShareButton } from 'ngx-sharebuttons/button';
|
import { ShareButton } from 'ngx-sharebuttons/button';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-details-user',
|
selector: 'app-details-user',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [SharedModule, BreadcrumbsComponent, NgOptimizedImage, ShareButton],
|
imports: [SharedModule, BreadcrumbsComponent, NgOptimizedImage, ShareButton],
|
||||||
templateUrl: './details-user.component.html',
|
templateUrl: './details-user.component.html',
|
||||||
styleUrl: '../details.scss',
|
styleUrl: '../details.scss',
|
||||||
})
|
})
|
||||||
export class DetailsUserComponent {
|
export class DetailsUserComponent {
|
||||||
private id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined;
|
private id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined;
|
||||||
user: User;
|
user: User;
|
||||||
breadcrumbs: BreadcrumbItem[] = [
|
breadcrumbs: BreadcrumbItem[] = [
|
||||||
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
|
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
|
||||||
{ label: 'Professionals', url: '/brokerListings' },
|
{ label: 'Professionals', url: '/brokerListings' },
|
||||||
{ label: 'Profile' }
|
{ label: 'Profile' }
|
||||||
];
|
];
|
||||||
user$: Observable<KeycloakUser>;
|
user$: Observable<KeycloakUser>;
|
||||||
keycloakUser: KeycloakUser;
|
keycloakUser: KeycloakUser;
|
||||||
environment = environment;
|
environment = environment;
|
||||||
businessListings: BusinessListing[];
|
businessListings: BusinessListing[];
|
||||||
commercialPropListings: CommercialPropertyListing[];
|
commercialPropListings: CommercialPropertyListing[];
|
||||||
companyOverview: SafeHtml;
|
companyOverview: SafeHtml;
|
||||||
offeredServices: SafeHtml;
|
offeredServices: SafeHtml;
|
||||||
ts = new Date().getTime();
|
ts = new Date().getTime();
|
||||||
env = environment;
|
env = environment;
|
||||||
emailToDirName = emailToDirName;
|
emailToDirName = emailToDirName;
|
||||||
formatPhoneNumber = formatPhoneNumber;
|
formatPhoneNumber = formatPhoneNumber;
|
||||||
constructor(
|
constructor(
|
||||||
private activatedRoute: ActivatedRoute,
|
private activatedRoute: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
private listingsService: ListingsService,
|
private listingsService: ListingsService,
|
||||||
public selectOptions: SelectOptionsService,
|
public selectOptions: SelectOptionsService,
|
||||||
private sanitizer: DomSanitizer,
|
private sanitizer: DomSanitizer,
|
||||||
private imageService: ImageService,
|
private imageService: ImageService,
|
||||||
public historyService: HistoryService,
|
public historyService: HistoryService,
|
||||||
public authService: AuthService,
|
public authService: AuthService,
|
||||||
private auditService: AuditService,
|
private auditService: AuditService,
|
||||||
private emailService: EMailService,
|
private emailService: EMailService,
|
||||||
private messageService: MessageService,
|
private messageService: MessageService,
|
||||||
private cdref: ChangeDetectorRef,
|
private cdref: ChangeDetectorRef,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
this.user = await this.userService.getById(this.id);
|
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')]);
|
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
|
// Zuweisen der Ergebnisse zu den Member-Variablen der Klasse
|
||||||
this.businessListings = results[0];
|
this.businessListings = results[0];
|
||||||
this.commercialPropListings = results[1] as CommercialPropertyListing[];
|
this.commercialPropListings = results[1] as CommercialPropertyListing[];
|
||||||
const token = await this.authService.getToken();
|
const token = await this.authService.getToken();
|
||||||
this.keycloakUser = map2User(token);
|
this.keycloakUser = map2User(token);
|
||||||
this.companyOverview = this.sanitizer.bypassSecurityTrustHtml(this.user.companyOverview ? this.user.companyOverview : '');
|
this.companyOverview = this.sanitizer.bypassSecurityTrustHtml(this.user.companyOverview ? this.user.companyOverview : '');
|
||||||
this.offeredServices = this.sanitizer.bypassSecurityTrustHtml(this.user.offeredServices ? this.user.offeredServices : '');
|
this.offeredServices = this.sanitizer.bypassSecurityTrustHtml(this.user.offeredServices ? this.user.offeredServices : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle professional favorite status
|
* Toggle professional favorite status
|
||||||
*/
|
*/
|
||||||
async toggleFavorite() {
|
async toggleFavorite() {
|
||||||
try {
|
try {
|
||||||
const isFavorited = this.user.favoritesForUser?.includes(this.keycloakUser.email);
|
const isFavorited = this.user.favoritesForUser?.includes(this.keycloakUser.email);
|
||||||
|
|
||||||
if (isFavorited) {
|
if (isFavorited) {
|
||||||
// Remove from favorites
|
// Remove from favorites
|
||||||
await this.listingsService.removeFavorite(this.user.id, 'user');
|
await this.listingsService.removeFavorite(this.user.id, 'user');
|
||||||
this.user.favoritesForUser = this.user.favoritesForUser.filter(
|
this.user.favoritesForUser = this.user.favoritesForUser.filter(
|
||||||
email => email !== this.keycloakUser.email
|
email => email !== this.keycloakUser.email
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Add to favorites
|
// Add to favorites
|
||||||
await this.listingsService.addToFavorites(this.user.id, 'user');
|
await this.listingsService.addToFavorites(this.user.id, 'user');
|
||||||
if (!this.user.favoritesForUser) {
|
if (!this.user.favoritesForUser) {
|
||||||
this.user.favoritesForUser = [];
|
this.user.favoritesForUser = [];
|
||||||
}
|
}
|
||||||
this.user.favoritesForUser.push(this.keycloakUser.email);
|
this.user.favoritesForUser.push(this.keycloakUser.email);
|
||||||
this.auditService.createEvent(this.user.id, 'favorite', this.keycloakUser?.email);
|
this.auditService.createEvent(this.user.id, 'favorite', this.keycloakUser?.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.cdref.detectChanges();
|
this.cdref.detectChanges();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error toggling favorite', error);
|
console.error('Error toggling favorite', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isAlreadyFavorite(): boolean {
|
isAlreadyFavorite(): boolean {
|
||||||
if (!this.keycloakUser?.email || !this.user?.favoritesForUser) return false;
|
if (!this.keycloakUser?.email || !this.user?.favoritesForUser) return false;
|
||||||
return this.user.favoritesForUser.includes(this.keycloakUser.email);
|
return this.user.favoritesForUser.includes(this.keycloakUser.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show email sharing modal
|
* Show email sharing modal
|
||||||
*/
|
*/
|
||||||
async showShareByEMail() {
|
async showShareByEMail() {
|
||||||
const result = await this.emailService.showShareByEMail({
|
const result = await this.emailService.showShareByEMail({
|
||||||
yourEmail: this.keycloakUser ? this.keycloakUser.email : '',
|
yourEmail: this.keycloakUser ? this.keycloakUser.email : '',
|
||||||
yourName: this.keycloakUser ? `${this.keycloakUser.firstName} ${this.keycloakUser.lastName}` : '',
|
yourName: this.keycloakUser ? `${this.keycloakUser.firstName} ${this.keycloakUser.lastName}` : '',
|
||||||
recipientEmail: '',
|
recipientEmail: '',
|
||||||
url: environment.mailinfoUrl,
|
url: environment.mailinfoUrl,
|
||||||
listingTitle: `${this.user.firstname} ${this.user.lastname} - ${this.user.companyName}`,
|
listingTitle: `${this.user.firstname} ${this.user.lastname} - ${this.user.companyName}`,
|
||||||
id: this.user.id,
|
id: this.user.id,
|
||||||
type: 'user',
|
type: 'user',
|
||||||
});
|
});
|
||||||
if (result) {
|
if (result) {
|
||||||
this.auditService.createEvent(this.user.id, 'email', this.keycloakUser?.email, <ShareByEMail>result);
|
this.auditService.createEvent(this.user.id, 'email', this.keycloakUser?.email, <ShareByEMail>result);
|
||||||
this.messageService.addMessage({
|
this.messageService.addMessage({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
text: 'Your Email has been sent',
|
text: 'Your Email has been sent',
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create audit event
|
* Create audit event
|
||||||
*/
|
*/
|
||||||
createEvent(eventType: EventTypeEnum) {
|
createEvent(eventType: EventTypeEnum) {
|
||||||
this.auditService.createEvent(this.user.id, eventType, this.keycloakUser?.email);
|
this.auditService.createEvent(this.user.id, eventType, this.keycloakUser?.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Share to Facebook
|
* Share to Facebook
|
||||||
*/
|
*/
|
||||||
shareToFacebook() {
|
shareToFacebook() {
|
||||||
const url = encodeURIComponent(window.location.href);
|
const url = encodeURIComponent(window.location.href);
|
||||||
window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`, '_blank', 'width=600,height=400');
|
window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`, '_blank', 'width=600,height=400');
|
||||||
this.createEvent('facebook');
|
this.createEvent('facebook');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Share to Twitter/X
|
* Share to Twitter/X
|
||||||
*/
|
*/
|
||||||
shareToTwitter() {
|
shareToTwitter() {
|
||||||
const url = encodeURIComponent(window.location.href);
|
const url = encodeURIComponent(window.location.href);
|
||||||
const text = encodeURIComponent(`Check out ${this.user.firstname} ${this.user.lastname} - ${this.user.companyName}`);
|
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');
|
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank', 'width=600,height=400');
|
||||||
this.createEvent('x');
|
this.createEvent('x');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Share to LinkedIn
|
* Share to LinkedIn
|
||||||
*/
|
*/
|
||||||
shareToLinkedIn() {
|
shareToLinkedIn() {
|
||||||
const url = encodeURIComponent(window.location.href);
|
const url = encodeURIComponent(window.location.href);
|
||||||
window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${url}`, '_blank', 'width=600,height=400');
|
window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${url}`, '_blank', 'width=600,height=400');
|
||||||
this.createEvent('linkedin');
|
this.createEvent('linkedin');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,110 +1,110 @@
|
||||||
:host ::ng-deep p {
|
:host ::ng-deep p {
|
||||||
display: block;
|
display: block;
|
||||||
//margin-top: 1em;
|
//margin-top: 1em;
|
||||||
//margin-bottom: 1em;
|
//margin-bottom: 1em;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
font-size: 1rem; /* oder 1rem, abhängig vom Browser und den Standardeinstellungen */
|
font-size: 1rem; /* oder 1rem, abhängig vom Browser und den Standardeinstellungen */
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
min-height: 1.5em;
|
min-height: 1.5em;
|
||||||
}
|
}
|
||||||
:host ::ng-deep h1 {
|
:host ::ng-deep h1 {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 2em; /* etwa 32px */
|
font-size: 2em; /* etwa 32px */
|
||||||
margin-top: 0.67em;
|
margin-top: 0.67em;
|
||||||
margin-bottom: 0.67em;
|
margin-bottom: 0.67em;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
:host ::ng-deep h2 {
|
:host ::ng-deep h2 {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 1.5em; /* etwa 24px */
|
font-size: 1.5em; /* etwa 24px */
|
||||||
margin-top: 0.83em;
|
margin-top: 0.83em;
|
||||||
margin-bottom: 0.83em;
|
margin-bottom: 0.83em;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
:host ::ng-deep h3 {
|
:host ::ng-deep h3 {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 1.17em; /* etwa 18.72px */
|
font-size: 1.17em; /* etwa 18.72px */
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
:host ::ng-deep ul {
|
:host ::ng-deep ul {
|
||||||
display: block;
|
display: block;
|
||||||
list-style-type: disc; /* listet Punkte (•) vor jedem Listenelement auf */
|
list-style-type: disc; /* listet Punkte (•) vor jedem Listenelement auf */
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
padding-left: 40px; /* Standard-Einrückung für Listen */
|
padding-left: 40px; /* Standard-Einrückung für Listen */
|
||||||
}
|
}
|
||||||
:host ::ng-deep li {
|
:host ::ng-deep li {
|
||||||
display: list-item; /* Zeigt das Element als Listenelement an */
|
display: list-item; /* Zeigt das Element als Listenelement an */
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
button.share {
|
button.share {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
transform: translateY(-2px) scale(1.03);
|
transform: translateY(-2px) scale(1.03);
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
margin-left: 2px;
|
margin-left: 2px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
i {
|
i {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.share-edit {
|
.share-edit {
|
||||||
background-color: #0088cc;
|
background-color: #0088cc;
|
||||||
}
|
}
|
||||||
.share-save {
|
.share-save {
|
||||||
background-color: #e60023;
|
background-color: #e60023;
|
||||||
}
|
}
|
||||||
.share-email {
|
.share-email {
|
||||||
background-color: #ff961c;
|
background-color: #ff961c;
|
||||||
}
|
}
|
||||||
.share-facebook {
|
.share-facebook {
|
||||||
background-color: #1877f2;
|
background-color: #1877f2;
|
||||||
}
|
}
|
||||||
.share-twitter {
|
.share-twitter {
|
||||||
background-color: #000000;
|
background-color: #000000;
|
||||||
}
|
}
|
||||||
.share-linkedin {
|
.share-linkedin {
|
||||||
background-color: #0a66c2;
|
background-color: #0a66c2;
|
||||||
}
|
}
|
||||||
:host ::ng-deep .ng-select-container {
|
:host ::ng-deep .ng-select-container {
|
||||||
height: 42px !important;
|
height: 42px !important;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
.ng-value-container .ng-input {
|
.ng-value-container .ng-input {
|
||||||
top: 10px;
|
top: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* details.scss */
|
/* details.scss */
|
||||||
|
|
||||||
/* Stil für das Adress-Info-Feld */
|
/* Stil für das Adress-Info-Feld */
|
||||||
.address-control {
|
.address-control {
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: rgba(255, 255, 255, 0.9);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.65);
|
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.65);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.address-control a {
|
.address-control a {
|
||||||
color: #007bff;
|
color: #007bff;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.address-control a:hover {
|
.address-control a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
<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" />
|
<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">
|
<div class="hidden md:flex items-center space-x-4">
|
||||||
@if(user){
|
@if(user){
|
||||||
<a routerLink="/account" class="text-primary-600 border border-primary-600 px-3 py-2 rounded">Account</a>
|
<a routerLink="/account" class="text-primary-600 border border-primary-600 px-3 py-2 rounded">Account</a>
|
||||||
} @else {
|
} @else {
|
||||||
<!-- <a routerLink="/pricing" class="text-neutral-800">Pricing</a> -->
|
<!-- <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: '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" [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> -->
|
<!-- <a routerLink="/login" class="text-primary-500 hover:underline">Login/Register</a> -->
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<button (click)="toggleMenu()" class="md:hidden text-neutral-600">
|
<button
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
(click)="toggleMenu()"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16m-7 6h7"></path>
|
class="md:hidden text-neutral-600"
|
||||||
</svg>
|
aria-label="Open navigation menu"
|
||||||
</button>
|
[attr.aria-expanded]="isMenuOpen"
|
||||||
</header>
|
>
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
<div *ngIf="isMenuOpen" class="fixed inset-0 bg-neutral-800 bg-opacity-75 z-20">
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16m-7 6h7"></path>
|
||||||
<div class="flex flex-col items-center justify-center h-full">
|
</svg>
|
||||||
<!-- <a href="#" class="text-white text-xl py-2">Pricing</a> -->
|
</button>
|
||||||
@if(user){
|
</header>
|
||||||
<a routerLink="/account" class="text-white text-xl py-2">Account</a>
|
|
||||||
} @else {
|
<div *ngIf="isMenuOpen" class="fixed inset-0 bg-neutral-800 bg-opacity-75 z-20">
|
||||||
<a routerLink="/login" [queryParams]="{ mode: 'login' }" class="text-white text-xl py-2">Log In</a>
|
<div class="flex flex-col items-center justify-center h-full">
|
||||||
<a routerLink="/login" [queryParams]="{ mode: 'register' }" class="text-white text-xl py-2">Sign Up</a>
|
<!-- <a href="#" class="text-white text-xl py-2">Pricing</a> -->
|
||||||
}
|
@if(user){
|
||||||
<button (click)="toggleMenu()" class="text-white mt-4">
|
<a routerLink="/account" class="text-white text-xl py-2">Account</a>
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
} @else {
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path>
|
<a routerLink="/login" [queryParams]="{ mode: 'login' }" class="text-white text-xl py-2">Log In</a>
|
||||||
</svg>
|
<a routerLink="/login" [queryParams]="{ mode: 'register' }" class="text-white text-xl py-2">Sign Up</a>
|
||||||
</button>
|
}
|
||||||
</div>
|
<button
|
||||||
</div>
|
(click)="toggleMenu()"
|
||||||
|
class="text-white mt-4"
|
||||||
<!-- ==== ANPASSUNGEN START ==== -->
|
aria-label="Close navigation menu"
|
||||||
<!-- 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">
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
<div
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
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"
|
</svg>
|
||||||
>
|
</button>
|
||||||
<div class="flex justify-center w-full">
|
</div>
|
||||||
<!-- 3. Für Mobile: m-2 statt max-w-xs; ab sm: wieder max-width und kein Margin -->
|
</div>
|
||||||
<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 -->
|
<!-- ==== ANPASSUNGEN START ==== -->
|
||||||
<section class="relative">
|
<!-- 1. px-4 für <main> (vorher px-2 sm:px-4) -->
|
||||||
<!-- Dein Hintergrundbild liegt hier per CSS oder absolutem <img> -->
|
<main class="flex flex-col items-center justify-center px-4 w-full flex-grow">
|
||||||
|
<div
|
||||||
<!-- 1) Overlay: sorgt für Kontrast auf hellem Himmel -->
|
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"
|
||||||
<div aria-hidden="true" class="pointer-events-none absolute inset-0"></div>
|
>
|
||||||
|
<!-- Optimized Background Image -->
|
||||||
<!-- 2) Textblock -->
|
<picture class="absolute inset-0 w-full h-full z-0 pointer-events-none">
|
||||||
<div class="relative z-10 mx-auto max-w-4xl px-6 sm:px-6 py-4 sm:py-16 text-center text-white">
|
<source srcset="/assets/images/flags_bg.avif" type="image/avif">
|
||||||
<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>
|
<img
|
||||||
|
width="2500"
|
||||||
<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)]">
|
height="1285"
|
||||||
Find profitable businesses for sale, commercial real estate, and franchise opportunities across the United States
|
fetchpriority="high"
|
||||||
</p>
|
loading="eager"
|
||||||
</div>
|
src="/assets/images/flags_bg.jpg"
|
||||||
</section>
|
alt=""
|
||||||
<!-- Restliche Anpassungen (Innenabstände, Button-Paddings etc.) bleiben wie im vorherigen Schritt -->
|
class="w-full h-full object-cover"
|
||||||
<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){
|
</picture>
|
||||||
<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">
|
<!-- Gradient Overlay -->
|
||||||
<li class="w-[33%]">
|
<div class="absolute inset-0 bg-gradient-to-b from-black/35 via-black/15 to-transparent z-0 pointer-events-none"></div>
|
||||||
<a
|
|
||||||
(click)="changeTab('business')"
|
<div class="flex justify-center w-full relative z-10">
|
||||||
[ngClass]="
|
<!-- 3. Für Mobile: m-2 statt max-w-xs; ab sm: wieder max-width und kein Margin -->
|
||||||
activeTabAction === 'business'
|
<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">
|
||||||
? ['text-primary-600', 'border-primary-600', 'active', 'dark:text-primary-500', 'dark:border-primary-500']
|
<!-- Hero-Container -->
|
||||||
: ['border-transparent', 'hover:text-neutral-600', 'hover:border-neutral-300', 'dark:hover:text-neutral-300']
|
<section class="relative">
|
||||||
"
|
<!-- Dein Hintergrundbild liegt hier per CSS oder absolutem <img> -->
|
||||||
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"
|
|
||||||
>
|
<!-- 1) Overlay: sorgt für Kontrast auf hellem Himmel (Previous overlay removed, using new global overlay) -->
|
||||||
<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" />
|
<!-- <div aria-hidden="true" class="pointer-events-none absolute inset-0"></div> -->
|
||||||
<span>Businesses</span>
|
|
||||||
</a>
|
<!-- 2) Textblock -->
|
||||||
</li>
|
<div class="relative z-10 mx-auto max-w-4xl px-6 sm:px-6 py-4 sm:py-16 text-center text-white">
|
||||||
@if ((numberOfCommercial$ | async) > 0) {
|
<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>
|
||||||
<li class="w-[33%]">
|
|
||||||
<a
|
<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)]">
|
||||||
(click)="changeTab('commercialProperty')"
|
Find profitable businesses for sale, commercial real estate, and franchise opportunities across the United States
|
||||||
[ngClass]="
|
</p>
|
||||||
activeTabAction === 'commercialProperty'
|
</div>
|
||||||
? ['text-primary-600', 'border-primary-600', 'active', 'dark:text-primary-500', 'dark:border-primary-500']
|
</section>
|
||||||
: ['border-transparent', 'hover:text-neutral-600', 'hover:border-neutral-300', 'dark:hover:text-neutral-300']
|
<!-- 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 }">
|
||||||
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"
|
@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">
|
||||||
<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" />
|
<ul class="flex flex-wrap -mb-px w-full" role="tablist">
|
||||||
<span>Properties</span>
|
<li class="w-[33%]" role="presentation">
|
||||||
</a>
|
<button
|
||||||
</li>
|
type="button"
|
||||||
}
|
role="tab"
|
||||||
<li class="w-[33%]">
|
[attr.aria-selected]="activeTabAction === 'business'"
|
||||||
<a
|
(click)="changeTab('business')"
|
||||||
(click)="changeTab('broker')"
|
[ngClass]="
|
||||||
[ngClass]="
|
activeTabAction === 'business'
|
||||||
activeTabAction === 'broker'
|
? ['text-primary-600', 'border-primary-600', 'active', 'dark:text-primary-500', 'dark:border-primary-500']
|
||||||
? ['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']
|
||||||
: ['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"
|
||||||
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="" 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" />
|
||||||
<img
|
<span>Businesses</span>
|
||||||
src="/assets/images/icon_professionals.png"
|
</button>
|
||||||
alt="Search business professionals and brokers"
|
</li>
|
||||||
class="tab-icon w-6 h-6 md:w-7 md:h-7 mr-1 md:mr-2 object-contain bg-transparent"
|
@if ((numberOfCommercial$ | async) > 0) {
|
||||||
style="mix-blend-mode: darken"
|
<li class="w-[33%]" role="presentation">
|
||||||
/>
|
<button
|
||||||
<span>Professionals</span>
|
type="button"
|
||||||
</a>
|
role="tab"
|
||||||
</li>
|
[attr.aria-selected]="activeTabAction === 'commercialProperty'"
|
||||||
</ul>
|
(click)="changeTab('commercialProperty')"
|
||||||
</div>
|
[ngClass]="
|
||||||
} @if(criteria && !aiSearch){
|
activeTabAction === 'commercialProperty'
|
||||||
<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">
|
? ['text-primary-600', 'border-primary-600', 'active', 'dark:text-primary-500', 'dark:border-primary-500']
|
||||||
<div class="md:flex-none md:w-48 flex-1 md:border-r border-neutral-300 overflow-hidden mb-2 md:mb-0">
|
: ['border-transparent', 'hover:text-neutral-600', 'hover:border-neutral-300', 'dark:hover:text-neutral-300']
|
||||||
<div class="relative max-sm:border border-neutral-300 rounded-md">
|
"
|
||||||
<select
|
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"
|
||||||
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"
|
<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" />
|
||||||
(ngModelChange)="onTypesChange($event)"
|
<span>Properties</span>
|
||||||
[ngClass]="{ 'placeholder-selected': criteria.types.length === 0 }"
|
</button>
|
||||||
>
|
</li>
|
||||||
<option [value]="[]">{{ getPlaceholderLabel() }}</option>
|
}
|
||||||
@for(type of getTypes(); track type){
|
<li class="w-[33%]" role="presentation">
|
||||||
<option [value]="type.value">{{ type.name }}</option>
|
<button
|
||||||
}
|
type="button"
|
||||||
</select>
|
role="tab"
|
||||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-neutral-700">
|
[attr.aria-selected]="activeTabAction === 'broker'"
|
||||||
<i class="fas fa-chevron-down text-xs"></i>
|
(click)="changeTab('broker')"
|
||||||
</div>
|
[ngClass]="
|
||||||
</div>
|
activeTabAction === 'broker'
|
||||||
</div>
|
? ['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']
|
||||||
<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">
|
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"
|
||||||
<ng-select
|
>
|
||||||
class="custom md:border-none rounded-md md:rounded-none"
|
<img
|
||||||
[multiple]="false"
|
src="/assets/images/icon_professionals.png"
|
||||||
[hideSelected]="true"
|
alt=""
|
||||||
[trackByFn]="trackByFn"
|
aria-hidden="true"
|
||||||
[minTermLength]="2"
|
class="tab-icon w-6 h-6 md:w-7 md:h-7 mr-1 md:mr-2 object-contain"
|
||||||
[loading]="cityLoading"
|
style="mix-blend-mode: darken"
|
||||||
typeToSearchText="Please enter 2 or more characters"
|
width="28" height="28"
|
||||||
[typeahead]="cityInput$"
|
/>
|
||||||
[ngModel]="cityOrState"
|
<span>Professionals</span>
|
||||||
(ngModelChange)="setCityOrState($event)"
|
</button>
|
||||||
placeholder="Enter City or State ..."
|
</li>
|
||||||
groupBy="type"
|
</ul>
|
||||||
>
|
</div>
|
||||||
@for (city of cities$ | async; track city.id) { @let state = city.type==='city'?city.content.state:''; @let separator = city.type==='city'?' - ':'';
|
} @if(criteria && !aiSearch){
|
||||||
<ng-option [value]="city">{{ city.content.name }}{{ separator }}{{ state }}</ng-option>
|
<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">
|
||||||
</ng-select>
|
<div class="relative max-sm:border border-neutral-300 rounded-md">
|
||||||
</div>
|
<label for="type-filter" class="sr-only">Filter by type</label>
|
||||||
</div>
|
<select
|
||||||
@if (criteria.radius && !aiSearch){
|
id="type-filter"
|
||||||
<div class="md:flex-none md:w-36 flex-1 md:border-r border-neutral-300 mb-2 md:mb-0">
|
aria-label="Filter by type"
|
||||||
<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]"
|
||||||
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)="onRadiusChange($event)"
|
(ngModelChange)="onTypesChange($event)"
|
||||||
[ngModel]="criteria.radius"
|
[ngClass]="{ 'placeholder-selected': criteria.types.length === 0 }"
|
||||||
[ngClass]="{ 'placeholder-selected': !criteria.radius }"
|
>
|
||||||
>
|
<option [value]="[]">{{ getPlaceholderLabel() }}</option>
|
||||||
<option [value]="null">City Radius</option>
|
@for(type of getTypes(); track type){
|
||||||
@for(dist of selectOptions.distances; track dist){
|
<option [value]="type.value">{{ type.name }}</option>
|
||||||
<option [value]="dist.value">{{ dist.name }}</option>
|
}
|
||||||
}
|
</select>
|
||||||
</select>
|
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-neutral-700">
|
||||||
<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>
|
||||||
<i class="fas fa-chevron-down text-xs"></i>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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="bg-primary-500 hover:bg-primary-600 max-sm:rounded-md search-button">
|
<div class="relative max-sm:border border-neutral-300 rounded-md">
|
||||||
@if( numberOfResults$){
|
<label for="location-search" class="sr-only">Search by city or state</label>
|
||||||
<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()">
|
<ng-select
|
||||||
<i class="fas fa-search"></i>
|
class="custom md:border-none rounded-md md:rounded-none"
|
||||||
<span>Search {{ numberOfResults$ | async }}</span>
|
[multiple]="false"
|
||||||
</button>
|
[hideSelected]="true"
|
||||||
}@else {
|
[trackByFn]="trackByFn"
|
||||||
<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()">
|
[minTermLength]="2"
|
||||||
<i class="fas fa-search"></i>
|
[loading]="cityLoading"
|
||||||
<span>Search</span>
|
typeToSearchText="Please enter 2 or more characters"
|
||||||
</button>
|
[typeahead]="cityInput$"
|
||||||
}
|
[ngModel]="cityOrState"
|
||||||
</div>
|
(ngModelChange)="setCityOrState($event)"
|
||||||
</div>
|
placeholder="Enter City or State ..."
|
||||||
}
|
groupBy="type"
|
||||||
</div>
|
labelForId="location-search"
|
||||||
</div>
|
aria-label="Search by city or state"
|
||||||
</div>
|
>
|
||||||
</div>
|
@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>
|
||||||
<!-- FAQ Section for SEO/AEO -->
|
}
|
||||||
<!-- <div class="w-full px-4 mt-12 max-w-4xl mx-auto">
|
</ng-select>
|
||||||
<app-faq [faqItems]="faqItems"></app-faq>
|
</div>
|
||||||
</div> -->
|
</div>
|
||||||
</main>
|
@if (criteria.radius && !aiSearch){
|
||||||
<!-- ==== ANPASSUNGEN ENDE ==== -->
|
<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 ==== -->
|
||||||
|
|
|
||||||
|
|
@ -1,271 +1,252 @@
|
||||||
.bg-cover-custom {
|
|
||||||
position: relative;
|
select:not([size]) {
|
||||||
// Prioritize AVIF format (69KB) over JPG (26MB)
|
background-image: unset;
|
||||||
background-image: url('/assets/images/flags_bg.avif');
|
}
|
||||||
background-size: cover;
|
[type='text'],
|
||||||
background-position: center;
|
[type='email'],
|
||||||
border-radius: 20px;
|
[type='url'],
|
||||||
|
[type='password'],
|
||||||
// Fallback for browsers that don't support AVIF
|
[type='number'],
|
||||||
@supports not (background-image: url('/assets/images/flags_bg.avif')) {
|
[type='date'],
|
||||||
background-image: url('/assets/images/flags_bg.jpg');
|
[type='datetime-local'],
|
||||||
}
|
[type='month'],
|
||||||
|
[type='search'],
|
||||||
// Add gradient overlay for better text contrast
|
[type='tel'],
|
||||||
&::before {
|
[type='time'],
|
||||||
content: '';
|
[type='week'],
|
||||||
position: absolute;
|
[multiple],
|
||||||
top: 0;
|
textarea,
|
||||||
left: 0;
|
select {
|
||||||
right: 0;
|
border: unset;
|
||||||
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%);
|
.toggle-checkbox:checked {
|
||||||
border-radius: 20px;
|
right: 0;
|
||||||
pointer-events: none;
|
border-color: rgb(125 211 252);
|
||||||
z-index: 1;
|
}
|
||||||
}
|
.toggle-checkbox:checked + .toggle-label {
|
||||||
|
background-color: rgb(125 211 252);
|
||||||
// Ensure content stays above overlay
|
}
|
||||||
> * {
|
:host ::ng-deep .ng-select.ng-select-single .ng-select-container {
|
||||||
position: relative;
|
min-height: 52px;
|
||||||
z-index: 2;
|
border: none;
|
||||||
}
|
background-color: transparent;
|
||||||
}
|
.ng-value-container .ng-input {
|
||||||
select:not([size]) {
|
top: 12px;
|
||||||
background-image: unset;
|
}
|
||||||
}
|
span.ng-arrow-wrapper {
|
||||||
[type='text'],
|
display: none;
|
||||||
[type='email'],
|
}
|
||||||
[type='url'],
|
}
|
||||||
[type='password'],
|
select {
|
||||||
[type='number'],
|
color: #000; /* Standard-Textfarbe für das Dropdown */
|
||||||
[type='date'],
|
// background-color: #fff; /* Hintergrundfarbe für das Dropdown */
|
||||||
[type='datetime-local'],
|
}
|
||||||
[type='month'],
|
|
||||||
[type='search'],
|
select option {
|
||||||
[type='tel'],
|
color: #000; /* Textfarbe für Dropdown-Optionen */
|
||||||
[type='time'],
|
}
|
||||||
[type='week'],
|
|
||||||
[multiple],
|
select.placeholder-selected {
|
||||||
textarea,
|
color: #999; /* Farbe für den Platzhalter */
|
||||||
select {
|
}
|
||||||
border: unset;
|
input::placeholder {
|
||||||
}
|
color: #555; /* Dunkleres Grau */
|
||||||
.toggle-checkbox:checked {
|
opacity: 1; /* Stellt sicher, dass die Deckkraft 100% ist */
|
||||||
right: 0;
|
}
|
||||||
border-color: rgb(125 211 252);
|
|
||||||
}
|
/* Stellt sicher, dass die Optionen im Dropdown immer schwarz sind */
|
||||||
.toggle-checkbox:checked + .toggle-label {
|
select:focus option,
|
||||||
background-color: rgb(125 211 252);
|
select:hover option {
|
||||||
}
|
color: #000 !important;
|
||||||
:host ::ng-deep .ng-select.ng-select-single .ng-select-container {
|
}
|
||||||
min-height: 52px;
|
input[type='text'][name='aiSearchText'] {
|
||||||
border: none;
|
padding: 14px; /* Innerer Abstand */
|
||||||
background-color: transparent;
|
font-size: 16px; /* Schriftgröße anpassen */
|
||||||
.ng-value-container .ng-input {
|
box-sizing: border-box; /* Padding und Border in die Höhe und Breite einrechnen */
|
||||||
top: 12px;
|
height: 48px;
|
||||||
}
|
}
|
||||||
span.ng-arrow-wrapper {
|
|
||||||
display: none;
|
// Enhanced Search Button Styling
|
||||||
}
|
.search-button {
|
||||||
}
|
position: relative;
|
||||||
select {
|
overflow: hidden;
|
||||||
color: #000; /* Standard-Textfarbe für das Dropdown */
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
// background-color: #fff; /* Hintergrundfarbe für das Dropdown */
|
|
||||||
}
|
&:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
|
||||||
select option {
|
filter: brightness(1.05);
|
||||||
color: #000; /* Textfarbe für Dropdown-Optionen */
|
}
|
||||||
}
|
|
||||||
|
&:active {
|
||||||
select.placeholder-selected {
|
transform: scale(0.98);
|
||||||
color: #999; /* Farbe für den Platzhalter */
|
}
|
||||||
}
|
|
||||||
input::placeholder {
|
// Ripple effect
|
||||||
color: #555; /* Dunkleres Grau */
|
&::after {
|
||||||
opacity: 1; /* Stellt sicher, dass die Deckkraft 100% ist */
|
content: '';
|
||||||
}
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
/* Stellt sicher, dass die Optionen im Dropdown immer schwarz sind */
|
left: 50%;
|
||||||
select:focus option,
|
width: 0;
|
||||||
select:hover option {
|
height: 0;
|
||||||
color: #000 !important;
|
border-radius: 50%;
|
||||||
}
|
background: rgba(255, 255, 255, 0.3);
|
||||||
input[type='text'][name='aiSearchText'] {
|
transform: translate(-50%, -50%);
|
||||||
padding: 14px; /* Innerer Abstand */
|
transition:
|
||||||
font-size: 16px; /* Schriftgröße anpassen */
|
width 0.6s,
|
||||||
box-sizing: border-box; /* Padding und Border in die Höhe und Breite einrechnen */
|
height 0.6s;
|
||||||
height: 48px;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhanced Search Button Styling
|
&:active::after {
|
||||||
.search-button {
|
width: 300px;
|
||||||
position: relative;
|
height: 300px;
|
||||||
overflow: hidden;
|
}
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
}
|
||||||
|
|
||||||
&:hover {
|
// Tab Icon Styling
|
||||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
|
.tab-icon {
|
||||||
filter: brightness(1.05);
|
font-size: 1rem;
|
||||||
}
|
margin-right: 0.5rem;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
&:active {
|
}
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
.tab-link {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
// Ripple effect
|
|
||||||
&::after {
|
&:hover .tab-icon {
|
||||||
content: '';
|
transform: scale(1.15);
|
||||||
position: absolute;
|
}
|
||||||
top: 50%;
|
}
|
||||||
left: 50%;
|
|
||||||
width: 0;
|
// Input Field Hover Effects
|
||||||
height: 0;
|
select,
|
||||||
border-radius: 50%;
|
.ng-select {
|
||||||
background: rgba(255, 255, 255, 0.3);
|
transition: all 0.2s ease-in-out;
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
transition:
|
&:hover {
|
||||||
width 0.6s,
|
background-color: rgba(243, 244, 246, 0.8);
|
||||||
height 0.6s;
|
}
|
||||||
pointer-events: none;
|
|
||||||
}
|
&:focus,
|
||||||
|
&:focus-within {
|
||||||
&:active::after {
|
background-color: white;
|
||||||
width: 300px;
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||||
height: 300px;
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Smooth tab transitions
|
||||||
// Tab Icon Styling
|
.tab-content {
|
||||||
.tab-icon {
|
animation: fadeInUp 0.3s ease-out;
|
||||||
font-size: 1rem;
|
}
|
||||||
margin-right: 0.5rem;
|
|
||||||
transition: transform 0.2s ease;
|
@keyframes fadeInUp {
|
||||||
}
|
from {
|
||||||
|
opacity: 0;
|
||||||
.tab-link {
|
transform: translateY(10px);
|
||||||
transition: all 0.2s ease-in-out;
|
}
|
||||||
|
to {
|
||||||
&:hover .tab-icon {
|
opacity: 1;
|
||||||
transform: scale(1.15);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Input Field Hover Effects
|
// Trust section container - more prominent
|
||||||
select,
|
.trust-section-container {
|
||||||
.ng-select {
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||||
transition: all 0.2s ease-in-out;
|
transition:
|
||||||
|
box-shadow 0.3s ease,
|
||||||
&:hover {
|
transform 0.3s ease;
|
||||||
background-color: rgba(243, 244, 246, 0.8);
|
|
||||||
}
|
&:hover {
|
||||||
|
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12);
|
||||||
&:focus,
|
}
|
||||||
&:focus-within {
|
}
|
||||||
background-color: white;
|
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
// Trust badge animations - subtle lowkey style
|
||||||
}
|
.trust-badge {
|
||||||
}
|
transition: opacity 0.2s ease;
|
||||||
|
|
||||||
// Smooth tab transitions
|
&:hover {
|
||||||
.tab-content {
|
opacity: 0.8;
|
||||||
animation: fadeInUp 0.3s ease-out;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeInUp {
|
.trust-icon {
|
||||||
from {
|
transition:
|
||||||
opacity: 0;
|
background-color 0.2s ease,
|
||||||
transform: translateY(10px);
|
color 0.2s ease;
|
||||||
}
|
}
|
||||||
to {
|
|
||||||
opacity: 1;
|
.trust-badge:hover .trust-icon {
|
||||||
transform: translateY(0);
|
background-color: rgb(229, 231, 235); // gray-200
|
||||||
}
|
color: rgb(75, 85, 99); // gray-600
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trust section container - more prominent
|
// Stat counter animation - minimal
|
||||||
.trust-section-container {
|
.stat-number {
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
transition: color 0.2s ease;
|
||||||
transition:
|
|
||||||
box-shadow 0.3s ease,
|
&:hover {
|
||||||
transform 0.3s ease;
|
color: rgb(55, 65, 81); // gray-700 darker
|
||||||
|
}
|
||||||
&:hover {
|
}
|
||||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12);
|
|
||||||
}
|
// Search form container enhancement
|
||||||
}
|
.search-form-container {
|
||||||
|
transition: all 0.3s ease;
|
||||||
// Trust badge animations - subtle lowkey style
|
// KEIN backdrop-filter hier!
|
||||||
.trust-badge {
|
background-color: rgba(255, 255, 255, 0.95) !important;
|
||||||
transition: opacity 0.2s ease;
|
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);
|
||||||
&:hover {
|
|
||||||
opacity: 0.8;
|
// Falls Firefox das Element "vergisst", erzwingen wir eine Ebene
|
||||||
}
|
transform: translateZ(0);
|
||||||
}
|
opacity: 1 !important;
|
||||||
|
visibility: visible !important;
|
||||||
.trust-icon {
|
}
|
||||||
transition:
|
|
||||||
background-color 0.2s ease,
|
// Header button improvements
|
||||||
color 0.2s ease;
|
header {
|
||||||
}
|
a {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
.trust-badge:hover .trust-icon {
|
|
||||||
background-color: rgb(229, 231, 235); // gray-200
|
&.text-blue-600.border.border-blue-600 {
|
||||||
color: rgb(75, 85, 99); // gray-600
|
// Log In button
|
||||||
}
|
&:hover {
|
||||||
|
background-color: rgba(37, 99, 235, 0.05);
|
||||||
// Stat counter animation - minimal
|
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.15);
|
||||||
.stat-number {
|
}
|
||||||
transition: color 0.2s ease;
|
|
||||||
|
&:active {
|
||||||
&:hover {
|
transform: scale(0.98);
|
||||||
color: rgb(55, 65, 81); // gray-700 darker
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
&.bg-blue-600 {
|
||||||
// Search form container enhancement
|
// Register button
|
||||||
.search-form-container {
|
&:hover {
|
||||||
transition: all 0.3s ease;
|
background-color: rgb(29, 78, 216);
|
||||||
// KEIN backdrop-filter hier!
|
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
|
||||||
background-color: rgba(255, 255, 255, 0.95) !important;
|
filter: brightness(1.05);
|
||||||
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);
|
|
||||||
|
&:active {
|
||||||
// Falls Firefox das Element "vergisst", erzwingen wir eine Ebene
|
transform: scale(0.98);
|
||||||
transform: translateZ(0);
|
}
|
||||||
opacity: 1 !important;
|
}
|
||||||
visibility: visible !important;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header button improvements
|
// Screen reader only - visually hidden but accessible
|
||||||
header {
|
.sr-only {
|
||||||
a {
|
position: absolute;
|
||||||
transition: all 0.2s ease-in-out;
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
&.text-blue-600.border.border-blue-600 {
|
padding: 0;
|
||||||
// Log In button
|
margin: -1px;
|
||||||
&:hover {
|
overflow: hidden;
|
||||||
background-color: rgba(37, 99, 235, 0.05);
|
clip: rect(0, 0, 0, 0);
|
||||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.15);
|
white-space: nowrap;
|
||||||
}
|
border-width: 0;
|
||||||
|
}
|
||||||
&: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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,345 +1,345 @@
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ChangeDetectorRef, Component, ElementRef, ViewChild } from '@angular/core';
|
import { ChangeDetectorRef, Component, ElementRef, ViewChild } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
import { NgSelectModule } from '@ng-select/ng-select';
|
import { NgSelectModule } from '@ng-select/ng-select';
|
||||||
import { UntilDestroy } from '@ngneat/until-destroy';
|
import { UntilDestroy } from '@ngneat/until-destroy';
|
||||||
import { catchError, concat, distinctUntilChanged, Observable, of, Subject, switchMap, tap } from 'rxjs';
|
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 { BusinessListingCriteria, CityAndStateResult, CommercialPropertyListingCriteria, GeoResult, KeycloakUser, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
|
||||||
import { FaqComponent, FAQItem } from '../../components/faq/faq.component';
|
import { FaqComponent, FAQItem } from '../../components/faq/faq.component';
|
||||||
import { ModalService } from '../../components/search-modal/modal.service';
|
import { ModalService } from '../../components/search-modal/modal.service';
|
||||||
import { TooltipComponent } from '../../components/tooltip/tooltip.component';
|
import { TooltipComponent } from '../../components/tooltip/tooltip.component';
|
||||||
import { AiService } from '../../services/ai.service';
|
import { AiService } from '../../services/ai.service';
|
||||||
import { AuthService } from '../../services/auth.service';
|
import { AuthService } from '../../services/auth.service';
|
||||||
import { FilterStateService } from '../../services/filter-state.service';
|
import { FilterStateService } from '../../services/filter-state.service';
|
||||||
import { GeoService } from '../../services/geo.service';
|
import { GeoService } from '../../services/geo.service';
|
||||||
import { ListingsService } from '../../services/listings.service';
|
import { ListingsService } from '../../services/listings.service';
|
||||||
import { SearchService } from '../../services/search.service';
|
import { SearchService } from '../../services/search.service';
|
||||||
import { SelectOptionsService } from '../../services/select-options.service';
|
import { SelectOptionsService } from '../../services/select-options.service';
|
||||||
import { SeoService } from '../../services/seo.service';
|
import { SeoService } from '../../services/seo.service';
|
||||||
import { UserService } from '../../services/user.service';
|
import { UserService } from '../../services/user.service';
|
||||||
import { map2User } from '../../utils/utils';
|
import { map2User } from '../../utils/utils';
|
||||||
|
|
||||||
@UntilDestroy()
|
@UntilDestroy()
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-home',
|
selector: 'app-home',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, RouterModule, NgSelectModule, FaqComponent],
|
imports: [CommonModule, FormsModule, RouterModule, NgSelectModule, FaqComponent],
|
||||||
templateUrl: './home.component.html',
|
templateUrl: './home.component.html',
|
||||||
styleUrl: './home.component.scss',
|
styleUrl: './home.component.scss',
|
||||||
})
|
})
|
||||||
export class HomeComponent {
|
export class HomeComponent {
|
||||||
placeholders: string[] = ['Property close to Houston less than 10M', 'Franchise business in Austin price less than 500K'];
|
placeholders: string[] = ['Property close to Houston less than 10M', 'Franchise business in Austin price less than 500K'];
|
||||||
activeTabAction: 'business' | 'commercialProperty' | 'broker' = 'business';
|
activeTabAction: 'business' | 'commercialProperty' | 'broker' = 'business';
|
||||||
type: string;
|
type: string;
|
||||||
maxPrice: string;
|
maxPrice: string;
|
||||||
minPrice: string;
|
minPrice: string;
|
||||||
criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
|
criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
|
||||||
states = [];
|
states = [];
|
||||||
isMenuOpen = false;
|
isMenuOpen = false;
|
||||||
user: KeycloakUser;
|
user: KeycloakUser;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
cities$: Observable<CityAndStateResult[]>;
|
cities$: Observable<CityAndStateResult[]>;
|
||||||
cityLoading = false;
|
cityLoading = false;
|
||||||
cityInput$ = new Subject<string>();
|
cityInput$ = new Subject<string>();
|
||||||
cityOrState = undefined;
|
cityOrState = undefined;
|
||||||
numberOfResults$: Observable<number>;
|
numberOfResults$: Observable<number>;
|
||||||
numberOfBroker$: Observable<number>;
|
numberOfBroker$: Observable<number>;
|
||||||
numberOfCommercial$: Observable<number>;
|
numberOfCommercial$: Observable<number>;
|
||||||
aiSearch = false;
|
aiSearch = false;
|
||||||
aiSearchText = '';
|
aiSearchText = '';
|
||||||
aiSearchFailed = false;
|
aiSearchFailed = false;
|
||||||
loadingAi = false;
|
loadingAi = false;
|
||||||
@ViewChild('aiSearchInput', { static: false }) searchInput!: ElementRef;
|
@ViewChild('aiSearchInput', { static: false }) searchInput!: ElementRef;
|
||||||
typingSpeed: number = 100;
|
typingSpeed: number = 100;
|
||||||
pauseTime: number = 2000;
|
pauseTime: number = 2000;
|
||||||
index: number = 0;
|
index: number = 0;
|
||||||
charIndex: number = 0;
|
charIndex: number = 0;
|
||||||
typingInterval: any;
|
typingInterval: any;
|
||||||
showInput: boolean = true;
|
showInput: boolean = true;
|
||||||
tooltipTargetBeta = 'tooltipTargetBeta';
|
tooltipTargetBeta = 'tooltipTargetBeta';
|
||||||
|
|
||||||
// FAQ data optimized for AEO (Answer Engine Optimization) and Featured Snippets
|
// FAQ data optimized for AEO (Answer Engine Optimization) and Featured Snippets
|
||||||
faqItems: FAQItem[] = [
|
faqItems: FAQItem[] = [
|
||||||
{
|
{
|
||||||
question: 'How do I buy a business on BizMatch?',
|
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>'
|
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?',
|
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>'
|
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?',
|
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>'
|
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?',
|
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>'
|
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?',
|
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>'
|
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?',
|
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>'
|
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?',
|
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>'
|
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?',
|
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>'
|
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?',
|
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>'
|
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?',
|
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>'
|
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(
|
constructor(
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private modalService: ModalService,
|
private modalService: ModalService,
|
||||||
private searchService: SearchService,
|
private searchService: SearchService,
|
||||||
private activatedRoute: ActivatedRoute,
|
private activatedRoute: ActivatedRoute,
|
||||||
public selectOptions: SelectOptionsService,
|
public selectOptions: SelectOptionsService,
|
||||||
private geoService: GeoService,
|
private geoService: GeoService,
|
||||||
public cdRef: ChangeDetectorRef,
|
public cdRef: ChangeDetectorRef,
|
||||||
private listingService: ListingsService,
|
private listingService: ListingsService,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
private aiService: AiService,
|
private aiService: AiService,
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
private filterStateService: FilterStateService,
|
private filterStateService: FilterStateService,
|
||||||
private seoService: SeoService,
|
private seoService: SeoService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
// Flowbite is now initialized once in AppComponent
|
// Flowbite is now initialized once in AppComponent
|
||||||
|
|
||||||
// Set SEO meta tags for home page
|
// Set SEO meta tags for home page
|
||||||
this.seoService.updateMetaTags({
|
this.seoService.updateMetaTags({
|
||||||
title: 'BizMatch - Buy & Sell Businesses and Commercial Properties',
|
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.',
|
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',
|
keywords: 'business for sale, businesses for sale, buy business, sell business, commercial property, commercial real estate, franchise opportunities, business broker, business marketplace',
|
||||||
type: 'website'
|
type: 'website'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add Organization schema for brand identity and FAQ schema for AEO
|
// Add Organization schema for brand identity and FAQ schema for AEO
|
||||||
const organizationSchema = this.seoService.generateOrganizationSchema();
|
const organizationSchema = this.seoService.generateOrganizationSchema();
|
||||||
const faqSchema = this.seoService.generateFAQPageSchema(
|
const faqSchema = this.seoService.generateFAQPageSchema(
|
||||||
this.faqItems.map(item => ({
|
this.faqItems.map(item => ({
|
||||||
question: item.question,
|
question: item.question,
|
||||||
answer: item.answer
|
answer: item.answer
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add HowTo schema for buying a business
|
// Add HowTo schema for buying a business
|
||||||
const howToSchema = this.seoService.generateHowToSchema({
|
const howToSchema = this.seoService.generateHowToSchema({
|
||||||
name: 'How to Buy a Business on BizMatch',
|
name: 'How to Buy a Business on BizMatch',
|
||||||
description: 'Step-by-step guide to finding and purchasing your ideal business through BizMatch marketplace',
|
description: 'Step-by-step guide to finding and purchasing your ideal business through BizMatch marketplace',
|
||||||
totalTime: 'PT45M',
|
totalTime: 'PT45M',
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
name: 'Browse Business Listings',
|
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.'
|
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',
|
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.'
|
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',
|
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.'
|
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',
|
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.'
|
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',
|
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.'
|
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',
|
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.'
|
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
|
// Add SearchBox schema for Sitelinks Search
|
||||||
const searchBoxSchema = this.seoService.generateSearchBoxSchema();
|
const searchBoxSchema = this.seoService.generateSearchBoxSchema();
|
||||||
|
|
||||||
this.seoService.injectMultipleSchemas([organizationSchema, faqSchema, howToSchema, searchBoxSchema]);
|
this.seoService.injectMultipleSchemas([organizationSchema, faqSchema, howToSchema, searchBoxSchema]);
|
||||||
|
|
||||||
// Clear all filters and sort options on initial load
|
// Clear all filters and sort options on initial load
|
||||||
this.filterStateService.resetCriteria('businessListings');
|
this.filterStateService.resetCriteria('businessListings');
|
||||||
this.filterStateService.resetCriteria('commercialPropertyListings');
|
this.filterStateService.resetCriteria('commercialPropertyListings');
|
||||||
this.filterStateService.resetCriteria('brokerListings');
|
this.filterStateService.resetCriteria('brokerListings');
|
||||||
this.filterStateService.updateSortBy('businessListings', null);
|
this.filterStateService.updateSortBy('businessListings', null);
|
||||||
this.filterStateService.updateSortBy('commercialPropertyListings', null);
|
this.filterStateService.updateSortBy('commercialPropertyListings', null);
|
||||||
this.filterStateService.updateSortBy('brokerListings', null);
|
this.filterStateService.updateSortBy('brokerListings', null);
|
||||||
|
|
||||||
// Initialize criteria for the default tab
|
// Initialize criteria for the default tab
|
||||||
this.criteria = this.filterStateService.getCriteria('businessListings');
|
this.criteria = this.filterStateService.getCriteria('businessListings');
|
||||||
|
|
||||||
this.numberOfBroker$ = this.userService.getNumberOfBroker(this.filterStateService.getCriteria('brokerListings') as UserListingCriteria);
|
this.numberOfBroker$ = this.userService.getNumberOfBroker(this.filterStateService.getCriteria('brokerListings') as UserListingCriteria);
|
||||||
this.numberOfCommercial$ = this.listingService.getNumberOfListings('commercialProperty');
|
this.numberOfCommercial$ = this.listingService.getNumberOfListings('commercialProperty');
|
||||||
const token = await this.authService.getToken();
|
const token = await this.authService.getToken();
|
||||||
this.user = map2User(token);
|
this.user = map2User(token);
|
||||||
this.loadCities();
|
this.loadCities();
|
||||||
this.setTotalNumberOfResults();
|
this.setTotalNumberOfResults();
|
||||||
}
|
}
|
||||||
|
|
||||||
changeTab(tabname: 'business' | 'commercialProperty' | 'broker') {
|
changeTab(tabname: 'business' | 'commercialProperty' | 'broker') {
|
||||||
this.activeTabAction = tabname;
|
this.activeTabAction = tabname;
|
||||||
this.cityOrState = null;
|
this.cityOrState = null;
|
||||||
const tabToListingType = {
|
const tabToListingType = {
|
||||||
business: 'businessListings',
|
business: 'businessListings',
|
||||||
commercialProperty: 'commercialPropertyListings',
|
commercialProperty: 'commercialPropertyListings',
|
||||||
broker: 'brokerListings',
|
broker: 'brokerListings',
|
||||||
};
|
};
|
||||||
this.criteria = this.filterStateService.getCriteria(tabToListingType[tabname] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings');
|
this.criteria = this.filterStateService.getCriteria(tabToListingType[tabname] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings');
|
||||||
this.setTotalNumberOfResults();
|
this.setTotalNumberOfResults();
|
||||||
}
|
}
|
||||||
|
|
||||||
search() {
|
search() {
|
||||||
this.router.navigate([`${this.activeTabAction}Listings`]);
|
this.router.navigate([`${this.activeTabAction}Listings`]);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleMenu() {
|
toggleMenu() {
|
||||||
this.isMenuOpen = !this.isMenuOpen;
|
this.isMenuOpen = !this.isMenuOpen;
|
||||||
}
|
}
|
||||||
|
|
||||||
onTypesChange(value) {
|
onTypesChange(value) {
|
||||||
const tabToListingType = {
|
const tabToListingType = {
|
||||||
business: 'businessListings',
|
business: 'businessListings',
|
||||||
commercialProperty: 'commercialPropertyListings',
|
commercialProperty: 'commercialPropertyListings',
|
||||||
broker: 'brokerListings',
|
broker: 'brokerListings',
|
||||||
};
|
};
|
||||||
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
|
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
|
||||||
this.filterStateService.updateCriteria(listingType, { types: value === '' ? [] : [value] });
|
this.filterStateService.updateCriteria(listingType, { types: value === '' ? [] : [value] });
|
||||||
this.criteria = this.filterStateService.getCriteria(listingType);
|
this.criteria = this.filterStateService.getCriteria(listingType);
|
||||||
this.setTotalNumberOfResults();
|
this.setTotalNumberOfResults();
|
||||||
}
|
}
|
||||||
|
|
||||||
onRadiusChange(value) {
|
onRadiusChange(value) {
|
||||||
const tabToListingType = {
|
const tabToListingType = {
|
||||||
business: 'businessListings',
|
business: 'businessListings',
|
||||||
commercialProperty: 'commercialPropertyListings',
|
commercialProperty: 'commercialPropertyListings',
|
||||||
broker: 'brokerListings',
|
broker: 'brokerListings',
|
||||||
};
|
};
|
||||||
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
|
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
|
||||||
this.filterStateService.updateCriteria(listingType, { radius: value === 'null' ? null : parseInt(value) });
|
this.filterStateService.updateCriteria(listingType, { radius: value === 'null' ? null : parseInt(value) });
|
||||||
this.criteria = this.filterStateService.getCriteria(listingType);
|
this.criteria = this.filterStateService.getCriteria(listingType);
|
||||||
this.setTotalNumberOfResults();
|
this.setTotalNumberOfResults();
|
||||||
}
|
}
|
||||||
|
|
||||||
async openModal() {
|
async openModal() {
|
||||||
const tabToListingType = {
|
const tabToListingType = {
|
||||||
business: 'businessListings',
|
business: 'businessListings',
|
||||||
commercialProperty: 'commercialPropertyListings',
|
commercialProperty: 'commercialPropertyListings',
|
||||||
broker: 'brokerListings',
|
broker: 'brokerListings',
|
||||||
};
|
};
|
||||||
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
|
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
|
||||||
const accepted = await this.modalService.showModal(this.criteria);
|
const accepted = await this.modalService.showModal(this.criteria);
|
||||||
if (accepted) {
|
if (accepted) {
|
||||||
this.router.navigate([`${this.activeTabAction}Listings`]);
|
this.router.navigate([`${this.activeTabAction}Listings`]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadCities() {
|
private loadCities() {
|
||||||
this.cities$ = concat(
|
this.cities$ = concat(
|
||||||
of([]),
|
of([]),
|
||||||
this.cityInput$.pipe(
|
this.cityInput$.pipe(
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
tap(() => (this.cityLoading = true)),
|
tap(() => (this.cityLoading = true)),
|
||||||
switchMap(term =>
|
switchMap(term =>
|
||||||
this.geoService.findCitiesAndStatesStartingWith(term).pipe(
|
this.geoService.findCitiesAndStatesStartingWith(term).pipe(
|
||||||
catchError(() => of([])),
|
catchError(() => of([])),
|
||||||
tap(() => (this.cityLoading = false)),
|
tap(() => (this.cityLoading = false)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
trackByFn(item: GeoResult) {
|
trackByFn(item: GeoResult) {
|
||||||
return item.id;
|
return item.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCityOrState(cityOrState: CityAndStateResult) {
|
setCityOrState(cityOrState: CityAndStateResult) {
|
||||||
const tabToListingType = {
|
const tabToListingType = {
|
||||||
business: 'businessListings',
|
business: 'businessListings',
|
||||||
commercialProperty: 'commercialPropertyListings',
|
commercialProperty: 'commercialPropertyListings',
|
||||||
broker: 'brokerListings',
|
broker: 'brokerListings',
|
||||||
};
|
};
|
||||||
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
|
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
|
||||||
|
|
||||||
if (cityOrState) {
|
if (cityOrState) {
|
||||||
if (cityOrState.type === 'state') {
|
if (cityOrState.type === 'state') {
|
||||||
this.filterStateService.updateCriteria(listingType, { state: cityOrState.content.state_code, city: null, radius: null, searchType: 'exact' });
|
this.filterStateService.updateCriteria(listingType, { state: cityOrState.content.state_code, city: null, radius: null, searchType: 'exact' });
|
||||||
} else {
|
} else {
|
||||||
this.filterStateService.updateCriteria(listingType, {
|
this.filterStateService.updateCriteria(listingType, {
|
||||||
city: cityOrState.content as GeoResult,
|
city: cityOrState.content as GeoResult,
|
||||||
state: cityOrState.content.state,
|
state: cityOrState.content.state,
|
||||||
searchType: 'radius',
|
searchType: 'radius',
|
||||||
radius: 20,
|
radius: 20,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.filterStateService.updateCriteria(listingType, { state: null, city: null, radius: null, searchType: 'exact' });
|
this.filterStateService.updateCriteria(listingType, { state: null, city: null, radius: null, searchType: 'exact' });
|
||||||
}
|
}
|
||||||
this.criteria = this.filterStateService.getCriteria(listingType);
|
this.criteria = this.filterStateService.getCriteria(listingType);
|
||||||
this.setTotalNumberOfResults();
|
this.setTotalNumberOfResults();
|
||||||
}
|
}
|
||||||
|
|
||||||
getTypes() {
|
getTypes() {
|
||||||
if (this.criteria.criteriaType === 'businessListings') {
|
if (this.criteria.criteriaType === 'businessListings') {
|
||||||
return this.selectOptions.typesOfBusiness;
|
return this.selectOptions.typesOfBusiness;
|
||||||
} else if (this.criteria.criteriaType === 'commercialPropertyListings') {
|
} else if (this.criteria.criteriaType === 'commercialPropertyListings') {
|
||||||
return this.selectOptions.typesOfCommercialProperty;
|
return this.selectOptions.typesOfCommercialProperty;
|
||||||
} else {
|
} else {
|
||||||
return this.selectOptions.customerSubTypes;
|
return this.selectOptions.customerSubTypes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getPlaceholderLabel() {
|
getPlaceholderLabel() {
|
||||||
if (this.criteria.criteriaType === 'businessListings') {
|
if (this.criteria.criteriaType === 'businessListings') {
|
||||||
return 'Business Type';
|
return 'Business Type';
|
||||||
} else if (this.criteria.criteriaType === 'commercialPropertyListings') {
|
} else if (this.criteria.criteriaType === 'commercialPropertyListings') {
|
||||||
return 'Property Type';
|
return 'Property Type';
|
||||||
} else {
|
} else {
|
||||||
return 'Professional Type';
|
return 'Professional Type';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setTotalNumberOfResults() {
|
setTotalNumberOfResults() {
|
||||||
if (this.criteria) {
|
if (this.criteria) {
|
||||||
console.log(`Getting total number of results for ${this.criteria.criteriaType}`);
|
console.log(`Getting total number of results for ${this.criteria.criteriaType}`);
|
||||||
const tabToListingType = {
|
const tabToListingType = {
|
||||||
business: 'businessListings',
|
business: 'businessListings',
|
||||||
commercialProperty: 'commercialPropertyListings',
|
commercialProperty: 'commercialPropertyListings',
|
||||||
broker: 'brokerListings',
|
broker: 'brokerListings',
|
||||||
};
|
};
|
||||||
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
|
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
|
||||||
|
|
||||||
if (this.criteria.criteriaType === 'businessListings' || this.criteria.criteriaType === 'commercialPropertyListings') {
|
if (this.criteria.criteriaType === 'businessListings' || this.criteria.criteriaType === 'commercialPropertyListings') {
|
||||||
this.numberOfResults$ = this.listingService.getNumberOfListings(this.criteria.criteriaType === 'businessListings' ? 'business' : 'commercialProperty');
|
this.numberOfResults$ = this.listingService.getNumberOfListings(this.criteria.criteriaType === 'businessListings' ? 'business' : 'commercialProperty');
|
||||||
} else if (this.criteria.criteriaType === 'brokerListings') {
|
} else if (this.criteria.criteriaType === 'brokerListings') {
|
||||||
this.numberOfResults$ = this.userService.getNumberOfBroker(this.filterStateService.getCriteria('brokerListings') as UserListingCriteria);
|
this.numberOfResults$ = this.userService.getNumberOfBroker(this.filterStateService.getCriteria('brokerListings') as UserListingCriteria);
|
||||||
} else {
|
} else {
|
||||||
this.numberOfResults$ = of();
|
this.numberOfResults$ = of();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
clearTimeout(this.typingInterval);
|
clearTimeout(this.typingInterval);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,201 +1,201 @@
|
||||||
<div class="container mx-auto px-4 py-8 max-w-4xl">
|
<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">
|
<div class="bg-white rounded-lg drop-shadow-custom-bg p-6 md:p-8 relative">
|
||||||
<button
|
<button
|
||||||
(click)="goBack()"
|
(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"
|
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"
|
aria-label="Go back"
|
||||||
>
|
>
|
||||||
<i class="fas fa-arrow-left text-lg"></i>
|
<i class="fas fa-arrow-left text-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
<h1 class="text-3xl font-bold text-neutral-900 mb-6 pr-14">Privacy Statement</h1>
|
<h1 class="text-3xl font-bold text-neutral-900 mb-6 pr-14">Privacy Statement</h1>
|
||||||
|
|
||||||
<section id="content" role="main">
|
<section id="content" role="main">
|
||||||
<article class="post page">
|
<article class="post page">
|
||||||
<section class="entry-content">
|
<section class="entry-content">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<p class="font-bold mb-4">Privacy Policy</p>
|
<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">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">This Privacy Policy relates to the use of any personal information you provide to us through this websites.</p>
|
||||||
<p class="mb-4">
|
<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
|
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.
|
Policy.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-4">
|
<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
|
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.
|
continuing to deal with us, you accept this Privacy Policy.
|
||||||
</p>
|
</p>
|
||||||
<p class="font-bold mb-4 mt-6">Collection of personal information</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">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">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">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">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">We may collect and store the following personal information:</p>
|
||||||
<p class="mb-4">
|
<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 />
|
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
|
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 />
|
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,
|
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 />
|
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;
|
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 />
|
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).
|
we may ask you to send us additional information, or to answer additional questions online to help verify your information).
|
||||||
</p>
|
</p>
|
||||||
<p class="font-bold mb-4 mt-6">How we use your information</p>
|
<p class="font-bold mb-4 mt-6">How we use your information</p>
|
||||||
<p class="mb-4">
|
<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
|
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 />
|
your personal information to:<br />
|
||||||
provide the services and customer support you request;<br />
|
provide the services and customer support you request;<br />
|
||||||
connect you with relevant parties:<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
|
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 />
|
business;<br />
|
||||||
If you are a seller / intermediary, we will disclose your details where you have given us permission to do so;<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 />
|
resolve disputes, collect fees, and troubleshoot problems;<br />
|
||||||
prevent potentially prohibited or illegal activities, and enforce our Terms and Conditions;<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 />
|
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.
|
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>
|
||||||
<p class="font-bold mb-4 mt-6">Our disclosure of your information</p>
|
<p class="font-bold mb-4 mt-6">Our disclosure of your information</p>
|
||||||
<p class="mb-4">
|
<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,
|
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.
|
property, or safety.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-4">
|
<p class="mb-4">
|
||||||
We may also share your personal information with<br />
|
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.
|
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>
|
||||||
<p class="mb-4">
|
<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
|
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
|
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.
|
be publicly displayed unless you specifically include it on your profile.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-4">
|
<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
|
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
|
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.
|
on the site.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-4">
|
<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
|
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.
|
testimonials on the site. We are not responsible for any personal information a user selects to include within their testimonial.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-4">
|
<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
|
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.
|
their promotional purposes and only store the information to gauge the effectiveness of our referral program.
|
||||||
</p>
|
</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 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">
|
<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
|
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.
|
information submitted here is governed by their privacy policy.
|
||||||
</p>
|
</p>
|
||||||
<p class="font-bold mb-4 mt-6">Masking Policy</p>
|
<p class="font-bold mb-4 mt-6">Masking Policy</p>
|
||||||
<p class="mb-4">
|
<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
|
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.
|
however the data collected on such pages is governed by the third party agent's privacy policy.
|
||||||
</p>
|
</p>
|
||||||
<p class="font-bold mb-4 mt-6">Legal Disclosure</p>
|
<p class="font-bold mb-4 mt-6">Legal Disclosure</p>
|
||||||
<p class="mb-4">
|
<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
|
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
|
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.
|
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>
|
||||||
<p class="mb-4">
|
<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
|
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
|
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.
|
Site, or by email.
|
||||||
</p>
|
</p>
|
||||||
<p class="font-bold mb-4 mt-6">Using information from BizMatch.net website</p>
|
<p class="font-bold mb-4 mt-6">Using information from BizMatch.net website</p>
|
||||||
<p class="mb-4">
|
<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
|
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.
|
users a chance to remove themselves from your database and a chance to review what information you have collected about them.
|
||||||
</p>
|
</p>
|
||||||
<p class="font-bold mb-4 mt-6">You agree to use BizMatch.net user information only for:</p>
|
<p class="font-bold mb-4 mt-6">You agree to use BizMatch.net user information only for:</p>
|
||||||
<p class="mb-4">
|
<p class="mb-4">
|
||||||
BizMatch.net transaction-related purposes that are not unsolicited commercial messages;<br />
|
BizMatch.net transaction-related purposes that are not unsolicited commercial messages;<br />
|
||||||
using services offered through BizMatch.net, or<br />
|
using services offered through BizMatch.net, or<br />
|
||||||
other purposes that a user expressly chooses.
|
other purposes that a user expressly chooses.
|
||||||
</p>
|
</p>
|
||||||
<p class="font-bold mb-4 mt-6">Marketing</p>
|
<p class="font-bold mb-4 mt-6">Marketing</p>
|
||||||
<p class="mb-4">
|
<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
|
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.
|
offers from third party partners or affiliates, we will communicate to you on their behalf. We will not pass your information on.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-4">
|
<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 /
|
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.
|
or change your preferences at any time by following instructions included within a communication or emailing Customer Services.
|
||||||
</p>
|
</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">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">
|
<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
|
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.
|
in nature, you will be unable to opt-out of them.
|
||||||
</p>
|
</p>
|
||||||
<p class="font-bold mb-4 mt-6">Cookies</p>
|
<p class="font-bold mb-4 mt-6">Cookies</p>
|
||||||
<p class="mb-4">
|
<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
|
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.
|
website, the cookie simply terminates. We use cookies so that we can personalise your experience of our websites.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-4">
|
<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
|
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.
|
site (for example, advertisers). We have no access to or control over these cookies.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-4">For more information about how BizMatch.net uses cookies please read our Cookie Policy.</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="font-bold mb-4 mt-6">Spam, spyware or spoofing</p>
|
||||||
<p class="mb-4">
|
<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
|
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.
|
contact us using the contact information provided in the Contact Us section of this privacy statement.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-4">
|
<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,
|
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.
|
phishing attacks and other malicious activity or illegal or prohibited content. We may also store these messages for back up purposes only.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-4">
|
<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
|
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.
|
addresses.
|
||||||
</p>
|
</p>
|
||||||
<p class="font-bold mb-4 mt-6">Account protection</p>
|
<p class="font-bold mb-4 mt-6">Account protection</p>
|
||||||
<p class="mb-4">
|
<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 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
|
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
|
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.
|
password.
|
||||||
</p>
|
</p>
|
||||||
<p class="font-bold mb-4 mt-6">Accessing, reviewing and changing your personal information</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">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">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">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">
|
<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
|
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.
|
Conditions, and take other actions otherwise permitted by law.
|
||||||
</p>
|
</p>
|
||||||
<p class="font-bold mb-4 mt-6">Security</p>
|
<p class="font-bold mb-4 mt-6">Security</p>
|
||||||
<p class="mb-4">
|
<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
|
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
|
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.
|
its absolute security.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-4">We employ the use of SSL encryption during the transmission of sensitive data across our websites.</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="font-bold mb-4 mt-6">Third parties</p>
|
||||||
<p class="mb-4">
|
<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
|
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
|
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.
|
policies of third parties, and you are subject to the privacy policies of those third parties where applicable.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-4">We encourage you to ask questions before you disclose your personal information to others.</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="font-bold mb-4 mt-6">General</p>
|
||||||
<p class="mb-4">
|
<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
|
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.
|
Policy was last revised by referring to the "Last Updated" legend at the top of this page.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-4">
|
<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
|
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.
|
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>
|
||||||
<p class="font-bold mb-4 mt-6">Contact Us</p>
|
<p class="font-bold mb-4 mt-6">Contact Us</p>
|
||||||
<p class="mb-4">
|
<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@bizmatch.net, or write
|
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@bizmatch.net, or write
|
||||||
to us at BizMatch, 715 S. Tanahua, Corpus Christi, TX 78401.)
|
to us at BizMatch, 715 S. Tanahua, Corpus Christi, TX 78401.)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
// Privacy Statement component styles
|
// Privacy Statement component styles
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,30 @@
|
||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
import { CommonModule, Location } from '@angular/common';
|
import { CommonModule, Location } from '@angular/common';
|
||||||
import { SeoService } from '../../services/seo.service';
|
import { SeoService } from '../../services/seo.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-privacy-statement',
|
selector: 'app-privacy-statement',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
templateUrl: './privacy-statement.component.html',
|
templateUrl: './privacy-statement.component.html',
|
||||||
styleUrls: ['./privacy-statement.component.scss']
|
styleUrls: ['./privacy-statement.component.scss']
|
||||||
})
|
})
|
||||||
export class PrivacyStatementComponent implements OnInit {
|
export class PrivacyStatementComponent implements OnInit {
|
||||||
constructor(
|
constructor(
|
||||||
private seoService: SeoService,
|
private seoService: SeoService,
|
||||||
private location: Location
|
private location: Location
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
// Set SEO meta tags for Privacy Statement page
|
// Set SEO meta tags for Privacy Statement page
|
||||||
this.seoService.updateMetaTags({
|
this.seoService.updateMetaTags({
|
||||||
title: 'Privacy Statement - BizMatch',
|
title: 'Privacy Statement - BizMatch',
|
||||||
description: 'Learn how BizMatch collects, uses, and protects your personal information. Read our privacy policy and data protection practices.',
|
description: 'Learn how BizMatch collects, uses, and protects your personal information. Read our privacy policy and data protection practices.',
|
||||||
type: 'website'
|
type: 'website'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
goBack(): void {
|
goBack(): void {
|
||||||
this.location.back();
|
this.location.back();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,150 +1,150 @@
|
||||||
<div class="container mx-auto px-4 py-8 max-w-4xl">
|
<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">
|
<div class="bg-white rounded-lg drop-shadow-custom-bg p-6 md:p-8 relative">
|
||||||
<button
|
<button
|
||||||
(click)="goBack()"
|
(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"
|
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"
|
aria-label="Go back"
|
||||||
>
|
>
|
||||||
<i class="fas fa-arrow-left text-lg"></i>
|
<i class="fas fa-arrow-left text-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
<h1 class="text-3xl font-bold text-neutral-900 mb-6 pr-14">Terms of Use</h1>
|
<h1 class="text-3xl font-bold text-neutral-900 mb-6 pr-14">Terms of Use</h1>
|
||||||
|
|
||||||
<section id="content" role="main">
|
<section id="content" role="main">
|
||||||
<article class="post page">
|
<article class="post page">
|
||||||
<section class="entry-content">
|
<section class="entry-content">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<p class="font-bold text-lg mb-4">AGREEMENT BETWEEN USER AND BizMatch</p>
|
<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 comprised of various Web pages operated by BizMatch.</p>
|
||||||
<p class="mb-4">
|
<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
|
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.
|
agreement to all such terms, conditions, and notices.
|
||||||
</p>
|
</p>
|
||||||
<p class="font-bold text-lg mb-4 mt-6">MODIFICATION OF THESE TERMS OF USE</p>
|
<p class="font-bold text-lg mb-4 mt-6">MODIFICATION OF THESE TERMS OF USE</p>
|
||||||
<p class="mb-4">
|
<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
|
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.
|
Site.
|
||||||
</p>
|
</p>
|
||||||
<p class="font-bold text-lg mb-4 mt-6">LINKS TO THIRD PARTY SITES</p>
|
<p class="font-bold text-lg mb-4 mt-6">LINKS TO THIRD PARTY SITES</p>
|
||||||
<p class="mb-4">
|
<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,
|
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
|
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.
|
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>
|
||||||
<p class="font-bold text-lg mb-4 mt-6">NO UNLAWFUL OR PROHIBITED USE</p>
|
<p class="font-bold text-lg mb-4 mt-6">NO UNLAWFUL OR PROHIBITED USE</p>
|
||||||
<p class="mb-4">
|
<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
|
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
|
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.
|
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>
|
||||||
<p class="font-bold text-lg mb-4 mt-6">USE OF COMMUNICATION SERVICES</p>
|
<p class="font-bold text-lg mb-4 mt-6">USE OF COMMUNICATION SERVICES</p>
|
||||||
<p class="mb-4">
|
<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
|
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
|
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:
|
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>
|
</p>
|
||||||
<ul class="list-disc pl-6 mb-4 space-y-2">
|
<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>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>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 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>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>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>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>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>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>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>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>Harvest or otherwise collect information about others, including e-mail addresses, without their consent.</li>
|
||||||
<li>Violate any applicable laws or regulations.</li>
|
<li>Violate any applicable laws or regulations.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="mb-4">
|
<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
|
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.
|
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>
|
||||||
<p class="mb-4">
|
<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
|
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.
|
any information or materials, in whole or in part, in BizMatch's sole discretion.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-4">
|
<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
|
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
|
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.
|
any Communication Service. Managers and hosts are not authorized BizMatch spokespersons, and their views do not necessarily reflect those of BizMatch.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-4">
|
<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 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.
|
materials.
|
||||||
</p>
|
</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="font-bold text-lg mb-4 mt-6">MATERIALS PROVIDED TO BizMatch OR POSTED AT ANY BizMatch WEB SITE</p>
|
||||||
<p class="mb-4">
|
<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
|
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
|
(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,
|
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.
|
reproduce, edit, translate and reformat your Submission; and to publish your name in connection with your Submission.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-4">
|
<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
|
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.
|
any time in BizMatch's sole discretion.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-4">
|
<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
|
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.
|
including, without limitation, all the rights necessary for you to provide, post, upload, input or submit the Submissions.
|
||||||
</p>
|
</p>
|
||||||
<p class="font-bold text-lg mb-4 mt-6">LIABILITY DISCLAIMER</p>
|
<p class="font-bold text-lg mb-4 mt-6">LIABILITY DISCLAIMER</p>
|
||||||
<p class="mb-4">
|
<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
|
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
|
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.
|
PERSONAL, MEDICAL, LEGAL OR FINANCIAL DECISIONS AND YOU SHOULD CONSULT AN APPROPRIATE PROFESSIONAL FOR SPECIFIC ADVICE TAILORED TO YOUR SITUATION.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-4">
|
<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
|
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
|
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
|
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.
|
ALL IMPLIED WARRANTIES OR CONDITIONS OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-4">
|
<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
|
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
|
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
|
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
|
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
|
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.
|
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>
|
||||||
<p class="mb-4">SERVICE CONTACT : info@bizmatch.net</p>
|
<p class="mb-4">SERVICE CONTACT : info@bizmatch.net</p>
|
||||||
<p class="font-bold text-lg mb-4 mt-6">TERMINATION/ACCESS RESTRICTION</p>
|
<p class="font-bold text-lg mb-4 mt-6">TERMINATION/ACCESS RESTRICTION</p>
|
||||||
<p class="mb-4">
|
<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
|
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,
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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.
|
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>
|
||||||
<p class="font-bold text-lg mb-4 mt-6">COPYRIGHT AND TRADEMARK NOTICES:</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="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="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 names of actual companies and products mentioned herein may be the trademarks of their respective owners.</p>
|
||||||
<p class="mb-4">
|
<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
|
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.
|
inferred.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-4">Any rights not expressly granted herein are reserved.</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="font-bold text-lg mb-4 mt-6">NOTICES AND PROCEDURE FOR MAKING CLAIMS OF COPYRIGHT INFRINGEMENT</p>
|
||||||
<p class="mb-4">
|
<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
|
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.
|
INQUIRIES NOT RELEVANT TO THE FOLLOWING PROCEDURE WILL RECEIVE NO RESPONSE. See Notice and Procedure for Making Claims of Copyright Infringement.
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-4">
|
<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
|
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.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
// Terms of Use component styles
|
// Terms of Use component styles
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,30 @@
|
||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
import { CommonModule, Location } from '@angular/common';
|
import { CommonModule, Location } from '@angular/common';
|
||||||
import { SeoService } from '../../services/seo.service';
|
import { SeoService } from '../../services/seo.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-terms-of-use',
|
selector: 'app-terms-of-use',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
templateUrl: './terms-of-use.component.html',
|
templateUrl: './terms-of-use.component.html',
|
||||||
styleUrls: ['./terms-of-use.component.scss']
|
styleUrls: ['./terms-of-use.component.scss']
|
||||||
})
|
})
|
||||||
export class TermsOfUseComponent implements OnInit {
|
export class TermsOfUseComponent implements OnInit {
|
||||||
constructor(
|
constructor(
|
||||||
private seoService: SeoService,
|
private seoService: SeoService,
|
||||||
private location: Location
|
private location: Location
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
// Set SEO meta tags for Terms of Use page
|
// Set SEO meta tags for Terms of Use page
|
||||||
this.seoService.updateMetaTags({
|
this.seoService.updateMetaTags({
|
||||||
title: 'Terms of Use - BizMatch',
|
title: 'Terms of Use - BizMatch',
|
||||||
description: 'Read the terms and conditions for using BizMatch marketplace. Learn about user responsibilities, listing guidelines, and platform rules.',
|
description: 'Read the terms and conditions for using BizMatch marketplace. Learn about user responsibilities, listing guidelines, and platform rules.',
|
||||||
type: 'website'
|
type: 'website'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
goBack(): void {
|
goBack(): void {
|
||||||
this.location.back();
|
this.location.back();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,159 +1,159 @@
|
||||||
<div class="flex flex-col md:flex-row">
|
<div class="flex flex-col md:flex-row">
|
||||||
<!-- Filter Panel for Desktop -->
|
<!-- 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">
|
<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>
|
<app-search-modal-broker [isModal]="false"></app-search-modal-broker>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="w-full p-4">
|
<div class="w-full p-4">
|
||||||
<div class="container mx-auto">
|
<div class="container mx-auto">
|
||||||
<!-- Breadcrumbs -->
|
<!-- Breadcrumbs -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
|
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SEO-optimized heading -->
|
<!-- SEO-optimized heading -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h1 class="text-3xl md:text-4xl font-bold text-neutral-900 mb-2">Professional Business Brokers & Advisors</h1>
|
<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
|
<p class="text-lg text-neutral-600">Connect with licensed business brokers, CPAs, attorneys, and other
|
||||||
professionals across the United States.</p>
|
professionals across the United States.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile Filter Button -->
|
<!-- Mobile Filter Button -->
|
||||||
<div class="md:hidden mb-4">
|
<div class="md:hidden mb-4">
|
||||||
<button (click)="openFilterModal()"
|
<button (click)="openFilterModal()"
|
||||||
class="w-full bg-primary-600 text-white py-3 px-4 rounded-lg flex items-center justify-center">
|
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>
|
<i class="fas fa-filter mr-2"></i>
|
||||||
Filter Results
|
Filter Results
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if(users?.length>0){
|
@if(users?.length>0){
|
||||||
<h2 class="text-2xl font-semibold text-neutral-800 mb-4">Professional Listings</h2>
|
<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">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<!-- Professional Cards -->
|
<!-- Professional Cards -->
|
||||||
@for (user of users; track user) {
|
@for (user of users; track user) {
|
||||||
<div
|
<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">
|
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 -->
|
<!-- Quick Actions Overlay -->
|
||||||
<div
|
<div
|
||||||
class="absolute top-4 right-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-20">
|
class="absolute top-4 right-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-20">
|
||||||
@if(currentUser) {
|
@if(currentUser) {
|
||||||
<button type="button" class="bg-white rounded-full p-2 shadow-lg transition-colors"
|
<button type="button" class="bg-white rounded-full p-2 shadow-lg transition-colors"
|
||||||
[class.bg-red-50]="isFavorite(user)"
|
[class.bg-red-50]="isFavorite(user)"
|
||||||
[title]="isFavorite(user) ? 'Remove from favorites' : 'Save to favorites'"
|
[title]="isFavorite(user) ? 'Remove from favorites' : 'Save to favorites'"
|
||||||
(click)="toggleFavorite($event, user)">
|
(click)="toggleFavorite($event, user)">
|
||||||
<i
|
<i
|
||||||
[class]="isFavorite(user) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i>
|
[class]="isFavorite(user) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<button type="button" class="bg-white rounded-full p-2 shadow-lg hover:bg-blue-50 transition-colors"
|
<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)">
|
title="Share professional" (click)="shareProfessional($event, user)">
|
||||||
<i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i>
|
<i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start space-x-4">
|
<div class="flex items-start space-x-4">
|
||||||
@if(user.hasProfile){
|
@if(user.hasProfile){
|
||||||
<img src="{{ env.imageBaseUrl }}/pictures/profile/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}"
|
<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"
|
[alt]="altText.generateBrokerProfileAlt(user)" class="rounded-md w-20 h-26 object-cover" width="80"
|
||||||
height="104" />
|
height="104" />
|
||||||
} @else {
|
} @else {
|
||||||
<img src="/assets/images/person_placeholder.jpg" alt="Default business broker placeholder profile photo"
|
<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" />
|
class="rounded-md w-20 h-26 object-cover" width="80" height="104" />
|
||||||
}
|
}
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<p class="text-sm text-neutral-800 mb-2">{{ user.description }}</p>
|
<p class="text-sm text-neutral-800 mb-2">{{ user.description }}</p>
|
||||||
<h3 class="text-lg font-semibold">
|
<h3 class="text-lg font-semibold">
|
||||||
{{ user.firstname }} {{ user.lastname }}<span
|
{{ user.firstname }} {{ user.lastname }}<span
|
||||||
class="bg-neutral-200 text-neutral-700 text-xs font-semibold px-2 py-1 rounded ml-4">{{
|
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>
|
user.location?.name }} - {{ user.location?.state }}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex items-center space-x-2 mt-2">
|
<div class="flex items-center space-x-2 mt-2">
|
||||||
<app-customer-sub-type [customerSubType]="user.customerSubType"></app-customer-sub-type>
|
<app-customer-sub-type [customerSubType]="user.customerSubType"></app-customer-sub-type>
|
||||||
<p class="text-sm text-neutral-600">{{ user.companyName }}</p>
|
<p class="text-sm text-neutral-600">{{ user.companyName }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between my-2"></div>
|
<div class="flex items-center justify-between my-2"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex justify-between items-center">
|
<div class="mt-4 flex justify-between items-center">
|
||||||
@if(user.hasCompanyLogo){
|
@if(user.hasCompanyLogo){
|
||||||
<img src="{{ env.imageBaseUrl }}/pictures/logo/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}"
|
<img src="{{ env.imageBaseUrl }}/pictures/logo/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}"
|
||||||
[alt]="altText.generateCompanyLogoAlt(user.companyName, user.firstname + ' ' + user.lastname)"
|
[alt]="altText.generateCompanyLogoAlt(user.companyName, user.firstname + ' ' + user.lastname)"
|
||||||
class="w-8 h-10 object-contain" width="32" height="40" />
|
class="w-8 h-10 object-contain" width="32" height="40" />
|
||||||
} @else {
|
} @else {
|
||||||
<img src="/assets/images/placeholder.png" alt="Default company logo placeholder"
|
<img src="/assets/images/placeholder.png" alt="Default company logo placeholder"
|
||||||
class="w-8 h-10 object-contain" width="32" height="40" />
|
class="w-8 h-10 object-contain" width="32" height="40" />
|
||||||
}
|
}
|
||||||
<button
|
<button
|
||||||
class="bg-success-500 hover:bg-success-600 text-white font-medium py-2 px-4 rounded-full flex items-center"
|
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]">
|
[routerLink]="['/details-user', user.id]">
|
||||||
View Full profile
|
View Full profile
|
||||||
<i class="fas fa-arrow-right ml-2"></i>
|
<i class="fas fa-arrow-right ml-2"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
} @else if (users?.length===0){
|
} @else if (users?.length===0){
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div class="w-full flex items-center flex-wrap justify-center gap-10 py-12">
|
<div class="w-full flex items-center flex-wrap justify-center gap-10 py-12">
|
||||||
<div class="grid gap-4 w-60">
|
<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"
|
<svg class="mx-auto" xmlns="http://www.w3.org/2000/svg" width="154" height="161" viewBox="0 0 154 161"
|
||||||
fill="none">
|
fill="none">
|
||||||
<path
|
<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"
|
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" />
|
fill="#EEF2FF" />
|
||||||
<path
|
<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"
|
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" />
|
fill="white" stroke="#E5E7EB" />
|
||||||
<ellipse cx="80.0618" cy="81" rx="28.0342" ry="28.0342" fill="#EEF2FF" />
|
<ellipse cx="80.0618" cy="81" rx="28.0342" ry="28.0342" fill="#EEF2FF" />
|
||||||
<path
|
<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"
|
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" />
|
stroke="#E5E7EB" />
|
||||||
<path
|
<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"
|
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" />
|
stroke="#E5E7EB" />
|
||||||
<path
|
<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"
|
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" />
|
fill="#A5B4FC" stroke="#818CF8" />
|
||||||
<path
|
<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"
|
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" />
|
fill="#4F46E5" />
|
||||||
<path
|
<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"
|
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" />
|
fill="#4F46E5" />
|
||||||
<path
|
<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"
|
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" />
|
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="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="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" />
|
<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="56.13" cy="31.0854" r="1.36752" fill="#4F46E5" />
|
||||||
<circle cx="61.6001" 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" />
|
<circle cx="67.0702" cy="31.0854" r="1.36752" fill="#4F46E5" />
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-center text-black text-xl font-semibold leading-loose pb-2">There're no professionals here
|
<h2 class="text-center text-black text-xl font-semibold leading-loose pb-2">There're no professionals here
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-center text-black text-base font-normal leading-relaxed pb-4">Try changing your filters to
|
<p class="text-center text-black text-base font-normal leading-relaxed pb-4">Try changing your filters to
|
||||||
<br />see professionals
|
<br />see professionals
|
||||||
</p>
|
</p>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button (click)="clearAllFilters()"
|
<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
|
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>
|
Filter</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
@if(pageCount>1){
|
@if(pageCount>1){
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator>
|
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,243 +1,243 @@
|
||||||
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
import { UntilDestroy } from '@ngneat/until-destroy';
|
import { UntilDestroy } from '@ngneat/until-destroy';
|
||||||
import { Subject, takeUntil } from 'rxjs';
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
import { BusinessListing, SortByOptions, User } from '../../../../../../bizmatch-server/src/models/db.model';
|
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 { LISTINGS_PER_PAGE, ListingType, UserListingCriteria, emailToDirName, KeycloakUser } from '../../../../../../bizmatch-server/src/models/main.model';
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
|
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
|
||||||
import { CustomerSubTypeComponent } from '../../../components/customer-sub-type/customer-sub-type.component';
|
import { CustomerSubTypeComponent } from '../../../components/customer-sub-type/customer-sub-type.component';
|
||||||
import { PaginatorComponent } from '../../../components/paginator/paginator.component';
|
import { PaginatorComponent } from '../../../components/paginator/paginator.component';
|
||||||
import { SearchModalBrokerComponent } from '../../../components/search-modal/search-modal-broker.component';
|
import { SearchModalBrokerComponent } from '../../../components/search-modal/search-modal-broker.component';
|
||||||
import { ModalService } from '../../../components/search-modal/modal.service';
|
import { ModalService } from '../../../components/search-modal/modal.service';
|
||||||
import { AltTextService } from '../../../services/alt-text.service';
|
import { AltTextService } from '../../../services/alt-text.service';
|
||||||
import { CriteriaChangeService } from '../../../services/criteria-change.service';
|
import { CriteriaChangeService } from '../../../services/criteria-change.service';
|
||||||
import { FilterStateService } from '../../../services/filter-state.service';
|
import { FilterStateService } from '../../../services/filter-state.service';
|
||||||
import { ImageService } from '../../../services/image.service';
|
import { ImageService } from '../../../services/image.service';
|
||||||
import { ListingsService } from '../../../services/listings.service';
|
import { ListingsService } from '../../../services/listings.service';
|
||||||
import { SearchService } from '../../../services/search.service';
|
import { SearchService } from '../../../services/search.service';
|
||||||
import { SelectOptionsService } from '../../../services/select-options.service';
|
import { SelectOptionsService } from '../../../services/select-options.service';
|
||||||
import { UserService } from '../../../services/user.service';
|
import { UserService } from '../../../services/user.service';
|
||||||
import { AuthService } from '../../../services/auth.service';
|
import { AuthService } from '../../../services/auth.service';
|
||||||
import { assignProperties, resetUserListingCriteria, map2User } from '../../../utils/utils';
|
import { assignProperties, resetUserListingCriteria, map2User } from '../../../utils/utils';
|
||||||
@UntilDestroy()
|
@UntilDestroy()
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-broker-listings',
|
selector: 'app-broker-listings',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, CustomerSubTypeComponent, BreadcrumbsComponent, SearchModalBrokerComponent],
|
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, CustomerSubTypeComponent, BreadcrumbsComponent, SearchModalBrokerComponent],
|
||||||
templateUrl: './broker-listings.component.html',
|
templateUrl: './broker-listings.component.html',
|
||||||
styleUrls: ['./broker-listings.component.scss', '../../pages.scss'],
|
styleUrls: ['./broker-listings.component.scss', '../../pages.scss'],
|
||||||
})
|
})
|
||||||
export class BrokerListingsComponent implements OnInit, OnDestroy {
|
export class BrokerListingsComponent implements OnInit, OnDestroy {
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
breadcrumbs: BreadcrumbItem[] = [
|
breadcrumbs: BreadcrumbItem[] = [
|
||||||
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
|
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
|
||||||
{ label: 'Professionals', url: '/brokerListings' }
|
{ label: 'Professionals', url: '/brokerListings' }
|
||||||
];
|
];
|
||||||
environment = environment;
|
environment = environment;
|
||||||
listings: Array<BusinessListing>;
|
listings: Array<BusinessListing>;
|
||||||
users: Array<User>;
|
users: Array<User>;
|
||||||
filteredListings: Array<ListingType>;
|
filteredListings: Array<ListingType>;
|
||||||
criteria: UserListingCriteria;
|
criteria: UserListingCriteria;
|
||||||
realEstateChecked: boolean;
|
realEstateChecked: boolean;
|
||||||
maxPrice: string;
|
maxPrice: string;
|
||||||
minPrice: string;
|
minPrice: string;
|
||||||
type: string;
|
type: string;
|
||||||
statesSet = new Set();
|
statesSet = new Set();
|
||||||
state: string;
|
state: string;
|
||||||
first: number = 0;
|
first: number = 0;
|
||||||
rows: number = 12;
|
rows: number = 12;
|
||||||
totalRecords: number = 0;
|
totalRecords: number = 0;
|
||||||
ts = new Date().getTime();
|
ts = new Date().getTime();
|
||||||
env = environment;
|
env = environment;
|
||||||
public category: 'business' | 'commercialProperty' | 'professionals_brokers' | undefined;
|
public category: 'business' | 'commercialProperty' | 'professionals_brokers' | undefined;
|
||||||
emailToDirName = emailToDirName;
|
emailToDirName = emailToDirName;
|
||||||
page = 1;
|
page = 1;
|
||||||
pageCount = 1;
|
pageCount = 1;
|
||||||
sortBy: SortByOptions = null; // Neu: Separate Property
|
sortBy: SortByOptions = null; // Neu: Separate Property
|
||||||
currentUser: KeycloakUser | null = null; // Current logged-in user
|
currentUser: KeycloakUser | null = null; // Current logged-in user
|
||||||
constructor(
|
constructor(
|
||||||
public altText: AltTextService,
|
public altText: AltTextService,
|
||||||
public selectOptions: SelectOptionsService,
|
public selectOptions: SelectOptionsService,
|
||||||
private listingsService: ListingsService,
|
private listingsService: ListingsService,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
private activatedRoute: ActivatedRoute,
|
private activatedRoute: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private cdRef: ChangeDetectorRef,
|
private cdRef: ChangeDetectorRef,
|
||||||
private imageService: ImageService,
|
private imageService: ImageService,
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private searchService: SearchService,
|
private searchService: SearchService,
|
||||||
private modalService: ModalService,
|
private modalService: ModalService,
|
||||||
private criteriaChangeService: CriteriaChangeService,
|
private criteriaChangeService: CriteriaChangeService,
|
||||||
private filterStateService: FilterStateService,
|
private filterStateService: FilterStateService,
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
) {
|
) {
|
||||||
this.loadSortBy();
|
this.loadSortBy();
|
||||||
}
|
}
|
||||||
private loadSortBy() {
|
private loadSortBy() {
|
||||||
const storedSortBy = sessionStorage.getItem('professionalsSortBy');
|
const storedSortBy = sessionStorage.getItem('professionalsSortBy');
|
||||||
this.sortBy = storedSortBy && storedSortBy !== 'null' ? (storedSortBy as SortByOptions) : null;
|
this.sortBy = storedSortBy && storedSortBy !== 'null' ? (storedSortBy as SortByOptions) : null;
|
||||||
}
|
}
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
// Get current logged-in user
|
// Get current logged-in user
|
||||||
const token = await this.authService.getToken();
|
const token = await this.authService.getToken();
|
||||||
this.currentUser = map2User(token);
|
this.currentUser = map2User(token);
|
||||||
|
|
||||||
// Subscribe to FilterStateService for criteria changes
|
// Subscribe to FilterStateService for criteria changes
|
||||||
this.filterStateService
|
this.filterStateService
|
||||||
.getState$('brokerListings')
|
.getState$('brokerListings')
|
||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe(state => {
|
.subscribe(state => {
|
||||||
this.criteria = state.criteria as UserListingCriteria;
|
this.criteria = state.criteria as UserListingCriteria;
|
||||||
this.sortBy = state.sortBy;
|
this.sortBy = state.sortBy;
|
||||||
this.search();
|
this.search();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Subscribe to SearchService for search triggers
|
// Subscribe to SearchService for search triggers
|
||||||
this.searchService.searchTrigger$
|
this.searchService.searchTrigger$
|
||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe(type => {
|
.subscribe(type => {
|
||||||
if (type === 'brokerListings') {
|
if (type === 'brokerListings') {
|
||||||
this.search();
|
this.search();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.destroy$.next();
|
this.destroy$.next();
|
||||||
this.destroy$.complete();
|
this.destroy$.complete();
|
||||||
}
|
}
|
||||||
async search() {
|
async search() {
|
||||||
const usersReponse = await this.userService.search(this.criteria);
|
const usersReponse = await this.userService.search(this.criteria);
|
||||||
this.users = usersReponse.results;
|
this.users = usersReponse.results;
|
||||||
this.totalRecords = usersReponse.totalCount;
|
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.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.page = this.criteria.page ? this.criteria.page : 1;
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
this.cdRef.detectChanges();
|
this.cdRef.detectChanges();
|
||||||
}
|
}
|
||||||
onPageChange(page: any) {
|
onPageChange(page: any) {
|
||||||
this.criteria.start = (page - 1) * LISTINGS_PER_PAGE;
|
this.criteria.start = (page - 1) * LISTINGS_PER_PAGE;
|
||||||
this.criteria.length = LISTINGS_PER_PAGE;
|
this.criteria.length = LISTINGS_PER_PAGE;
|
||||||
this.criteria.page = page;
|
this.criteria.page = page;
|
||||||
this.search();
|
this.search();
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() { }
|
reset() { }
|
||||||
|
|
||||||
// New methods for filter actions
|
// New methods for filter actions
|
||||||
clearAllFilters() {
|
clearAllFilters() {
|
||||||
// Reset criteria to default values
|
// Reset criteria to default values
|
||||||
resetUserListingCriteria(this.criteria);
|
resetUserListingCriteria(this.criteria);
|
||||||
|
|
||||||
// Reset pagination
|
// Reset pagination
|
||||||
this.criteria.page = 1;
|
this.criteria.page = 1;
|
||||||
this.criteria.start = 0;
|
this.criteria.start = 0;
|
||||||
|
|
||||||
this.criteriaChangeService.notifyCriteriaChange();
|
this.criteriaChangeService.notifyCriteriaChange();
|
||||||
|
|
||||||
// Search with cleared filters
|
// Search with cleared filters
|
||||||
this.searchService.search('brokerListings');
|
this.searchService.search('brokerListings');
|
||||||
}
|
}
|
||||||
|
|
||||||
async openFilterModal() {
|
async openFilterModal() {
|
||||||
// Open the search modal with current criteria
|
// Open the search modal with current criteria
|
||||||
const modalResult = await this.modalService.showModal(this.criteria);
|
const modalResult = await this.modalService.showModal(this.criteria);
|
||||||
if (modalResult.accepted) {
|
if (modalResult.accepted) {
|
||||||
this.searchService.search('brokerListings');
|
this.searchService.search('brokerListings');
|
||||||
} else {
|
} else {
|
||||||
this.criteria = assignProperties(this.criteria, modalResult.criteria);
|
this.criteria = assignProperties(this.criteria, modalResult.criteria);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if professional/user is already in current user's favorites
|
* Check if professional/user is already in current user's favorites
|
||||||
*/
|
*/
|
||||||
isFavorite(professional: User): boolean {
|
isFavorite(professional: User): boolean {
|
||||||
if (!this.currentUser?.email || !professional.favoritesForUser) return false;
|
if (!this.currentUser?.email || !professional.favoritesForUser) return false;
|
||||||
return professional.favoritesForUser.includes(this.currentUser.email);
|
return professional.favoritesForUser.includes(this.currentUser.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle favorite status for a professional
|
* Toggle favorite status for a professional
|
||||||
*/
|
*/
|
||||||
async toggleFavorite(event: Event, professional: User): Promise<void> {
|
async toggleFavorite(event: Event, professional: User): Promise<void> {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (!this.currentUser?.email) {
|
if (!this.currentUser?.email) {
|
||||||
// User not logged in - redirect to login
|
// User not logged in - redirect to login
|
||||||
this.router.navigate(['/login']);
|
this.router.navigate(['/login']);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Toggling favorite for:', professional.email, 'Current user:', this.currentUser.email);
|
console.log('Toggling favorite for:', professional.email, 'Current user:', this.currentUser.email);
|
||||||
console.log('Before update, favorites:', professional.favoritesForUser);
|
console.log('Before update, favorites:', professional.favoritesForUser);
|
||||||
|
|
||||||
if (this.isFavorite(professional)) {
|
if (this.isFavorite(professional)) {
|
||||||
// Remove from favorites
|
// Remove from favorites
|
||||||
await this.listingsService.removeFavorite(professional.id, 'user');
|
await this.listingsService.removeFavorite(professional.id, 'user');
|
||||||
professional.favoritesForUser = professional.favoritesForUser.filter(
|
professional.favoritesForUser = professional.favoritesForUser.filter(
|
||||||
email => email !== this.currentUser!.email
|
email => email !== this.currentUser!.email
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Add to favorites
|
// Add to favorites
|
||||||
await this.listingsService.addToFavorites(professional.id, 'user');
|
await this.listingsService.addToFavorites(professional.id, 'user');
|
||||||
if (!professional.favoritesForUser) {
|
if (!professional.favoritesForUser) {
|
||||||
professional.favoritesForUser = [];
|
professional.favoritesForUser = [];
|
||||||
}
|
}
|
||||||
// Use spread to create new array reference
|
// Use spread to create new array reference
|
||||||
professional.favoritesForUser = [...professional.favoritesForUser, this.currentUser.email];
|
professional.favoritesForUser = [...professional.favoritesForUser, this.currentUser.email];
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('After update, favorites:', professional.favoritesForUser);
|
console.log('After update, favorites:', professional.favoritesForUser);
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
this.cdRef.detectChanges();
|
this.cdRef.detectChanges();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error toggling favorite:', error);
|
console.error('Error toggling favorite:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Share professional profile
|
* Share professional profile
|
||||||
*/
|
*/
|
||||||
async shareProfessional(event: Event, user: User): Promise<void> {
|
async shareProfessional(event: Event, user: User): Promise<void> {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const url = `${window.location.origin}/details-user/${user.id}`;
|
const url = `${window.location.origin}/details-user/${user.id}`;
|
||||||
const title = `${user.firstname} ${user.lastname} - ${user.companyName}`;
|
const title = `${user.firstname} ${user.lastname} - ${user.companyName}`;
|
||||||
|
|
||||||
// Try native share API first (works on mobile and some desktop browsers)
|
// Try native share API first (works on mobile and some desktop browsers)
|
||||||
if (navigator.share) {
|
if (navigator.share) {
|
||||||
try {
|
try {
|
||||||
await navigator.share({
|
await navigator.share({
|
||||||
title: title,
|
title: title,
|
||||||
text: `Check out this professional: ${title}`,
|
text: `Check out this professional: ${title}`,
|
||||||
url: url,
|
url: url,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// User cancelled or share failed - fall back to clipboard
|
// User cancelled or share failed - fall back to clipboard
|
||||||
this.copyToClipboard(url);
|
this.copyToClipboard(url);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback: open Facebook share dialog
|
// Fallback: open Facebook share dialog
|
||||||
const encodedUrl = encodeURIComponent(url);
|
const encodedUrl = encodeURIComponent(url);
|
||||||
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, '_blank', 'width=600,height=400');
|
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, '_blank', 'width=600,height=400');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy URL to clipboard and show feedback
|
* Copy URL to clipboard and show feedback
|
||||||
*/
|
*/
|
||||||
private copyToClipboard(url: string): void {
|
private copyToClipboard(url: string): void {
|
||||||
navigator.clipboard.writeText(url).then(() => {
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
console.log('Link copied to clipboard!');
|
console.log('Link copied to clipboard!');
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error('Failed to copy link:', err);
|
console.error('Failed to copy link:', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,259 +1,259 @@
|
||||||
<div class="flex flex-col md:flex-row">
|
<div class="flex flex-col md:flex-row">
|
||||||
<!-- Filter Panel for Desktop -->
|
<!-- 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">
|
<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>
|
<app-search-modal [isModal]="false"></app-search-modal>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="w-full p-4">
|
<div class="w-full p-4">
|
||||||
<div class="container mx-auto">
|
<div class="container mx-auto">
|
||||||
<!-- Breadcrumbs -->
|
<!-- Breadcrumbs -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
|
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SEO-optimized heading -->
|
<!-- SEO-optimized heading -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h1 class="text-3xl md:text-4xl font-bold text-neutral-900 mb-2">Businesses for Sale</h1>
|
<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
|
<p class="text-lg text-neutral-600">Discover profitable business opportunities across the United States. Browse
|
||||||
verified listings from business owners and brokers.</p>
|
verified listings from business owners and brokers.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading Skeleton -->
|
<!-- Loading Skeleton -->
|
||||||
@if(isLoading) {
|
@if(isLoading) {
|
||||||
<h2 class="text-2xl font-semibold text-neutral-800 mb-4">Loading Business Listings...</h2>
|
<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">
|
<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) {
|
@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="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg overflow-hidden">
|
||||||
<div class="p-6 animate-pulse">
|
<div class="p-6 animate-pulse">
|
||||||
<!-- Category icon and text -->
|
<!-- Category icon and text -->
|
||||||
<div class="flex items-center mb-4">
|
<div class="flex items-center mb-4">
|
||||||
<div class="w-5 h-5 bg-neutral-200 rounded mr-2"></div>
|
<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 class="h-5 bg-neutral-200 rounded w-32"></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
<div class="h-7 bg-neutral-200 rounded w-3/4 mb-4"></div>
|
<div class="h-7 bg-neutral-200 rounded w-3/4 mb-4"></div>
|
||||||
<!-- Badges -->
|
<!-- Badges -->
|
||||||
<div class="flex justify-between mb-4">
|
<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-20"></div>
|
||||||
<div class="h-6 bg-neutral-200 rounded-full w-16"></div>
|
<div class="h-6 bg-neutral-200 rounded-full w-16"></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Details -->
|
<!-- Details -->
|
||||||
<div class="space-y-2 mb-4">
|
<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-full"></div>
|
||||||
<div class="h-4 bg-neutral-200 rounded w-5/6"></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-4/6"></div>
|
||||||
<div class="h-4 bg-neutral-200 rounded w-3/4"></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 class="h-4 bg-neutral-200 rounded w-2/3"></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Button -->
|
<!-- Button -->
|
||||||
<div class="h-12 bg-neutral-200 rounded-full w-full mt-4"></div>
|
<div class="h-12 bg-neutral-200 rounded-full w-full mt-4"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
} @else if(listings?.length > 0) {
|
} @else if(listings?.length > 0) {
|
||||||
<h2 class="text-2xl font-semibold text-neutral-800 mb-4">Available Business Listings</h2>
|
<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">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
@for (listing of listings; track listing.id) {
|
@for (listing of listings; track listing.id) {
|
||||||
<div
|
<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">
|
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]">
|
<div class="p-6 flex flex-col h-full relative z-[0]">
|
||||||
<!-- Quick Actions Overlay -->
|
<!-- Quick Actions Overlay -->
|
||||||
<div
|
<div
|
||||||
class="absolute top-4 right-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-20">
|
class="absolute top-4 right-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-20">
|
||||||
@if(user) {
|
@if(user) {
|
||||||
<button class="bg-white rounded-full p-2 shadow-lg transition-colors"
|
<button class="bg-white rounded-full p-2 shadow-lg transition-colors"
|
||||||
[class.bg-red-50]="isFavorite(listing)"
|
[class.bg-red-50]="isFavorite(listing)"
|
||||||
[title]="isFavorite(listing) ? 'Remove from favorites' : 'Save to favorites'"
|
[title]="isFavorite(listing) ? 'Remove from favorites' : 'Save to favorites'"
|
||||||
(click)="toggleFavorite($event, listing)">
|
(click)="toggleFavorite($event, listing)">
|
||||||
<i
|
<i
|
||||||
[class]="isFavorite(listing) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i>
|
[class]="isFavorite(listing) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<button type="button" class="bg-white rounded-full p-2 shadow-lg hover:bg-blue-50 transition-colors"
|
<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)">
|
title="Share listing" (click)="shareListing($event, listing)">
|
||||||
<i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i>
|
<i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center mb-4">
|
<div class="flex items-center mb-4">
|
||||||
<i [class]="selectOptions.getIconAndTextColorType(listing.type)" class="mr-2 text-xl"></i>
|
<i [class]="selectOptions.getIconAndTextColorType(listing.type)" class="mr-2 text-xl"></i>
|
||||||
<span [class]="selectOptions.getTextColorType(listing.type)" class="font-bold text-lg">{{
|
<span [class]="selectOptions.getTextColorType(listing.type)" class="font-bold text-lg">{{
|
||||||
selectOptions.getBusiness(listing.type) }}</span>
|
selectOptions.getBusiness(listing.type) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="text-xl font-semibold mb-4">
|
<h2 class="text-xl font-semibold mb-4">
|
||||||
{{ listing.title }}
|
{{ listing.title }}
|
||||||
@if(listing.draft) {
|
@if(listing.draft) {
|
||||||
<span
|
<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>
|
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>
|
</h2>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span
|
<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">
|
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) }}
|
{{ selectOptions.getState(listing.location.state) }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@if (getListingBadge(listing); as badge) {
|
@if (getListingBadge(listing); as badge) {
|
||||||
<span
|
<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"
|
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]="{
|
[ngClass]="{
|
||||||
'bg-emerald-100 text-emerald-800 border-emerald-300': badge === 'NEW',
|
'bg-emerald-100 text-emerald-800 border-emerald-300': badge === 'NEW',
|
||||||
'bg-teal-100 text-teal-800 border-teal-300': badge === 'UPDATED'
|
'bg-teal-100 text-teal-800 border-teal-300': badge === 'UPDATED'
|
||||||
}">
|
}">
|
||||||
{{ badge }}
|
{{ badge }}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-base font-bold text-neutral-800 mb-2">
|
<p class="text-base font-bold text-neutral-800 mb-2">
|
||||||
<strong>Asking price:</strong>
|
<strong>Asking price:</strong>
|
||||||
<span class="text-success-600">
|
<span class="text-success-600">
|
||||||
{{ listing?.price != null ? (listing.price | currency : 'USD' : 'symbol' : '1.0-0') : 'undisclosed' }}
|
{{ listing?.price != null ? (listing.price | currency : 'USD' : 'symbol' : '1.0-0') : 'undisclosed' }}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-neutral-600 mb-2">
|
<p class="text-sm text-neutral-600 mb-2">
|
||||||
<strong>Sales revenue:</strong>
|
<strong>Sales revenue:</strong>
|
||||||
{{ listing?.salesRevenue != null ? (listing.salesRevenue | currency : 'USD' : 'symbol' : '1.0-0') :
|
{{ listing?.salesRevenue != null ? (listing.salesRevenue | currency : 'USD' : 'symbol' : '1.0-0') :
|
||||||
'undisclosed' }}
|
'undisclosed' }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-neutral-600 mb-2">
|
<p class="text-sm text-neutral-600 mb-2">
|
||||||
<strong>Net profit:</strong>
|
<strong>Net profit:</strong>
|
||||||
{{ listing?.cashFlow != null ? (listing.cashFlow | currency : 'USD' : 'symbol' : '1.0-0') : 'undisclosed'
|
{{ listing?.cashFlow != null ? (listing.cashFlow | currency : 'USD' : 'symbol' : '1.0-0') : 'undisclosed'
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-neutral-600 mb-2">
|
<p class="text-sm text-neutral-600 mb-2">
|
||||||
<strong>Location:</strong> {{ listing.location.name ? listing.location.name : listing.location.county ?
|
<strong>Location:</strong> {{ listing.location.name ? listing.location.name : listing.location.county ?
|
||||||
listing.location.county : this.selectOptions.getState(listing.location.state) }}
|
listing.location.county : this.selectOptions.getState(listing.location.state) }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-neutral-600 mb-4"><strong>Years established:</strong> {{ listing.established }}</p>
|
<p class="text-sm text-neutral-600 mb-4"><strong>Years established:</strong> {{ listing.established }}</p>
|
||||||
@if(listing.imageName) {
|
@if(listing.imageName) {
|
||||||
<img [appLazyLoad]="env.imageBaseUrl + '/pictures/logo/' + listing.imageName + '.avif?_ts=' + ts"
|
<img [appLazyLoad]="env.imageBaseUrl + '/pictures/logo/' + listing.imageName + '.avif?_ts=' + ts"
|
||||||
[alt]="altText.generateListingCardLogoAlt(listing)"
|
[alt]="altText.generateListingCardLogoAlt(listing)"
|
||||||
class="absolute bottom-[80px] right-[20px] h-[45px] w-auto" width="100" height="45" />
|
class="absolute bottom-[80px] right-[20px] h-[45px] w-auto" width="100" height="45" />
|
||||||
}
|
}
|
||||||
<div class="flex-grow"></div>
|
<div class="flex-grow"></div>
|
||||||
<button
|
<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"
|
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]">
|
[routerLink]="['/business', listing.slug || listing.id]">
|
||||||
<span class="font-semibold">View Opportunity</span>
|
<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>
|
<i class="fas fa-arrow-right ml-2 group-hover/btn:translate-x-1 transition-transform duration-200"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
} @else if (listings?.length === 0) {
|
} @else if (listings?.length === 0) {
|
||||||
<div class="w-full flex items-center flex-wrap justify-center gap-10 py-12">
|
<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">
|
<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"
|
<svg class="mx-auto" xmlns="http://www.w3.org/2000/svg" width="154" height="161" viewBox="0 0 154 161"
|
||||||
fill="none">
|
fill="none">
|
||||||
<path
|
<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"
|
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" />
|
fill="#EEF2FF" />
|
||||||
<path
|
<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"
|
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" />
|
fill="white" stroke="#E5E7EB" />
|
||||||
<ellipse cx="80.0618" cy="81" rx="28.0342" ry="28.0342" fill="#EEF2FF" />
|
<ellipse cx="80.0618" cy="81" rx="28.0342" ry="28.0342" fill="#EEF2FF" />
|
||||||
<path
|
<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"
|
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" />
|
stroke="#E5E7EB" />
|
||||||
<path
|
<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"
|
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" />
|
stroke="#E5E7EB" />
|
||||||
<path
|
<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"
|
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" />
|
fill="#A5B4FC" stroke="#818CF8" />
|
||||||
<path
|
<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"
|
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" />
|
fill="#4F46E5" />
|
||||||
<path
|
<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"
|
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" />
|
fill="#4F46E5" />
|
||||||
<path
|
<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"
|
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" />
|
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="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="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" />
|
<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="56.13" cy="31.0854" r="1.36752" fill="#4F46E5" />
|
||||||
<circle cx="61.6001" 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" />
|
<circle cx="67.0702" cy="31.0854" r="1.36752" fill="#4F46E5" />
|
||||||
</svg>
|
</svg>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<h2 class="text-black text-2xl font-semibold leading-loose pb-2">No listings found</h2>
|
<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
|
<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>
|
matching your criteria.<br />Try adjusting your filters or explore popular categories below.</p>
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
<div class="flex flex-col sm:flex-row gap-3 justify-center mb-8">
|
<div class="flex flex-col sm:flex-row gap-3 justify-center mb-8">
|
||||||
<button (click)="clearAllFilters()"
|
<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">
|
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
|
<i class="fas fa-redo mr-2"></i>Clear All Filters
|
||||||
</button>
|
</button>
|
||||||
<button [routerLink]="['/home']"
|
<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">
|
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
|
<i class="fas fa-home mr-2"></i>Back to Home
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Popular Categories Suggestions -->
|
<!-- Popular Categories Suggestions -->
|
||||||
<div class="mt-8 p-6 bg-neutral-50 rounded-lg">
|
<div class="mt-8 p-6 bg-neutral-50 rounded-lg">
|
||||||
<h3 class="text-lg font-semibold text-neutral-800 mb-4">
|
<h3 class="text-lg font-semibold text-neutral-800 mb-4">
|
||||||
<i class="fas fa-fire text-orange-500 mr-2"></i>Popular Categories
|
<i class="fas fa-fire text-orange-500 mr-2"></i>Popular Categories
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
|
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
<button (click)="filterByCategory('foodAndRestaurant')"
|
<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">
|
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
|
<i class="fas fa-utensils mr-2"></i>Restaurants
|
||||||
</button>
|
</button>
|
||||||
<button (click)="filterByCategory('retail')"
|
<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">
|
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
|
<i class="fas fa-store mr-2"></i>Retail
|
||||||
</button>
|
</button>
|
||||||
<button (click)="filterByCategory('realEstate')"
|
<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">
|
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
|
<i class="fas fa-building mr-2"></i>Real Estate
|
||||||
</button>
|
</button>
|
||||||
<button (click)="filterByCategory('service')"
|
<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">
|
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
|
<i class="fas fa-cut mr-2"></i>Services
|
||||||
</button>
|
</button>
|
||||||
<button (click)="filterByCategory('franchise')"
|
<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">
|
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
|
<i class="fas fa-handshake mr-2"></i>Franchise
|
||||||
</button>
|
</button>
|
||||||
<button (click)="filterByCategory('professional')"
|
<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">
|
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
|
<i class="fas fa-briefcase mr-2"></i>Professional
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Helpful Tips -->
|
<!-- Helpful Tips -->
|
||||||
<div class="mt-6 p-4 bg-primary-50 border border-primary-100 rounded-lg text-left">
|
<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">
|
<h4 class="font-semibold text-primary-900 mb-2 flex items-center">
|
||||||
<i class="fas fa-lightbulb mr-2"></i>Search Tips
|
<i class="fas fa-lightbulb mr-2"></i>Search Tips
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="text-sm text-primary-800 space-y-1">
|
<ul class="text-sm text-primary-800 space-y-1">
|
||||||
<li>• Try expanding your search radius</li>
|
<li>• Try expanding your search radius</li>
|
||||||
<li>• Consider adjusting your price range</li>
|
<li>• Consider adjusting your price range</li>
|
||||||
<li>• Browse all categories to discover opportunities</li>
|
<li>• Browse all categories to discover opportunities</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if(pageCount > 1) {
|
@if(pageCount > 1) {
|
||||||
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator>
|
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filter Button for Mobile -->
|
<!-- Filter Button for Mobile -->
|
||||||
<button (click)="openFilterModal()"
|
<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="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>
|
class="fas fa-filter"></i> Filter</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,330 +1,330 @@
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
import { UntilDestroy } from '@ngneat/until-destroy';
|
import { UntilDestroy } from '@ngneat/until-destroy';
|
||||||
import { Subject, takeUntil } from 'rxjs';
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { BusinessListing, SortByOptions } from '../../../../../../bizmatch-server/src/models/db.model';
|
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 { BusinessListingCriteria, KeycloakUser, LISTINGS_PER_PAGE, ListingType, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
|
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
|
||||||
import { PaginatorComponent } from '../../../components/paginator/paginator.component';
|
import { PaginatorComponent } from '../../../components/paginator/paginator.component';
|
||||||
import { ModalService } from '../../../components/search-modal/modal.service';
|
import { ModalService } from '../../../components/search-modal/modal.service';
|
||||||
import { SearchModalComponent } from '../../../components/search-modal/search-modal.component';
|
import { SearchModalComponent } from '../../../components/search-modal/search-modal.component';
|
||||||
import { LazyLoadImageDirective } from '../../../directives/lazy-load-image.directive';
|
import { LazyLoadImageDirective } from '../../../directives/lazy-load-image.directive';
|
||||||
import { AltTextService } from '../../../services/alt-text.service';
|
import { AltTextService } from '../../../services/alt-text.service';
|
||||||
import { AuthService } from '../../../services/auth.service';
|
import { AuthService } from '../../../services/auth.service';
|
||||||
import { FilterStateService } from '../../../services/filter-state.service';
|
import { FilterStateService } from '../../../services/filter-state.service';
|
||||||
import { ImageService } from '../../../services/image.service';
|
import { ImageService } from '../../../services/image.service';
|
||||||
import { ListingsService } from '../../../services/listings.service';
|
import { ListingsService } from '../../../services/listings.service';
|
||||||
import { SearchService } from '../../../services/search.service';
|
import { SearchService } from '../../../services/search.service';
|
||||||
import { SelectOptionsService } from '../../../services/select-options.service';
|
import { SelectOptionsService } from '../../../services/select-options.service';
|
||||||
import { SeoService } from '../../../services/seo.service';
|
import { SeoService } from '../../../services/seo.service';
|
||||||
import { map2User } from '../../../utils/utils';
|
import { map2User } from '../../../utils/utils';
|
||||||
|
|
||||||
@UntilDestroy()
|
@UntilDestroy()
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-business-listings',
|
selector: 'app-business-listings',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, SearchModalComponent, LazyLoadImageDirective, BreadcrumbsComponent],
|
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, SearchModalComponent, LazyLoadImageDirective, BreadcrumbsComponent],
|
||||||
templateUrl: './business-listings.component.html',
|
templateUrl: './business-listings.component.html',
|
||||||
styleUrls: ['./business-listings.component.scss', '../../pages.scss'],
|
styleUrls: ['./business-listings.component.scss', '../../pages.scss'],
|
||||||
})
|
})
|
||||||
export class BusinessListingsComponent implements OnInit, OnDestroy {
|
export class BusinessListingsComponent implements OnInit, OnDestroy {
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
// Component properties
|
// Component properties
|
||||||
environment = environment;
|
environment = environment;
|
||||||
env = environment;
|
env = environment;
|
||||||
listings: Array<BusinessListing> = [];
|
listings: Array<BusinessListing> = [];
|
||||||
filteredListings: Array<ListingType> = [];
|
filteredListings: Array<ListingType> = [];
|
||||||
criteria: BusinessListingCriteria;
|
criteria: BusinessListingCriteria;
|
||||||
sortBy: SortByOptions | null = null;
|
sortBy: SortByOptions | null = null;
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
totalRecords = 0;
|
totalRecords = 0;
|
||||||
page = 1;
|
page = 1;
|
||||||
pageCount = 1;
|
pageCount = 1;
|
||||||
first = 0;
|
first = 0;
|
||||||
rows = LISTINGS_PER_PAGE;
|
rows = LISTINGS_PER_PAGE;
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
ts = new Date().getTime();
|
ts = new Date().getTime();
|
||||||
emailToDirName = emailToDirName;
|
emailToDirName = emailToDirName;
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
|
|
||||||
// Breadcrumbs
|
// Breadcrumbs
|
||||||
breadcrumbs: BreadcrumbItem[] = [
|
breadcrumbs: BreadcrumbItem[] = [
|
||||||
{ label: 'Home', url: '/', icon: 'fas fa-home' },
|
{ label: 'Home', url: '/', icon: 'fas fa-home' },
|
||||||
{ label: 'Business Listings' }
|
{ label: 'Business Listings' }
|
||||||
];
|
];
|
||||||
|
|
||||||
// User for favorites
|
// User for favorites
|
||||||
user: KeycloakUser | null = null;
|
user: KeycloakUser | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public altText: AltTextService,
|
public altText: AltTextService,
|
||||||
public selectOptions: SelectOptionsService,
|
public selectOptions: SelectOptionsService,
|
||||||
private listingsService: ListingsService,
|
private listingsService: ListingsService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private cdRef: ChangeDetectorRef,
|
private cdRef: ChangeDetectorRef,
|
||||||
private imageService: ImageService,
|
private imageService: ImageService,
|
||||||
private searchService: SearchService,
|
private searchService: SearchService,
|
||||||
private modalService: ModalService,
|
private modalService: ModalService,
|
||||||
private filterStateService: FilterStateService,
|
private filterStateService: FilterStateService,
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private seoService: SeoService,
|
private seoService: SeoService,
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
// Load user for favorites functionality
|
// Load user for favorites functionality
|
||||||
const token = await this.authService.getToken();
|
const token = await this.authService.getToken();
|
||||||
this.user = map2User(token);
|
this.user = map2User(token);
|
||||||
|
|
||||||
// Set SEO meta tags for business listings page
|
// Set SEO meta tags for business listings page
|
||||||
this.seoService.updateMetaTags({
|
this.seoService.updateMetaTags({
|
||||||
title: 'Businesses for Sale - Find Profitable Business Opportunities | BizMatch',
|
title: 'Businesses for Sale - Profitable 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.',
|
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',
|
keywords: 'businesses for sale, buy a business, business opportunities, franchise for sale, restaurant for sale, retail business for sale, business broker listings',
|
||||||
type: 'website'
|
type: 'website'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Subscribe to state changes
|
// Subscribe to state changes
|
||||||
this.filterStateService
|
this.filterStateService
|
||||||
.getState$('businessListings')
|
.getState$('businessListings')
|
||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe(state => {
|
.subscribe(state => {
|
||||||
this.criteria = state.criteria;
|
this.criteria = state.criteria;
|
||||||
this.sortBy = state.sortBy;
|
this.sortBy = state.sortBy;
|
||||||
// Automatically search when state changes
|
// Automatically search when state changes
|
||||||
this.search();
|
this.search();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Subscribe to search triggers (if triggered from other components)
|
// Subscribe to search triggers (if triggered from other components)
|
||||||
this.searchService.searchTrigger$.pipe(takeUntil(this.destroy$)).subscribe(type => {
|
this.searchService.searchTrigger$.pipe(takeUntil(this.destroy$)).subscribe(type => {
|
||||||
if (type === 'businessListings') {
|
if (type === 'businessListings') {
|
||||||
this.search();
|
this.search();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(): Promise<void> {
|
async search(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Show loading state
|
// Show loading state
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
|
|
||||||
// Get current criteria from service
|
// Get current criteria from service
|
||||||
this.criteria = this.filterStateService.getCriteria('businessListings') as BusinessListingCriteria;
|
this.criteria = this.filterStateService.getCriteria('businessListings') as BusinessListingCriteria;
|
||||||
|
|
||||||
// Add sortBy if available
|
// Add sortBy if available
|
||||||
const searchCriteria = {
|
const searchCriteria = {
|
||||||
...this.criteria,
|
...this.criteria,
|
||||||
sortBy: this.sortBy,
|
sortBy: this.sortBy,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Perform search
|
// Perform search
|
||||||
const listingsResponse = await this.listingsService.getListings('business');
|
const listingsResponse = await this.listingsService.getListings('business');
|
||||||
this.listings = listingsResponse.results;
|
this.listings = listingsResponse.results;
|
||||||
this.totalRecords = listingsResponse.totalCount;
|
this.totalRecords = listingsResponse.totalCount;
|
||||||
this.pageCount = Math.ceil(this.totalRecords / LISTINGS_PER_PAGE);
|
this.pageCount = Math.ceil(this.totalRecords / LISTINGS_PER_PAGE);
|
||||||
this.page = this.criteria.page || 1;
|
this.page = this.criteria.page || 1;
|
||||||
|
|
||||||
// Hide loading state
|
// Hide loading state
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
|
|
||||||
// Update pagination SEO links
|
// Update pagination SEO links
|
||||||
this.updatePaginationSEO();
|
this.updatePaginationSEO();
|
||||||
|
|
||||||
// Update view
|
// Update view
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
this.cdRef.detectChanges();
|
this.cdRef.detectChanges();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search error:', error);
|
console.error('Search error:', error);
|
||||||
// Handle error appropriately
|
// Handle error appropriately
|
||||||
this.listings = [];
|
this.listings = [];
|
||||||
this.totalRecords = 0;
|
this.totalRecords = 0;
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onPageChange(page: number): void {
|
onPageChange(page: number): void {
|
||||||
// Update only pagination properties
|
// Update only pagination properties
|
||||||
this.filterStateService.updateCriteria('businessListings', {
|
this.filterStateService.updateCriteria('businessListings', {
|
||||||
page: page,
|
page: page,
|
||||||
start: (page - 1) * LISTINGS_PER_PAGE,
|
start: (page - 1) * LISTINGS_PER_PAGE,
|
||||||
length: LISTINGS_PER_PAGE,
|
length: LISTINGS_PER_PAGE,
|
||||||
});
|
});
|
||||||
// Search will be triggered automatically through state subscription
|
// Search will be triggered automatically through state subscription
|
||||||
}
|
}
|
||||||
|
|
||||||
clearAllFilters(): void {
|
clearAllFilters(): void {
|
||||||
// Reset criteria but keep sortBy
|
// Reset criteria but keep sortBy
|
||||||
this.filterStateService.clearFilters('businessListings');
|
this.filterStateService.clearFilters('businessListings');
|
||||||
// Search will be triggered automatically through state subscription
|
// Search will be triggered automatically through state subscription
|
||||||
}
|
}
|
||||||
|
|
||||||
async openFilterModal(): Promise<void> {
|
async openFilterModal(): Promise<void> {
|
||||||
// Open modal with current criteria
|
// Open modal with current criteria
|
||||||
const currentCriteria = this.filterStateService.getCriteria('businessListings');
|
const currentCriteria = this.filterStateService.getCriteria('businessListings');
|
||||||
const modalResult = await this.modalService.showModal(currentCriteria);
|
const modalResult = await this.modalService.showModal(currentCriteria);
|
||||||
|
|
||||||
if (modalResult.accepted) {
|
if (modalResult.accepted) {
|
||||||
// Modal accepted changes - state is updated by modal
|
// Modal accepted changes - state is updated by modal
|
||||||
// Search will be triggered automatically through state subscription
|
// Search will be triggered automatically through state subscription
|
||||||
} else {
|
} else {
|
||||||
// Modal was cancelled - no action needed
|
// Modal was cancelled - no action needed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getListingPrice(listing: BusinessListing): string {
|
getListingPrice(listing: BusinessListing): string {
|
||||||
if (!listing.price) return 'Price on Request';
|
if (!listing.price) return 'Price on Request';
|
||||||
return `$${listing.price.toLocaleString()}`;
|
return `$${listing.price.toLocaleString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
getListingLocation(listing: BusinessListing): string {
|
getListingLocation(listing: BusinessListing): string {
|
||||||
if (!listing.location) return 'Location not specified';
|
if (!listing.location) return 'Location not specified';
|
||||||
return `${listing.location.name}, ${listing.location.state}`;
|
return `${listing.location.name}, ${listing.location.state}`;
|
||||||
}
|
}
|
||||||
private isWithinDays(date: Date | string | undefined | null, days: number): boolean {
|
private isWithinDays(date: Date | string | undefined | null, days: number): boolean {
|
||||||
if (!date) return false;
|
if (!date) return false;
|
||||||
return dayjs().diff(dayjs(date), 'day') < days;
|
return dayjs().diff(dayjs(date), 'day') < days;
|
||||||
}
|
}
|
||||||
|
|
||||||
getListingBadge(listing: BusinessListing): 'NEW' | 'UPDATED' | null {
|
getListingBadge(listing: BusinessListing): 'NEW' | 'UPDATED' | null {
|
||||||
if (this.isWithinDays(listing.created, 14)) return 'NEW'; // Priorität
|
if (this.isWithinDays(listing.created, 14)) return 'NEW'; // Priorität
|
||||||
if (this.isWithinDays(listing.updated, 14)) return 'UPDATED';
|
if (this.isWithinDays(listing.updated, 14)) return 'UPDATED';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
navigateToDetails(listingId: string): void {
|
navigateToDetails(listingId: string): void {
|
||||||
this.router.navigate(['/details-business', listingId]);
|
this.router.navigate(['/details-business', listingId]);
|
||||||
}
|
}
|
||||||
getDaysListed(listing: BusinessListing) {
|
getDaysListed(listing: BusinessListing) {
|
||||||
return dayjs().diff(listing.created, 'day');
|
return dayjs().diff(listing.created, 'day');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter by popular category
|
* Filter by popular category
|
||||||
*/
|
*/
|
||||||
filterByCategory(category: string): void {
|
filterByCategory(category: string): void {
|
||||||
this.filterStateService.updateCriteria('businessListings', {
|
this.filterStateService.updateCriteria('businessListings', {
|
||||||
types: [category],
|
types: [category],
|
||||||
page: 1,
|
page: 1,
|
||||||
start: 0,
|
start: 0,
|
||||||
length: LISTINGS_PER_PAGE,
|
length: LISTINGS_PER_PAGE,
|
||||||
});
|
});
|
||||||
// Search will be triggered automatically through state subscription
|
// Search will be triggered automatically through state subscription
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if listing is already in user's favorites
|
* Check if listing is already in user's favorites
|
||||||
*/
|
*/
|
||||||
isFavorite(listing: BusinessListing): boolean {
|
isFavorite(listing: BusinessListing): boolean {
|
||||||
if (!this.user?.email || !listing.favoritesForUser) return false;
|
if (!this.user?.email || !listing.favoritesForUser) return false;
|
||||||
return listing.favoritesForUser.includes(this.user.email);
|
return listing.favoritesForUser.includes(this.user.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle favorite status for a listing
|
* Toggle favorite status for a listing
|
||||||
*/
|
*/
|
||||||
async toggleFavorite(event: Event, listing: BusinessListing): Promise<void> {
|
async toggleFavorite(event: Event, listing: BusinessListing): Promise<void> {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (!this.user?.email) {
|
if (!this.user?.email) {
|
||||||
// User not logged in - redirect to login or show message
|
// User not logged in - redirect to login or show message
|
||||||
this.router.navigate(['/login']);
|
this.router.navigate(['/login']);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (this.isFavorite(listing)) {
|
if (this.isFavorite(listing)) {
|
||||||
// Remove from favorites
|
// Remove from favorites
|
||||||
await this.listingsService.removeFavorite(listing.id, 'business');
|
await this.listingsService.removeFavorite(listing.id, 'business');
|
||||||
listing.favoritesForUser = listing.favoritesForUser.filter(email => email !== this.user!.email);
|
listing.favoritesForUser = listing.favoritesForUser.filter(email => email !== this.user!.email);
|
||||||
} else {
|
} else {
|
||||||
// Add to favorites
|
// Add to favorites
|
||||||
await this.listingsService.addToFavorites(listing.id, 'business');
|
await this.listingsService.addToFavorites(listing.id, 'business');
|
||||||
if (!listing.favoritesForUser) {
|
if (!listing.favoritesForUser) {
|
||||||
listing.favoritesForUser = [];
|
listing.favoritesForUser = [];
|
||||||
}
|
}
|
||||||
listing.favoritesForUser.push(this.user.email);
|
listing.favoritesForUser.push(this.user.email);
|
||||||
}
|
}
|
||||||
this.cdRef.detectChanges();
|
this.cdRef.detectChanges();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error toggling favorite:', error);
|
console.error('Error toggling favorite:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Share a listing - opens native share dialog or copies to clipboard
|
* Share a listing - opens native share dialog or copies to clipboard
|
||||||
*/
|
*/
|
||||||
async shareListing(event: Event, listing: BusinessListing): Promise<void> {
|
async shareListing(event: Event, listing: BusinessListing): Promise<void> {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const url = `${window.location.origin}/business/${listing.slug || listing.id}`;
|
const url = `${window.location.origin}/business/${listing.slug || listing.id}`;
|
||||||
const title = listing.title || 'Business Listing';
|
const title = listing.title || 'Business Listing';
|
||||||
|
|
||||||
// Try native share API first (works on mobile and some desktop browsers)
|
// Try native share API first (works on mobile and some desktop browsers)
|
||||||
if (navigator.share) {
|
if (navigator.share) {
|
||||||
try {
|
try {
|
||||||
await navigator.share({
|
await navigator.share({
|
||||||
title: title,
|
title: title,
|
||||||
text: `Check out this business: ${title}`,
|
text: `Check out this business: ${title}`,
|
||||||
url: url,
|
url: url,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// User cancelled or share failed - fall back to clipboard
|
// User cancelled or share failed - fall back to clipboard
|
||||||
this.copyToClipboard(url);
|
this.copyToClipboard(url);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback: open Facebook share dialog
|
// Fallback: open Facebook share dialog
|
||||||
const encodedUrl = encodeURIComponent(url);
|
const encodedUrl = encodeURIComponent(url);
|
||||||
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, '_blank', 'width=600,height=400');
|
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, '_blank', 'width=600,height=400');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy URL to clipboard and show feedback
|
* Copy URL to clipboard and show feedback
|
||||||
*/
|
*/
|
||||||
private copyToClipboard(url: string): void {
|
private copyToClipboard(url: string): void {
|
||||||
navigator.clipboard.writeText(url).then(() => {
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
// Could add a toast notification here
|
// Could add a toast notification here
|
||||||
console.log('Link copied to clipboard!');
|
console.log('Link copied to clipboard!');
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error('Failed to copy link:', err);
|
console.error('Failed to copy link:', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.destroy$.next();
|
this.destroy$.next();
|
||||||
this.destroy$.complete();
|
this.destroy$.complete();
|
||||||
// Clean up pagination links when leaving the page
|
// Clean up pagination links when leaving the page
|
||||||
this.seoService.clearPaginationLinks();
|
this.seoService.clearPaginationLinks();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update pagination SEO links (rel="next/prev") and CollectionPage schema
|
* Update pagination SEO links (rel="next/prev") and CollectionPage schema
|
||||||
*/
|
*/
|
||||||
private updatePaginationSEO(): void {
|
private updatePaginationSEO(): void {
|
||||||
const baseUrl = `${this.seoService.getBaseUrl()}/businessListings`;
|
const baseUrl = `${this.seoService.getBaseUrl()}/businessListings`;
|
||||||
|
|
||||||
// Inject rel="next" and rel="prev" links
|
// Inject rel="next" and rel="prev" links
|
||||||
this.seoService.injectPaginationLinks(baseUrl, this.page, this.pageCount);
|
this.seoService.injectPaginationLinks(baseUrl, this.page, this.pageCount);
|
||||||
|
|
||||||
// Inject CollectionPage schema for paginated results
|
// Inject CollectionPage schema for paginated results
|
||||||
const collectionSchema = this.seoService.generateCollectionPageSchema({
|
const collectionSchema = this.seoService.generateCollectionPageSchema({
|
||||||
name: 'Businesses for Sale',
|
name: 'Businesses for Sale',
|
||||||
description: 'Browse thousands of businesses for sale across the United States. Find restaurants, franchises, retail stores, and more.',
|
description: 'Browse thousands of businesses for sale across the United States. Find restaurants, franchises, retail stores, and more.',
|
||||||
totalItems: this.totalRecords,
|
totalItems: this.totalRecords,
|
||||||
itemsPerPage: LISTINGS_PER_PAGE,
|
itemsPerPage: LISTINGS_PER_PAGE,
|
||||||
currentPage: this.page,
|
currentPage: this.page,
|
||||||
baseUrl: baseUrl
|
baseUrl: baseUrl
|
||||||
});
|
});
|
||||||
this.seoService.injectStructuredData(collectionSchema);
|
this.seoService.injectStructuredData(collectionSchema);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,143 +1,143 @@
|
||||||
<div class="flex flex-col md:flex-row">
|
<div class="flex flex-col md:flex-row">
|
||||||
<!-- Filter Panel for Desktop -->
|
<!-- 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">
|
<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>
|
<app-search-modal-commercial [isModal]="false"></app-search-modal-commercial>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="w-full p-4">
|
<div class="w-full p-4">
|
||||||
<div class="container mx-auto">
|
<div class="container mx-auto">
|
||||||
<!-- Breadcrumbs -->
|
<!-- Breadcrumbs -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
|
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SEO-optimized heading -->
|
<!-- SEO-optimized heading -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h1 class="text-3xl md:text-4xl font-bold text-neutral-900 mb-2">Commercial Properties for Sale</h1>
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
@if(listings?.length > 0) {
|
@if(listings?.length > 0) {
|
||||||
<h2 class="text-2xl font-semibold text-neutral-800 mb-4">Available Commercial Property Listings</h2>
|
<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">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
@for (listing of listings; track listing.id) {
|
@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">
|
<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 -->
|
<!-- 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">
|
<div class="absolute top-4 right-4 z-10 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||||
@if(user) {
|
@if(user) {
|
||||||
<button
|
<button
|
||||||
class="bg-white rounded-full p-2 shadow-lg transition-colors"
|
class="bg-white rounded-full p-2 shadow-lg transition-colors"
|
||||||
[class.bg-red-50]="isFavorite(listing)"
|
[class.bg-red-50]="isFavorite(listing)"
|
||||||
[class.opacity-100]="isFavorite(listing)"
|
[class.opacity-100]="isFavorite(listing)"
|
||||||
[title]="isFavorite(listing) ? 'Remove from favorites' : 'Save to favorites'"
|
[title]="isFavorite(listing) ? 'Remove from favorites' : 'Save to favorites'"
|
||||||
(click)="toggleFavorite($event, listing)">
|
(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>
|
<i [class]="isFavorite(listing) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<button type="button" class="bg-white rounded-full p-2 shadow-lg hover:bg-blue-50 transition-colors"
|
<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)">
|
title="Share property" (click)="shareProperty($event, listing)">
|
||||||
<i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i>
|
<i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@if (listing.imageOrder?.length>0){
|
@if (listing.imageOrder?.length>0){
|
||||||
<img [appLazyLoad]="env.imageBaseUrl + '/pictures/property/' + listing.imagePath + '/' + listing.serialId + '/' + listing.imageOrder[0]"
|
<img [appLazyLoad]="env.imageBaseUrl + '/pictures/property/' + listing.imagePath + '/' + listing.serialId + '/' + listing.imageOrder[0]"
|
||||||
[alt]="altText.generatePropertyListingAlt(listing)"
|
[alt]="altText.generatePropertyListingAlt(listing)"
|
||||||
class="w-full h-48 object-cover"
|
class="w-full h-48 object-cover"
|
||||||
width="400"
|
width="400"
|
||||||
height="192" />
|
height="192" />
|
||||||
} @else {
|
} @else {
|
||||||
<img [appLazyLoad]="'assets/images/placeholder_properties.jpg'"
|
<img [appLazyLoad]="'assets/images/placeholder_properties.jpg'"
|
||||||
[alt]="'Commercial property placeholder - ' + listing.title"
|
[alt]="'Commercial property placeholder - ' + listing.title"
|
||||||
class="w-full h-48 object-cover"
|
class="w-full h-48 object-cover"
|
||||||
width="400"
|
width="400"
|
||||||
height="192" />
|
height="192" />
|
||||||
}
|
}
|
||||||
<div class="p-4 flex flex-col flex-grow">
|
<div class="p-4 flex flex-col flex-grow">
|
||||||
<span [class]="selectOptions.getTextColorTypeOfCommercial(listing.type)" class="text-sm font-semibold"
|
<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
|
><i [class]="selectOptions.getIconAndTextColorTypeOfCommercials(listing.type)" class="mr-1"></i> {{ selectOptions.getCommercialProperty(listing.type) }}</span
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between my-2">
|
<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>
|
<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">
|
<p class="text-sm text-neutral-600 mb-4">
|
||||||
<strong>{{ getDaysListed(listing) }} days listed</strong>
|
<strong>{{ getDaysListed(listing) }} days listed</strong>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-semibold mb-2">
|
<h3 class="text-lg font-semibold mb-2">
|
||||||
{{ listing.title }}
|
{{ listing.title }}
|
||||||
@if(listing.draft){
|
@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>
|
<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>
|
</h3>
|
||||||
<p class="text-neutral-600 mb-2">{{ listing.location.name ? listing.location.name : listing.location.county }}</p>
|
<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>
|
<p class="text-xl font-bold mb-4">{{ listing.price | currency : 'USD' : 'symbol' : '1.0-0' }}</p>
|
||||||
<div class="flex-grow"></div>
|
<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">
|
<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>
|
View Full Listing <i class="fas fa-arrow-right ml-1"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
} @else if (listings?.length === 0){
|
} @else if (listings?.length === 0){
|
||||||
<div class="w-full flex items-center flex-wrap justify-center gap-10">
|
<div class="w-full flex items-center flex-wrap justify-center gap-10">
|
||||||
<div class="grid gap-4 w-60">
|
<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">
|
<svg class="mx-auto" xmlns="http://www.w3.org/2000/svg" width="154" height="161" viewBox="0 0 154 161" fill="none">
|
||||||
<path
|
<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"
|
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"
|
fill="#EEF2FF"
|
||||||
/>
|
/>
|
||||||
<path
|
<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"
|
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"
|
fill="white"
|
||||||
stroke="#E5E7EB"
|
stroke="#E5E7EB"
|
||||||
/>
|
/>
|
||||||
<ellipse cx="80.0618" cy="81" rx="28.0342" ry="28.0342" fill="#EEF2FF" />
|
<ellipse cx="80.0618" cy="81" rx="28.0342" ry="28.0342" fill="#EEF2FF" />
|
||||||
<path
|
<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"
|
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"
|
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="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
|
<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"
|
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"
|
fill="#A5B4FC"
|
||||||
stroke="#818CF8"
|
stroke="#818CF8"
|
||||||
/>
|
/>
|
||||||
<path
|
<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"
|
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"
|
fill="#4F46E5"
|
||||||
/>
|
/>
|
||||||
<path
|
<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"
|
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"
|
fill="#4F46E5"
|
||||||
/>
|
/>
|
||||||
<path
|
<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"
|
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"
|
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="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="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" />
|
<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="56.13" cy="31.0854" r="1.36752" fill="#4F46E5" />
|
||||||
<circle cx="61.6001" 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" />
|
<circle cx="67.0702" cy="31.0854" r="1.36752" fill="#4F46E5" />
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-center text-black text-xl font-semibold leading-loose pb-2">There’s no listing here</h2>
|
<h2 class="text-center text-black text-xl font-semibold leading-loose pb-2">There’s 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>
|
<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">
|
<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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if(pageCount > 1) {
|
@if(pageCount > 1) {
|
||||||
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator>
|
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filter Button for Mobile -->
|
<!-- 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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -1,301 +1,301 @@
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
import { UntilDestroy } from '@ngneat/until-destroy';
|
import { UntilDestroy } from '@ngneat/until-destroy';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { Subject, takeUntil } from 'rxjs';
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
import { CommercialPropertyListing, SortByOptions } from '../../../../../../bizmatch-server/src/models/db.model';
|
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 { CommercialPropertyListingCriteria, KeycloakUser, LISTINGS_PER_PAGE, ResponseCommercialPropertyListingArray } from '../../../../../../bizmatch-server/src/models/main.model';
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
|
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
|
||||||
import { PaginatorComponent } from '../../../components/paginator/paginator.component';
|
import { PaginatorComponent } from '../../../components/paginator/paginator.component';
|
||||||
import { ModalService } from '../../../components/search-modal/modal.service';
|
import { ModalService } from '../../../components/search-modal/modal.service';
|
||||||
import { SearchModalCommercialComponent } from '../../../components/search-modal/search-modal-commercial.component';
|
import { SearchModalCommercialComponent } from '../../../components/search-modal/search-modal-commercial.component';
|
||||||
import { LazyLoadImageDirective } from '../../../directives/lazy-load-image.directive';
|
import { LazyLoadImageDirective } from '../../../directives/lazy-load-image.directive';
|
||||||
import { AltTextService } from '../../../services/alt-text.service';
|
import { AltTextService } from '../../../services/alt-text.service';
|
||||||
import { FilterStateService } from '../../../services/filter-state.service';
|
import { FilterStateService } from '../../../services/filter-state.service';
|
||||||
import { ImageService } from '../../../services/image.service';
|
import { ImageService } from '../../../services/image.service';
|
||||||
import { ListingsService } from '../../../services/listings.service';
|
import { ListingsService } from '../../../services/listings.service';
|
||||||
import { SearchService } from '../../../services/search.service';
|
import { SearchService } from '../../../services/search.service';
|
||||||
import { SelectOptionsService } from '../../../services/select-options.service';
|
import { SelectOptionsService } from '../../../services/select-options.service';
|
||||||
import { SeoService } from '../../../services/seo.service';
|
import { SeoService } from '../../../services/seo.service';
|
||||||
import { AuthService } from '../../../services/auth.service';
|
import { AuthService } from '../../../services/auth.service';
|
||||||
import { map2User } from '../../../utils/utils';
|
import { map2User } from '../../../utils/utils';
|
||||||
|
|
||||||
@UntilDestroy()
|
@UntilDestroy()
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-commercial-property-listings',
|
selector: 'app-commercial-property-listings',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, SearchModalCommercialComponent, LazyLoadImageDirective, BreadcrumbsComponent],
|
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, SearchModalCommercialComponent, LazyLoadImageDirective, BreadcrumbsComponent],
|
||||||
templateUrl: './commercial-property-listings.component.html',
|
templateUrl: './commercial-property-listings.component.html',
|
||||||
styleUrls: ['./commercial-property-listings.component.scss', '../../pages.scss'],
|
styleUrls: ['./commercial-property-listings.component.scss', '../../pages.scss'],
|
||||||
})
|
})
|
||||||
export class CommercialPropertyListingsComponent implements OnInit, OnDestroy {
|
export class CommercialPropertyListingsComponent implements OnInit, OnDestroy {
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
// Component properties
|
// Component properties
|
||||||
environment = environment;
|
environment = environment;
|
||||||
env = environment;
|
env = environment;
|
||||||
listings: Array<CommercialPropertyListing> = [];
|
listings: Array<CommercialPropertyListing> = [];
|
||||||
filteredListings: Array<CommercialPropertyListing> = [];
|
filteredListings: Array<CommercialPropertyListing> = [];
|
||||||
criteria: CommercialPropertyListingCriteria;
|
criteria: CommercialPropertyListingCriteria;
|
||||||
sortBy: SortByOptions | null = null;
|
sortBy: SortByOptions | null = null;
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
totalRecords = 0;
|
totalRecords = 0;
|
||||||
page = 1;
|
page = 1;
|
||||||
pageCount = 1;
|
pageCount = 1;
|
||||||
first = 0;
|
first = 0;
|
||||||
rows = LISTINGS_PER_PAGE;
|
rows = LISTINGS_PER_PAGE;
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
ts = new Date().getTime();
|
ts = new Date().getTime();
|
||||||
|
|
||||||
// Breadcrumbs
|
// Breadcrumbs
|
||||||
breadcrumbs: BreadcrumbItem[] = [
|
breadcrumbs: BreadcrumbItem[] = [
|
||||||
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
|
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
|
||||||
{ label: 'Commercial Properties' }
|
{ label: 'Commercial Properties' }
|
||||||
];
|
];
|
||||||
|
|
||||||
// User for favorites
|
// User for favorites
|
||||||
user: KeycloakUser | null = null;
|
user: KeycloakUser | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public altText: AltTextService,
|
public altText: AltTextService,
|
||||||
public selectOptions: SelectOptionsService,
|
public selectOptions: SelectOptionsService,
|
||||||
private listingsService: ListingsService,
|
private listingsService: ListingsService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private cdRef: ChangeDetectorRef,
|
private cdRef: ChangeDetectorRef,
|
||||||
private imageService: ImageService,
|
private imageService: ImageService,
|
||||||
private searchService: SearchService,
|
private searchService: SearchService,
|
||||||
private modalService: ModalService,
|
private modalService: ModalService,
|
||||||
private filterStateService: FilterStateService,
|
private filterStateService: FilterStateService,
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private seoService: SeoService,
|
private seoService: SeoService,
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
// Load user for favorites functionality
|
// Load user for favorites functionality
|
||||||
const token = await this.authService.getToken();
|
const token = await this.authService.getToken();
|
||||||
this.user = map2User(token);
|
this.user = map2User(token);
|
||||||
|
|
||||||
// Set SEO meta tags for commercial property listings page
|
// Set SEO meta tags for commercial property listings page
|
||||||
this.seoService.updateMetaTags({
|
this.seoService.updateMetaTags({
|
||||||
title: 'Commercial Properties for Sale - Office, Retail, Industrial Real Estate | BizMatch',
|
title: 'Commercial Properties for Sale - Office, Retail | 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.',
|
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',
|
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'
|
type: 'website'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Subscribe to state changes
|
// Subscribe to state changes
|
||||||
this.filterStateService
|
this.filterStateService
|
||||||
.getState$('commercialPropertyListings')
|
.getState$('commercialPropertyListings')
|
||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe(state => {
|
.subscribe(state => {
|
||||||
this.criteria = state.criteria;
|
this.criteria = state.criteria;
|
||||||
this.sortBy = state.sortBy;
|
this.sortBy = state.sortBy;
|
||||||
// Automatically search when state changes
|
// Automatically search when state changes
|
||||||
this.search();
|
this.search();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Subscribe to search triggers (if triggered from other components)
|
// Subscribe to search triggers (if triggered from other components)
|
||||||
this.searchService.searchTrigger$.pipe(takeUntil(this.destroy$)).subscribe(type => {
|
this.searchService.searchTrigger$.pipe(takeUntil(this.destroy$)).subscribe(type => {
|
||||||
if (type === 'commercialPropertyListings') {
|
if (type === 'commercialPropertyListings') {
|
||||||
this.search();
|
this.search();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(): Promise<void> {
|
async search(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Perform search
|
// Perform search
|
||||||
const listingResponse = await this.listingsService.getListings('commercialProperty');
|
const listingResponse = await this.listingsService.getListings('commercialProperty');
|
||||||
this.listings = (listingResponse as ResponseCommercialPropertyListingArray).results;
|
this.listings = (listingResponse as ResponseCommercialPropertyListingArray).results;
|
||||||
this.totalRecords = (listingResponse as ResponseCommercialPropertyListingArray).totalCount;
|
this.totalRecords = (listingResponse as ResponseCommercialPropertyListingArray).totalCount;
|
||||||
this.pageCount = Math.ceil(this.totalRecords / LISTINGS_PER_PAGE);
|
this.pageCount = Math.ceil(this.totalRecords / LISTINGS_PER_PAGE);
|
||||||
this.page = this.criteria.page || 1;
|
this.page = this.criteria.page || 1;
|
||||||
|
|
||||||
// Update pagination SEO links
|
// Update pagination SEO links
|
||||||
this.updatePaginationSEO();
|
this.updatePaginationSEO();
|
||||||
|
|
||||||
// Update view
|
// Update view
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
this.cdRef.detectChanges();
|
this.cdRef.detectChanges();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search error:', error);
|
console.error('Search error:', error);
|
||||||
// Handle error appropriately
|
// Handle error appropriately
|
||||||
this.listings = [];
|
this.listings = [];
|
||||||
this.totalRecords = 0;
|
this.totalRecords = 0;
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onPageChange(page: number): void {
|
onPageChange(page: number): void {
|
||||||
// Update only pagination properties
|
// Update only pagination properties
|
||||||
this.filterStateService.updateCriteria('commercialPropertyListings', {
|
this.filterStateService.updateCriteria('commercialPropertyListings', {
|
||||||
page: page,
|
page: page,
|
||||||
start: (page - 1) * LISTINGS_PER_PAGE,
|
start: (page - 1) * LISTINGS_PER_PAGE,
|
||||||
length: LISTINGS_PER_PAGE,
|
length: LISTINGS_PER_PAGE,
|
||||||
});
|
});
|
||||||
// Search will be triggered automatically through state subscription
|
// Search will be triggered automatically through state subscription
|
||||||
}
|
}
|
||||||
|
|
||||||
clearAllFilters(): void {
|
clearAllFilters(): void {
|
||||||
// Reset criteria but keep sortBy
|
// Reset criteria but keep sortBy
|
||||||
this.filterStateService.clearFilters('commercialPropertyListings');
|
this.filterStateService.clearFilters('commercialPropertyListings');
|
||||||
// Search will be triggered automatically through state subscription
|
// Search will be triggered automatically through state subscription
|
||||||
}
|
}
|
||||||
|
|
||||||
async openFilterModal(): Promise<void> {
|
async openFilterModal(): Promise<void> {
|
||||||
// Open modal with current criteria
|
// Open modal with current criteria
|
||||||
const currentCriteria = this.filterStateService.getCriteria('commercialPropertyListings');
|
const currentCriteria = this.filterStateService.getCriteria('commercialPropertyListings');
|
||||||
const modalResult = await this.modalService.showModal(currentCriteria);
|
const modalResult = await this.modalService.showModal(currentCriteria);
|
||||||
|
|
||||||
if (modalResult.accepted) {
|
if (modalResult.accepted) {
|
||||||
// Modal accepted changes - state is updated by modal
|
// Modal accepted changes - state is updated by modal
|
||||||
// Search will be triggered automatically through state subscription
|
// Search will be triggered automatically through state subscription
|
||||||
} else {
|
} else {
|
||||||
// Modal was cancelled - no action needed
|
// Modal was cancelled - no action needed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper methods for template
|
// Helper methods for template
|
||||||
getTS(): number {
|
getTS(): number {
|
||||||
return new Date().getTime();
|
return new Date().getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
getDaysListed(listing: CommercialPropertyListing): number {
|
getDaysListed(listing: CommercialPropertyListing): number {
|
||||||
return dayjs().diff(listing.created, 'day');
|
return dayjs().diff(listing.created, 'day');
|
||||||
}
|
}
|
||||||
|
|
||||||
getListingImage(listing: CommercialPropertyListing): string {
|
getListingImage(listing: CommercialPropertyListing): string {
|
||||||
if (listing.imageOrder?.length > 0) {
|
if (listing.imageOrder?.length > 0) {
|
||||||
return `${this.env.imageBaseUrl}/pictures/property/${listing.imagePath}/${listing.serialId}/${listing.imageOrder[0]}`;
|
return `${this.env.imageBaseUrl}/pictures/property/${listing.imagePath}/${listing.serialId}/${listing.imageOrder[0]}`;
|
||||||
}
|
}
|
||||||
return 'assets/images/placeholder_properties.jpg';
|
return 'assets/images/placeholder_properties.jpg';
|
||||||
}
|
}
|
||||||
|
|
||||||
getListingPrice(listing: CommercialPropertyListing): string {
|
getListingPrice(listing: CommercialPropertyListing): string {
|
||||||
if (!listing.price) return 'Price on Request';
|
if (!listing.price) return 'Price on Request';
|
||||||
return `$${listing.price.toLocaleString()}`;
|
return `$${listing.price.toLocaleString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
getListingLocation(listing: CommercialPropertyListing): string {
|
getListingLocation(listing: CommercialPropertyListing): string {
|
||||||
if (!listing.location) return 'Location not specified';
|
if (!listing.location) return 'Location not specified';
|
||||||
return listing.location.name || listing.location.county || 'Location not specified';
|
return listing.location.name || listing.location.county || 'Location not specified';
|
||||||
}
|
}
|
||||||
|
|
||||||
navigateToDetails(listingId: string): void {
|
navigateToDetails(listingId: string): void {
|
||||||
this.router.navigate(['/details-commercial-property-listing', listingId]);
|
this.router.navigate(['/details-commercial-property-listing', listingId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if listing is already in user's favorites
|
* Check if listing is already in user's favorites
|
||||||
*/
|
*/
|
||||||
isFavorite(listing: CommercialPropertyListing): boolean {
|
isFavorite(listing: CommercialPropertyListing): boolean {
|
||||||
if (!this.user?.email || !listing.favoritesForUser) return false;
|
if (!this.user?.email || !listing.favoritesForUser) return false;
|
||||||
return listing.favoritesForUser.includes(this.user.email);
|
return listing.favoritesForUser.includes(this.user.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle favorite status for a listing
|
* Toggle favorite status for a listing
|
||||||
*/
|
*/
|
||||||
async toggleFavorite(event: Event, listing: CommercialPropertyListing): Promise<void> {
|
async toggleFavorite(event: Event, listing: CommercialPropertyListing): Promise<void> {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (!this.user?.email) {
|
if (!this.user?.email) {
|
||||||
// User not logged in - redirect to login
|
// User not logged in - redirect to login
|
||||||
this.router.navigate(['/login']);
|
this.router.navigate(['/login']);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (this.isFavorite(listing)) {
|
if (this.isFavorite(listing)) {
|
||||||
// Remove from favorites
|
// Remove from favorites
|
||||||
await this.listingsService.removeFavorite(listing.id, 'commercialProperty');
|
await this.listingsService.removeFavorite(listing.id, 'commercialProperty');
|
||||||
listing.favoritesForUser = listing.favoritesForUser.filter(email => email !== this.user!.email);
|
listing.favoritesForUser = listing.favoritesForUser.filter(email => email !== this.user!.email);
|
||||||
} else {
|
} else {
|
||||||
// Add to favorites
|
// Add to favorites
|
||||||
await this.listingsService.addToFavorites(listing.id, 'commercialProperty');
|
await this.listingsService.addToFavorites(listing.id, 'commercialProperty');
|
||||||
if (!listing.favoritesForUser) {
|
if (!listing.favoritesForUser) {
|
||||||
listing.favoritesForUser = [];
|
listing.favoritesForUser = [];
|
||||||
}
|
}
|
||||||
listing.favoritesForUser.push(this.user.email);
|
listing.favoritesForUser.push(this.user.email);
|
||||||
}
|
}
|
||||||
this.cdRef.detectChanges();
|
this.cdRef.detectChanges();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error toggling favorite:', error);
|
console.error('Error toggling favorite:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.destroy$.next();
|
this.destroy$.next();
|
||||||
this.destroy$.complete();
|
this.destroy$.complete();
|
||||||
// Clean up pagination links when leaving the page
|
// Clean up pagination links when leaving the page
|
||||||
this.seoService.clearPaginationLinks();
|
this.seoService.clearPaginationLinks();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update pagination SEO links (rel="next/prev") and CollectionPage schema
|
* Update pagination SEO links (rel="next/prev") and CollectionPage schema
|
||||||
*/
|
*/
|
||||||
private updatePaginationSEO(): void {
|
private updatePaginationSEO(): void {
|
||||||
const baseUrl = `${this.seoService.getBaseUrl()}/commercialPropertyListings`;
|
const baseUrl = `${this.seoService.getBaseUrl()}/commercialPropertyListings`;
|
||||||
|
|
||||||
// Inject rel="next" and rel="prev" links
|
// Inject rel="next" and rel="prev" links
|
||||||
this.seoService.injectPaginationLinks(baseUrl, this.page, this.pageCount);
|
this.seoService.injectPaginationLinks(baseUrl, this.page, this.pageCount);
|
||||||
|
|
||||||
// Inject CollectionPage schema for paginated results
|
// Inject CollectionPage schema for paginated results
|
||||||
const collectionSchema = this.seoService.generateCollectionPageSchema({
|
const collectionSchema = this.seoService.generateCollectionPageSchema({
|
||||||
name: 'Commercial Properties for Sale',
|
name: 'Commercial Properties for Sale',
|
||||||
description: 'Browse commercial real estate listings including office buildings, retail spaces, warehouses, and industrial properties across the United States.',
|
description: 'Browse commercial real estate listings including office buildings, retail spaces, warehouses, and industrial properties across the United States.',
|
||||||
totalItems: this.totalRecords,
|
totalItems: this.totalRecords,
|
||||||
itemsPerPage: LISTINGS_PER_PAGE,
|
itemsPerPage: LISTINGS_PER_PAGE,
|
||||||
currentPage: this.page,
|
currentPage: this.page,
|
||||||
baseUrl: baseUrl
|
baseUrl: baseUrl
|
||||||
});
|
});
|
||||||
this.seoService.injectStructuredData(collectionSchema);
|
this.seoService.injectStructuredData(collectionSchema);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Share property listing
|
* Share property listing
|
||||||
*/
|
*/
|
||||||
async shareProperty(event: Event, listing: CommercialPropertyListing): Promise<void> {
|
async shareProperty(event: Event, listing: CommercialPropertyListing): Promise<void> {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const url = `${window.location.origin}/commercial-property/${listing.slug || listing.id}`;
|
const url = `${window.location.origin}/commercial-property/${listing.slug || listing.id}`;
|
||||||
const title = listing.title || 'Commercial Property Listing';
|
const title = listing.title || 'Commercial Property Listing';
|
||||||
|
|
||||||
// Try native share API first (works on mobile and some desktop browsers)
|
// Try native share API first (works on mobile and some desktop browsers)
|
||||||
if (navigator.share) {
|
if (navigator.share) {
|
||||||
try {
|
try {
|
||||||
await navigator.share({
|
await navigator.share({
|
||||||
title: title,
|
title: title,
|
||||||
text: `Check out this property: ${title}`,
|
text: `Check out this property: ${title}`,
|
||||||
url: url,
|
url: url,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// User cancelled or share failed - fall back to clipboard
|
// User cancelled or share failed - fall back to clipboard
|
||||||
this.copyToClipboard(url);
|
this.copyToClipboard(url);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback: open Facebook share dialog
|
// Fallback: open Facebook share dialog
|
||||||
const encodedUrl = encodeURIComponent(url);
|
const encodedUrl = encodeURIComponent(url);
|
||||||
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, '_blank', 'width=600,height=400');
|
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, '_blank', 'width=600,height=400');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy URL to clipboard and show feedback
|
* Copy URL to clipboard and show feedback
|
||||||
*/
|
*/
|
||||||
private copyToClipboard(url: string): void {
|
private copyToClipboard(url: string): void {
|
||||||
navigator.clipboard.writeText(url).then(() => {
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
console.log('Link copied to clipboard!');
|
console.log('Link copied to clipboard!');
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error('Failed to copy link:', err);
|
console.error('Failed to copy link:', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,42 @@
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
|
|
||||||
import { AuthService } from '../../services/auth.service';
|
import { AuthService } from '../../services/auth.service';
|
||||||
import { UserService } from '../../services/user.service';
|
import { UserService } from '../../services/user.service';
|
||||||
import { map2User } from '../../utils/utils';
|
import { map2User } from '../../utils/utils';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-login',
|
selector: 'app-login',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterModule],
|
imports: [CommonModule, RouterModule],
|
||||||
template: ``,
|
template: ``,
|
||||||
})
|
})
|
||||||
export class LoginComponent {
|
export class LoginComponent {
|
||||||
page: string | undefined = this.activatedRoute.snapshot.params['page'] as string | undefined;
|
page: string | undefined = this.activatedRoute.snapshot.params['page'] as string | undefined;
|
||||||
constructor(
|
constructor(
|
||||||
public userService: UserService,
|
public userService: UserService,
|
||||||
private activatedRoute: ActivatedRoute,
|
private activatedRoute: ActivatedRoute,
|
||||||
|
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
) {}
|
) {}
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
const token = await this.authService.getToken();
|
const token = await this.authService.getToken();
|
||||||
const keycloakUser = map2User(token);
|
const keycloakUser = map2User(token);
|
||||||
const email = keycloakUser.email;
|
const email = keycloakUser.email;
|
||||||
const user = await this.userService.getByMail(email);
|
const user = await this.userService.getByMail(email);
|
||||||
// if (!user.subscriptionPlan) {
|
// if (!user.subscriptionPlan) {
|
||||||
// const subscriptions = await lastValueFrom(this.subscriptionService.getAllSubscriptions(user.email));
|
// const subscriptions = await lastValueFrom(this.subscriptionService.getAllSubscriptions(user.email));
|
||||||
// const activeSubscription = subscriptions.filter(s => s.status === 'active');
|
// const activeSubscription = subscriptions.filter(s => s.status === 'active');
|
||||||
// if (activeSubscription.length > 0) {
|
// if (activeSubscription.length > 0) {
|
||||||
// user.subscriptionPlan = activeSubscription[0].metadata['plan'] === 'Broker Plan' ? 'broker' : 'professional';
|
// user.subscriptionPlan = activeSubscription[0].metadata['plan'] === 'Broker Plan' ? 'broker' : 'professional';
|
||||||
// this.userService.saveGuaranteed(user);
|
// this.userService.saveGuaranteed(user);
|
||||||
// } else {
|
// } else {
|
||||||
// this.router.navigate([`/home`]);
|
// this.router.navigate([`/home`]);
|
||||||
// return;
|
// return;
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
this.router.navigate([`/${this.page}`]);
|
this.router.navigate([`/${this.page}`]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,335 +1,335 @@
|
||||||
<div class="container mx-auto p-4">
|
<div class="container mx-auto p-4">
|
||||||
@if (user){
|
@if (user){
|
||||||
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6">
|
<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">
|
<form #accountForm="ngForm" class="space-y-4">
|
||||||
<h2 class="text-2xl font-bold mb-4">Account Details</h2>
|
<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="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<label for="email" class="block text-sm font-medium text-gray-700">E-mail (required)</label>
|
<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
|
<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" />
|
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
|
<p class="text-xs text-gray-500 mt-1">You can only modify your email by contacting us at
|
||||||
support@bizmatch.net</p>
|
support@bizmatch.net</p>
|
||||||
</div>
|
</div>
|
||||||
@if (isProfessional || (authService.isAdmin() | async)){
|
@if (isProfessional || (authService.isAdmin() | async)){
|
||||||
<div class="flex flex-row items-center justify-around md:space-x-4">
|
<div class="flex flex-row items-center justify-around md:space-x-4">
|
||||||
<div class="flex h-full justify-between flex-col">
|
<div class="flex h-full justify-between flex-col">
|
||||||
<p class="text-sm font-medium text-gray-700 mb-1">Company Logo</p>
|
<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">
|
<div class="w-20 h-20 w-full rounded-md flex items-center justify-center relative">
|
||||||
@if(user?.hasCompanyLogo){
|
@if(user?.hasCompanyLogo){
|
||||||
<img src="{{ companyLogoUrl }}" alt="Company logo" class="max-w-full max-h-full" />
|
<img src="{{ companyLogoUrl }}" alt="Company logo" class="max-w-full max-h-full" />
|
||||||
<div
|
<div
|
||||||
class="absolute top-[-0.5rem] right-[0rem] bg-white rounded-full p-1 drop-shadow-custom-bg hover:cursor-pointer"
|
class="absolute top-[-0.5rem] right-[0rem] bg-white rounded-full p-1 drop-shadow-custom-bg hover:cursor-pointer"
|
||||||
(click)="deleteConfirm('logo')">
|
(click)="deleteConfirm('logo')">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
<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">
|
class="w-4 h-4 text-gray-600">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<img src="/assets/images/placeholder.png" class="max-w-full max-h-full" />
|
<img src="/assets/images/placeholder.png" class="max-w-full max-h-full" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<button type="button"
|
<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"
|
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()">
|
(click)="uploadCompanyLogo()">
|
||||||
Upload
|
Upload
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex h-full justify-between flex-col">
|
<div class="flex h-full justify-between flex-col">
|
||||||
<p class="text-sm font-medium text-gray-700 mb-1">Your Profile Picture</p>
|
<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">
|
<div class="w-20 h-20 w-full rounded-md flex items-center justify-center relative">
|
||||||
@if(user?.hasProfile){
|
@if(user?.hasProfile){
|
||||||
<img src="{{ profileUrl }}" alt="Profile picture" class="max-w-full max-h-full" />
|
<img src="{{ profileUrl }}" alt="Profile picture" class="max-w-full max-h-full" />
|
||||||
<div
|
<div
|
||||||
class="absolute top-[-0.5rem] right-[0rem] bg-white rounded-full p-1 drop-shadow-custom-bg hover:cursor-pointer"
|
class="absolute top-[-0.5rem] right-[0rem] bg-white rounded-full p-1 drop-shadow-custom-bg hover:cursor-pointer"
|
||||||
(click)="deleteConfirm('profile')">
|
(click)="deleteConfirm('profile')">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
<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">
|
class="w-4 h-4 text-gray-600">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<img src="/assets/images/placeholder.png" class="max-w-full max-h-full" />
|
<img src="/assets/images/placeholder.png" class="max-w-full max-h-full" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<button type="button"
|
<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"
|
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()">
|
(click)="uploadProfile()">
|
||||||
Upload
|
Upload
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<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="First Name" name="firstname" [(ngModel)]="user.firstname"></app-validated-input>
|
||||||
<app-validated-input label="Last Name" name="lastname" [(ngModel)]="user.lastname"></app-validated-input>
|
<app-validated-input label="Last Name" name="lastname" [(ngModel)]="user.lastname"></app-validated-input>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<!-- <div>
|
<!-- <div>
|
||||||
<label for="customerType" class="block text-sm font-medium text-gray-700">Customer Type</label>
|
<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">
|
<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>
|
<option *ngFor="let type of customerTypes" [value]="type">{{ type | titlecase }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div> -->
|
</div> -->
|
||||||
@if ((authService.isAdmin() | async) && !id){
|
@if ((authService.isAdmin() | async) && !id){
|
||||||
<div>
|
<div>
|
||||||
<label for="customerType" class="block text-sm font-medium text-gray-700">User Type</label>
|
<label for="customerType" class="block text-sm font-medium text-gray-700">User Type</label>
|
||||||
<span
|
<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>
|
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>
|
</div>
|
||||||
|
|
||||||
}@else{
|
}@else{
|
||||||
<app-validated-select label="Customer Type" name="customerType" [(ngModel)]="user.customerType"
|
<app-validated-select label="Customer Type" name="customerType" [(ngModel)]="user.customerType"
|
||||||
[options]="customerTypeOptions"></app-validated-select>
|
[options]="customerTypeOptions"></app-validated-select>
|
||||||
} @if (isProfessional){
|
} @if (isProfessional){
|
||||||
<!-- <div>
|
<!-- <div>
|
||||||
<label for="customerSubType" class="block text-sm font-medium text-gray-700">Professional Type</label>
|
<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">
|
<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>
|
<option *ngFor="let subType of customerSubTypes" [value]="subType">{{ subType | titlecase }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div> -->
|
</div> -->
|
||||||
<app-validated-select label="Professional Type" name="customerSubType" [(ngModel)]="user.customerSubType"
|
<app-validated-select label="Professional Type" name="customerSubType" [(ngModel)]="user.customerSubType"
|
||||||
[options]="customerSubTypeOptions"></app-validated-select>
|
[options]="customerSubTypeOptions"></app-validated-select>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (isProfessional){
|
@if (isProfessional){
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<!-- <div>
|
<!-- <div>
|
||||||
<label for="companyName" class="block text-sm font-medium text-gray-700">Company Name</label>
|
<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" />
|
<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> -->
|
||||||
<!-- <div>
|
<!-- <div>
|
||||||
<label for="description" class="block text-sm font-medium text-gray-700">Describe yourself</label>
|
<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" />
|
<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> -->
|
</div> -->
|
||||||
<app-validated-input label="Company Name" name="companyName"
|
<app-validated-input label="Company Name" name="companyName"
|
||||||
[(ngModel)]="user.companyName"></app-validated-input>
|
[(ngModel)]="user.companyName"></app-validated-input>
|
||||||
<app-validated-input label="Describe Yourself" name="description"
|
<app-validated-input label="Describe Yourself" name="description"
|
||||||
[(ngModel)]="user.description"></app-validated-input>
|
[(ngModel)]="user.description"></app-validated-input>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<!-- <div>
|
<!-- <div>
|
||||||
<label for="phoneNumber" class="block text-sm font-medium text-gray-700">Your Phone Number</label>
|
<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" />
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label for="companyWebsite" class="block text-sm font-medium text-gray-700">Company Website</label>
|
<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" />
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label for="companyLocation" class="block text-sm font-medium text-gray-700">Company Location</label>
|
<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" />
|
<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> -->
|
</div> -->
|
||||||
<app-validated-input label="Your Phone Number" name="phoneNumber" [(ngModel)]="user.phoneNumber"
|
<app-validated-input label="Your Phone Number" name="phoneNumber" [(ngModel)]="user.phoneNumber"
|
||||||
mask="(000) 000-0000"></app-validated-input>
|
mask="(000) 000-0000"></app-validated-input>
|
||||||
<app-validated-input label="Company Website" name="companyWebsite"
|
<app-validated-input label="Company Website" name="companyWebsite"
|
||||||
[(ngModel)]="user.companyWebsite"></app-validated-input>
|
[(ngModel)]="user.companyWebsite"></app-validated-input>
|
||||||
<!-- <app-validated-input label="Company Location" name="companyLocation" [(ngModel)]="user.companyLocation"></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-city label="Company Location" name="location" [(ngModel)]="user.location"></app-validated-city> -->
|
||||||
<app-validated-location label="Company Location" name="location"
|
<app-validated-location label="Company Location" name="location"
|
||||||
[(ngModel)]="user.location"></app-validated-location>
|
[(ngModel)]="user.location"></app-validated-location>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- <div>
|
<!-- <div>
|
||||||
<label for="companyOverview" class="block text-sm font-medium text-gray-700">Company Overview</label>
|
<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>
|
<quill-editor [(ngModel)]="user.companyOverview" name="companyOverview" [modules]="quillModules"></quill-editor>
|
||||||
</div> -->
|
</div> -->
|
||||||
<div>
|
<div>
|
||||||
<app-validated-quill label="Company Overview" name="companyOverview"
|
<app-validated-quill label="Company Overview" name="companyOverview"
|
||||||
[(ngModel)]="user.companyOverview"></app-validated-quill>
|
[(ngModel)]="user.companyOverview"></app-validated-quill>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<!-- <label for="offeredServices" class="block text-sm font-medium text-gray-700">Services We Offer</label>
|
<!-- <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> -->
|
<quill-editor [(ngModel)]="user.offeredServices" name="offeredServices" [modules]="quillModules"></quill-editor> -->
|
||||||
<app-validated-quill label="Services We Offer" name="offeredServices"
|
<app-validated-quill label="Services We Offer" name="offeredServices"
|
||||||
[(ngModel)]="user.offeredServices"></app-validated-quill>
|
[(ngModel)]="user.offeredServices"></app-validated-quill>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-gray-700 mb-2 relative w-fit">
|
<h3 class="text-lg font-medium text-gray-700 mb-2 relative w-fit">
|
||||||
Areas We Serve @if(getValidationMessage('areasServed')){
|
Areas We Serve @if(getValidationMessage('areasServed')){
|
||||||
<div [attr.data-tooltip-target]="tooltipTargetAreasServed"
|
<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">
|
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>
|
</div>
|
||||||
<app-tooltip [id]="tooltipTargetAreasServed" [text]="getValidationMessage('areasServed')"></app-tooltip>
|
<app-tooltip [id]="tooltipTargetAreasServed" [text]="getValidationMessage('areasServed')"></app-tooltip>
|
||||||
}
|
}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid grid-cols-12 gap-4">
|
<div class="grid grid-cols-12 gap-4">
|
||||||
<div class="col-span-6">
|
<div class="col-span-6">
|
||||||
<label for="state" class="block text-sm font-medium text-gray-700">State</label>
|
<label for="state" class="block text-sm font-medium text-gray-700">State</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-5">
|
<div class="col-span-5">
|
||||||
<label for="county" class="block text-sm font-medium text-gray-700">County</label>
|
<label for="county" class="block text-sm font-medium text-gray-700">County</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@for (areasServed of user.areasServed; track areasServed; let i=$index){
|
@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="grid grid-cols-12 md:gap-4 gap-1 mb-3 md:mb-1">
|
||||||
<div class="col-span-6">
|
<div class="col-span-6">
|
||||||
<ng-select [items]="selectOptions?.states" bindLabel="name" bindValue="value"
|
<ng-select [items]="selectOptions?.states" bindLabel="name" bindValue="value"
|
||||||
[(ngModel)]="areasServed.state" (ngModelChange)="setState(i, $event)" name="areasServed_state{{ i }}">
|
[(ngModel)]="areasServed.state" (ngModelChange)="setState(i, $event)" name="areasServed_state{{ i }}">
|
||||||
</ng-select>
|
</ng-select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-5">
|
<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" /> -->
|
<!-- <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"
|
<app-validated-county name="county{{ i }}" [(ngModel)]="areasServed.county"
|
||||||
labelClasses="text-gray-900 font-medium" [state]="areasServed.state"
|
labelClasses="text-gray-900 font-medium" [state]="areasServed.state"
|
||||||
[readonly]="!areasServed.state"></app-validated-county>
|
[readonly]="!areasServed.state"></app-validated-county>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-1">
|
<div class="col-span-1">
|
||||||
<button type="button" class="px-2 py-1 bg-red-500 text-white rounded-md h-[42px] w-8"
|
<button type="button" class="px-2 py-1 bg-red-500 text-white rounded-md h-[42px] w-8"
|
||||||
(click)="removeArea(i)">-</button>
|
(click)="removeArea(i)">-</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="mt-2">
|
<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"
|
<button type="button" class="px-2 py-1 bg-green-500 text-white rounded-md mr-2 h-[42px] w-8"
|
||||||
(click)="addArea()">+</button>
|
(click)="addArea()">+</button>
|
||||||
|
|
||||||
<span class="text-sm text-gray-500 ml-2">[Add more Areas or remove existing ones.]</span>
|
<span class="text-sm text-gray-500 ml-2">[Add more Areas or remove existing ones.]</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-gray-700 mb-2 relative">
|
<h3 class="text-lg font-medium text-gray-700 mb-2 relative">
|
||||||
Licensed In@if(getValidationMessage('licensedIn')){
|
Licensed In@if(getValidationMessage('licensedIn')){
|
||||||
<div [attr.data-tooltip-target]="tooltipTargetLicensed"
|
<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">
|
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>
|
</div>
|
||||||
<app-tooltip [id]="tooltipTargetLicensed" [text]="getValidationMessage('licensedIn')"></app-tooltip>
|
<app-tooltip [id]="tooltipTargetLicensed" [text]="getValidationMessage('licensedIn')"></app-tooltip>
|
||||||
}
|
}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid grid-cols-12 gap-4">
|
<div class="grid grid-cols-12 gap-4">
|
||||||
<div class="col-span-6">
|
<div class="col-span-6">
|
||||||
<label for="state" class="block text-sm font-medium text-gray-700">State</label>
|
<label for="state" class="block text-sm font-medium text-gray-700">State</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-5">
|
<div class="col-span-5">
|
||||||
<label for="county" class="block text-sm font-medium text-gray-700">License Number</label>
|
<label for="county" class="block text-sm font-medium text-gray-700">License Number</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@for (licensedIn of user.licensedIn; track licensedIn; let i=$index){
|
@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="grid grid-cols-12 md:gap-4 gap-1 mb-3 md:mb-1">
|
||||||
<div class="col-span-6">
|
<div class="col-span-6">
|
||||||
<ng-select [items]="selectOptions?.states" bindLabel="name" bindValue="value" [(ngModel)]="licensedIn.state"
|
<ng-select [items]="selectOptions?.states" bindLabel="name" bindValue="value" [(ngModel)]="licensedIn.state"
|
||||||
name="licensedIn_state{{ i }}"> </ng-select>
|
name="licensedIn_state{{ i }}"> </ng-select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-5">
|
<div class="col-span-5">
|
||||||
<input type="text" id="licenseNumber{{ i }}" name="licenseNumber{{ i }}" [(ngModel)]="licensedIn.registerNo"
|
<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" />
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" />
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="px-2 py-1 bg-red-500 text-white rounded-md h-[42px] w-8"
|
<button type="button" class="px-2 py-1 bg-red-500 text-white rounded-md h-[42px] w-8"
|
||||||
(click)="removeLicence(i)">-</button>
|
(click)="removeLicence(i)">-</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="mt-2">
|
<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"
|
<button type="button" class="px-2 py-1 bg-green-500 text-white rounded-md mr-2 h-[42px] w-8"
|
||||||
(click)="addLicence()">+</button>
|
(click)="addLicence()">+</button>
|
||||||
<span class="text-sm text-gray-500 ml-2">[Add more licenses or remove existing ones.]</span>
|
<span class="text-sm text-gray-500 ml-2">[Add more licenses or remove existing ones.]</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center !my-8">
|
<div class="flex items-center !my-8">
|
||||||
<label class="flex items-center cursor-pointer">
|
<label class="flex items-center cursor-pointer">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input type="checkbox" [(ngModel)]="user.showInDirectory" name="showInDirectory" class="hidden" />
|
<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 class="toggle-bg block w-12 h-6 rounded-full bg-gray-600 transition"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-3 text-gray-700 font-medium">Show your profile in Professional Directory</div>
|
<div class="ml-3 text-gray-700 font-medium">Show your profile in Professional Directory</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="flex justify-start">
|
<div class="flex justify-start">
|
||||||
<button type="submit"
|
<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"
|
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)">
|
(click)="updateProfile(user)">
|
||||||
Update Profile
|
Update Profile
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<!-- <div class="mt-8 max-lg:hidden">
|
<!-- <div class="mt-8 max-lg:hidden">
|
||||||
<h3 class="text-lg font-medium text-gray-700 mb-2">Membership Level</h3>
|
<h3 class="text-lg font-medium text-gray-700 mb-2">Membership Level</h3>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<div class="inline-block min-w-full">
|
<div class="inline-block min-w-full">
|
||||||
<div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
|
<div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<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">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">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">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">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>
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
@for (subscription of subscriptions; track subscriptions; let i=$index){
|
@for (subscription of subscriptions; track subscriptions; let i=$index){
|
||||||
<tr>
|
<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">{{ 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">{{ 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">{{ 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">{{ getNextSettlement(i) }}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ getStatus(i) }}</td>
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ getStatus(i) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 sm:hidden">
|
<div class="mt-8 sm:hidden">
|
||||||
<h3 class="text-lg font-medium text-gray-700 mb-1">Membership Level</h3>
|
<h3 class="text-lg font-medium text-gray-700 mb-1">Membership Level</h3>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
@for (subscription of subscriptions; track subscriptions; let i=$index){
|
@for (subscription of subscriptions; track subscriptions; let i=$index){
|
||||||
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||||
<div class="px-4 py-5 sm:px-6">
|
<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">
|
<dl class="grid grid-cols-1 gap-x-4 gap-y-2 sm:grid-cols-2">
|
||||||
<div class="sm:col-span-1 flex">
|
<div class="sm:col-span-1 flex">
|
||||||
<dt class="text-sm font-bold text-gray-500 mr-2">Level</dt>
|
<dt class="text-sm font-bold text-gray-500 mr-2">Level</dt>
|
||||||
<dd class="text-sm text-gray-900">{{ getLevel(i) }}</dd>
|
<dd class="text-sm text-gray-900">{{ getLevel(i) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="sm:col-span-1 flex">
|
<div class="sm:col-span-1 flex">
|
||||||
<dt class="text-sm font-bold text-gray-500 mr-2">Start Date</dt>
|
<dt class="text-sm font-bold text-gray-500 mr-2">Start Date</dt>
|
||||||
<dd class="text-sm text-gray-900">{{ getStartDate(i) }}</dd>
|
<dd class="text-sm text-gray-900">{{ getStartDate(i) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="sm:col-span-1 flex">
|
<div class="sm:col-span-1 flex">
|
||||||
<dt class="text-sm font-bold text-gray-500 mr-2">End Date</dt>
|
<dt class="text-sm font-bold text-gray-500 mr-2">End Date</dt>
|
||||||
<dd class="text-sm text-gray-900">{{ getEndDate(i) }}</dd>
|
<dd class="text-sm text-gray-900">{{ getEndDate(i) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="sm:col-span-1 flex">
|
<div class="sm:col-span-1 flex">
|
||||||
<dt class="text-sm font-bold text-gray-500 mr-2">Next Settlement</dt>
|
<dt class="text-sm font-bold text-gray-500 mr-2">Next Settlement</dt>
|
||||||
<dd class="text-sm text-gray-900">{{ getNextSettlement(i) }}</dd>
|
<dd class="text-sm text-gray-900">{{ getNextSettlement(i) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="sm:col-span-1 flex">
|
<div class="sm:col-span-1 flex">
|
||||||
<dt class="text-sm font-bold text-gray-500 mr-2">Status</dt>
|
<dt class="text-sm font-bold text-gray-500 mr-2">Status</dt>
|
||||||
<dd class="text-sm text-gray-900">{{ getStatus(i) }}</dd>
|
<dd class="text-sm text-gray-900">{{ getStatus(i) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div> -->
|
</div> -->
|
||||||
<!-- @if(user.subscriptionPlan==='free'){
|
<!-- @if(user.subscriptionPlan==='free'){
|
||||||
<div class="flex justify-start">
|
<div class="flex justify-start">
|
||||||
<button
|
<button
|
||||||
routerLink="/pricing"
|
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"
|
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
|
Upgrade Subscription Plan
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
} -->
|
} -->
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<app-image-crop-and-upload [uploadParams]="uploadParams"
|
<app-image-crop-and-upload [uploadParams]="uploadParams"
|
||||||
(uploadFinished)="uploadFinished($event)"></app-image-crop-and-upload>
|
(uploadFinished)="uploadFinished($event)"></app-image-crop-and-upload>
|
||||||
<app-confirmation></app-confirmation>
|
<app-confirmation></app-confirmation>
|
||||||
|
|
@ -1,274 +1,274 @@
|
||||||
import { DatePipe, TitleCasePipe } from '@angular/common';
|
import { DatePipe, TitleCasePipe } from '@angular/common';
|
||||||
import { ChangeDetectorRef, Component } from '@angular/core';
|
import { ChangeDetectorRef, Component } from '@angular/core';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { faTrash } from '@fortawesome/free-solid-svg-icons';
|
import { faTrash } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { NgSelectModule } from '@ng-select/ng-select';
|
import { NgSelectModule } from '@ng-select/ng-select';
|
||||||
import { QuillModule } from 'ngx-quill';
|
import { QuillModule } from 'ngx-quill';
|
||||||
import { lastValueFrom } from 'rxjs';
|
import { lastValueFrom } from 'rxjs';
|
||||||
import { User } from '../../../../../../bizmatch-server/src/models/db.model';
|
import { User } from '../../../../../../bizmatch-server/src/models/db.model';
|
||||||
import { AutoCompleteCompleteEvent, Invoice, UploadParams, ValidationMessage, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
|
import { AutoCompleteCompleteEvent, Invoice, UploadParams, ValidationMessage, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
import { ConfirmationComponent } from '../../../components/confirmation/confirmation.component';
|
import { ConfirmationComponent } from '../../../components/confirmation/confirmation.component';
|
||||||
import { ConfirmationService } from '../../../components/confirmation/confirmation.service';
|
import { ConfirmationService } from '../../../components/confirmation/confirmation.service';
|
||||||
import { ImageCropAndUploadComponent, UploadReponse } from '../../../components/image-crop-and-upload/image-crop-and-upload.component';
|
import { ImageCropAndUploadComponent, UploadReponse } from '../../../components/image-crop-and-upload/image-crop-and-upload.component';
|
||||||
import { MessageService } from '../../../components/message/message.service';
|
import { MessageService } from '../../../components/message/message.service';
|
||||||
import { TooltipComponent } from '../../../components/tooltip/tooltip.component';
|
import { TooltipComponent } from '../../../components/tooltip/tooltip.component';
|
||||||
import { ValidatedCountyComponent } from '../../../components/validated-county/validated-county.component';
|
import { ValidatedCountyComponent } from '../../../components/validated-county/validated-county.component';
|
||||||
import { ValidatedInputComponent } from '../../../components/validated-input/validated-input.component';
|
import { ValidatedInputComponent } from '../../../components/validated-input/validated-input.component';
|
||||||
import { ValidatedLocationComponent } from '../../../components/validated-location/validated-location.component';
|
import { ValidatedLocationComponent } from '../../../components/validated-location/validated-location.component';
|
||||||
import { ValidatedQuillComponent } from '../../../components/validated-quill/validated-quill.component';
|
import { ValidatedQuillComponent } from '../../../components/validated-quill/validated-quill.component';
|
||||||
import { ValidatedSelectComponent } from '../../../components/validated-select/validated-select.component';
|
import { ValidatedSelectComponent } from '../../../components/validated-select/validated-select.component';
|
||||||
import { ValidationMessagesService } from '../../../components/validation-messages.service';
|
import { ValidationMessagesService } from '../../../components/validation-messages.service';
|
||||||
import { AuthService } from '../../../services/auth.service';
|
import { AuthService } from '../../../services/auth.service';
|
||||||
import { GeoService } from '../../../services/geo.service';
|
import { GeoService } from '../../../services/geo.service';
|
||||||
import { ImageService } from '../../../services/image.service';
|
import { ImageService } from '../../../services/image.service';
|
||||||
import { LoadingService } from '../../../services/loading.service';
|
import { LoadingService } from '../../../services/loading.service';
|
||||||
import { SelectOptionsService } from '../../../services/select-options.service';
|
import { SelectOptionsService } from '../../../services/select-options.service';
|
||||||
import { SharedService } from '../../../services/shared.service';
|
import { SharedService } from '../../../services/shared.service';
|
||||||
import { UserService } from '../../../services/user.service';
|
import { UserService } from '../../../services/user.service';
|
||||||
import { SharedModule } from '../../../shared/shared/shared.module';
|
import { SharedModule } from '../../../shared/shared/shared.module';
|
||||||
import { map2User } from '../../../utils/utils';
|
import { map2User } from '../../../utils/utils';
|
||||||
import { TOOLBAR_OPTIONS } from '../../utils/defaults';
|
import { TOOLBAR_OPTIONS } from '../../utils/defaults';
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-account',
|
selector: 'app-account',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
SharedModule,
|
SharedModule,
|
||||||
QuillModule,
|
QuillModule,
|
||||||
NgSelectModule,
|
NgSelectModule,
|
||||||
ConfirmationComponent,
|
ConfirmationComponent,
|
||||||
ImageCropAndUploadComponent,
|
ImageCropAndUploadComponent,
|
||||||
ValidatedInputComponent,
|
ValidatedInputComponent,
|
||||||
ValidatedSelectComponent,
|
ValidatedSelectComponent,
|
||||||
ValidatedQuillComponent,
|
ValidatedQuillComponent,
|
||||||
TooltipComponent,
|
TooltipComponent,
|
||||||
ValidatedCountyComponent,
|
ValidatedCountyComponent,
|
||||||
ValidatedLocationComponent,
|
ValidatedLocationComponent,
|
||||||
],
|
],
|
||||||
providers: [TitleCasePipe, DatePipe],
|
providers: [TitleCasePipe, DatePipe],
|
||||||
templateUrl: './account.component.html',
|
templateUrl: './account.component.html',
|
||||||
styleUrl: './account.component.scss',
|
styleUrl: './account.component.scss',
|
||||||
})
|
})
|
||||||
export class AccountComponent {
|
export class AccountComponent {
|
||||||
id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined;
|
id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined;
|
||||||
user: User;
|
user: User;
|
||||||
companyLogoUrl: string;
|
companyLogoUrl: string;
|
||||||
profileUrl: string;
|
profileUrl: string;
|
||||||
type: 'company' | 'profile';
|
type: 'company' | 'profile';
|
||||||
environment = environment;
|
environment = environment;
|
||||||
editorModules = TOOLBAR_OPTIONS;
|
editorModules = TOOLBAR_OPTIONS;
|
||||||
env = environment;
|
env = environment;
|
||||||
faTrash = faTrash;
|
faTrash = faTrash;
|
||||||
quillModules = {
|
quillModules = {
|
||||||
toolbar: [['bold', 'italic', 'underline', 'strike'], [{ list: 'ordered' }, { list: 'bullet' }], [{ header: [1, 2, 3, 4, 5, 6, false] }], [{ color: [] }, { background: [] }], ['clean']],
|
toolbar: [['bold', 'italic', 'underline', 'strike'], [{ list: 'ordered' }, { list: 'bullet' }], [{ header: [1, 2, 3, 4, 5, 6, false] }], [{ color: [] }, { background: [] }], ['clean']],
|
||||||
};
|
};
|
||||||
uploadParams: UploadParams;
|
uploadParams: UploadParams;
|
||||||
validationMessages: ValidationMessage[] = [];
|
validationMessages: ValidationMessage[] = [];
|
||||||
customerTypeOptions: Array<{ value: string; label: string }> = [];
|
customerTypeOptions: Array<{ value: string; label: string }> = [];
|
||||||
customerSubTypeOptions: Array<{ value: string; label: string }> = [];
|
customerSubTypeOptions: Array<{ value: string; label: string }> = [];
|
||||||
tooltipTargetAreasServed = 'tooltip-areasServed';
|
tooltipTargetAreasServed = 'tooltip-areasServed';
|
||||||
tooltipTargetLicensed = 'tooltip-licensedIn';
|
tooltipTargetLicensed = 'tooltip-licensedIn';
|
||||||
constructor(
|
constructor(
|
||||||
public userService: UserService,
|
public userService: UserService,
|
||||||
private geoService: GeoService,
|
private geoService: GeoService,
|
||||||
public selectOptions: SelectOptionsService,
|
public selectOptions: SelectOptionsService,
|
||||||
private cdref: ChangeDetectorRef,
|
private cdref: ChangeDetectorRef,
|
||||||
private activatedRoute: ActivatedRoute,
|
private activatedRoute: ActivatedRoute,
|
||||||
private loadingService: LoadingService,
|
private loadingService: LoadingService,
|
||||||
private imageUploadService: ImageService,
|
private imageUploadService: ImageService,
|
||||||
private imageService: ImageService,
|
private imageService: ImageService,
|
||||||
private confirmationService: ConfirmationService,
|
private confirmationService: ConfirmationService,
|
||||||
private messageService: MessageService,
|
private messageService: MessageService,
|
||||||
private sharedService: SharedService,
|
private sharedService: SharedService,
|
||||||
private titleCasePipe: TitleCasePipe,
|
private titleCasePipe: TitleCasePipe,
|
||||||
private validationMessagesService: ValidationMessagesService,
|
private validationMessagesService: ValidationMessagesService,
|
||||||
private datePipe: DatePipe,
|
private datePipe: DatePipe,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
public authService: AuthService,
|
public authService: AuthService,
|
||||||
) { }
|
) { }
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
// Flowbite is now initialized once in AppComponent
|
// Flowbite is now initialized once in AppComponent
|
||||||
if (this.id) {
|
if (this.id) {
|
||||||
this.user = await this.userService.getById(this.id);
|
this.user = await this.userService.getById(this.id);
|
||||||
} else {
|
} else {
|
||||||
const token = await this.authService.getToken();
|
const token = await this.authService.getToken();
|
||||||
const keycloakUser = map2User(token);
|
const keycloakUser = map2User(token);
|
||||||
const email = keycloakUser.email;
|
const email = keycloakUser.email;
|
||||||
this.user = await this.userService.getByMail(email);
|
this.user = await this.userService.getByMail(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
// this.subscriptions = await lastValueFrom(this.subscriptionService.getAllSubscriptions(this.user.email));
|
// this.subscriptions = await lastValueFrom(this.subscriptionService.getAllSubscriptions(this.user.email));
|
||||||
// await this.synchronizeSubscriptions(this.subscriptions);
|
// 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.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.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
|
this.customerTypeOptions = this.selectOptions.customerTypes
|
||||||
// .filter(ct => ct.value === 'buyer' || ct.value === 'seller' || this.user.customerType === 'professional')
|
// .filter(ct => ct.value === 'buyer' || ct.value === 'seller' || this.user.customerType === 'professional')
|
||||||
.map(type => ({
|
.map(type => ({
|
||||||
value: type.value,
|
value: type.value,
|
||||||
label: this.titleCasePipe.transform(type.name),
|
label: this.titleCasePipe.transform(type.name),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.customerSubTypeOptions = this.selectOptions.customerSubTypes
|
this.customerSubTypeOptions = this.selectOptions.customerSubTypes
|
||||||
// .filter(ct => ct.value !== 'broker' || this.user.customerSubType === 'broker')
|
// .filter(ct => ct.value !== 'broker' || this.user.customerSubType === 'broker')
|
||||||
.map(type => ({
|
.map(type => ({
|
||||||
value: type.value,
|
value: type.value,
|
||||||
label: this.titleCasePipe.transform(type.name),
|
label: this.titleCasePipe.transform(type.name),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
// async synchronizeSubscriptions(subscriptions: StripeSubscription[]) {
|
// async synchronizeSubscriptions(subscriptions: StripeSubscription[]) {
|
||||||
// let changed = false;
|
// let changed = false;
|
||||||
// if (this.isAdmin()) {
|
// if (this.isAdmin()) {
|
||||||
// return;
|
// return;
|
||||||
// }
|
// }
|
||||||
// if (this.subscriptions.length === 0) {
|
// if (this.subscriptions.length === 0) {
|
||||||
// if (!this.user.subscriptionPlan) {
|
// if (!this.user.subscriptionPlan) {
|
||||||
// this.router.navigate(['pricing']);
|
// this.router.navigate(['pricing']);
|
||||||
// } else {
|
// } else {
|
||||||
// this.subscriptions = [{ ended_at: null, start_date: Math.floor(new Date(this.user.created).getTime() / 1000), status: null, metadata: { plan: 'Free Plan' } }];
|
// 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.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.customerSubType, () => (this.user.customerSubType = null));
|
||||||
// changed = checkAndUpdate(changed, this.user.subscriptionPlan !== 'free', () => (this.user.subscriptionPlan = 'free'));
|
// changed = checkAndUpdate(changed, this.user.subscriptionPlan !== 'free', () => (this.user.subscriptionPlan = 'free'));
|
||||||
// changed = checkAndUpdate(changed, !!this.user.subscriptionId, () => (this.user.subscriptionId = null));
|
// changed = checkAndUpdate(changed, !!this.user.subscriptionId, () => (this.user.subscriptionId = null));
|
||||||
// }
|
// }
|
||||||
// } else {
|
// } else {
|
||||||
// const subscription = subscriptions[0];
|
// 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.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.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.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'] === '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.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.subscriptionPlan !== 'professional', () => (this.user.subscriptionPlan = 'professional'));
|
||||||
// changed = checkAndUpdate(changed, subscription.metadata['plan'] === 'Professional Plan' && this.user.subscriptionId !== 'professional', () => (this.user.subscriptionId = subscription.id));
|
// changed = checkAndUpdate(changed, subscription.metadata['plan'] === 'Professional Plan' && this.user.subscriptionId !== 'professional', () => (this.user.subscriptionId = subscription.id));
|
||||||
// }
|
// }
|
||||||
// if (changed) {
|
// if (changed) {
|
||||||
// await this.userService.saveGuaranteed(this.user);
|
// await this.userService.saveGuaranteed(this.user);
|
||||||
// this.cdref.detectChanges();
|
// this.cdref.detectChanges();
|
||||||
// this.cdref.markForCheck();
|
// this.cdref.markForCheck();
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
|
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
|
||||||
}
|
}
|
||||||
printInvoice(invoice: Invoice) { }
|
printInvoice(invoice: Invoice) { }
|
||||||
|
|
||||||
async updateProfile(user: User) {
|
async updateProfile(user: User) {
|
||||||
try {
|
try {
|
||||||
await this.userService.save(this.user);
|
await this.userService.save(this.user);
|
||||||
this.userService.changeUser(this.user);
|
this.userService.changeUser(this.user);
|
||||||
this.messageService.addMessage({ severity: 'success', text: 'Account changes have been persisted', duration: 3000 });
|
this.messageService.addMessage({ severity: 'success', text: 'Account changes have been persisted', duration: 3000 });
|
||||||
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
|
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
|
||||||
this.validationMessages = [];
|
this.validationMessages = [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.messageService.addMessage({
|
this.messageService.addMessage({
|
||||||
severity: 'danger',
|
severity: 'danger',
|
||||||
text: 'An error occurred while saving the profile - Please check your inputs',
|
text: 'An error occurred while saving the profile - Please check your inputs',
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
if (error.error && Array.isArray(error.error?.message)) {
|
if (error.error && Array.isArray(error.error?.message)) {
|
||||||
this.validationMessagesService.updateMessages(error.error.message);
|
this.validationMessagesService.updateMessages(error.error.message);
|
||||||
this.validationMessages = error.error.message;
|
this.validationMessages = error.error.message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onUploadCompanyLogo(event: any) {
|
onUploadCompanyLogo(event: any) {
|
||||||
const uniqueSuffix = '?_ts=' + new Date().getTime();
|
const uniqueSuffix = '?_ts=' + new Date().getTime();
|
||||||
this.companyLogoUrl = `${this.env.imageBaseUrl}/pictures/logo/${emailToDirName(this.user.email)}${uniqueSuffix}`;
|
this.companyLogoUrl = `${this.env.imageBaseUrl}/pictures/logo/${emailToDirName(this.user.email)}${uniqueSuffix}`;
|
||||||
}
|
}
|
||||||
onUploadProfilePicture(event: any) {
|
onUploadProfilePicture(event: any) {
|
||||||
const uniqueSuffix = '?_ts=' + new Date().getTime();
|
const uniqueSuffix = '?_ts=' + new Date().getTime();
|
||||||
this.profileUrl = `${this.env.imageBaseUrl}/pictures/profile/${emailToDirName(this.user.email)}${uniqueSuffix}`;
|
this.profileUrl = `${this.env.imageBaseUrl}/pictures/profile/${emailToDirName(this.user.email)}${uniqueSuffix}`;
|
||||||
}
|
}
|
||||||
setImageToFallback(event: Event) {
|
setImageToFallback(event: Event) {
|
||||||
(event.target as HTMLImageElement).src = `/assets/images/placeholder.png`; // Pfad zum Platzhalterbild
|
(event.target as HTMLImageElement).src = `/assets/images/placeholder.png`; // Pfad zum Platzhalterbild
|
||||||
}
|
}
|
||||||
|
|
||||||
suggestions: string[] | undefined;
|
suggestions: string[] | undefined;
|
||||||
|
|
||||||
async search(event: AutoCompleteCompleteEvent) {
|
async search(event: AutoCompleteCompleteEvent) {
|
||||||
const result = await lastValueFrom(this.geoService.findCitiesStartingWith(event.query));
|
const result = await lastValueFrom(this.geoService.findCitiesStartingWith(event.query));
|
||||||
this.suggestions = result.map(r => `${r.name} - ${r.state}`).slice(0, 5);
|
this.suggestions = result.map(r => `${r.name} - ${r.state}`).slice(0, 5);
|
||||||
}
|
}
|
||||||
addLicence() {
|
addLicence() {
|
||||||
this.user.licensedIn.push({ registerNo: '', state: '' });
|
this.user.licensedIn.push({ registerNo: '', state: '' });
|
||||||
}
|
}
|
||||||
removeLicence(index: number) {
|
removeLicence(index: number) {
|
||||||
this.user.licensedIn.splice(index, 1);
|
this.user.licensedIn.splice(index, 1);
|
||||||
}
|
}
|
||||||
addArea() {
|
addArea() {
|
||||||
this.user.areasServed.push({ county: '', state: '' });
|
this.user.areasServed.push({ county: '', state: '' });
|
||||||
}
|
}
|
||||||
removeArea(index: number) {
|
removeArea(index: number) {
|
||||||
this.user.areasServed.splice(index, 1);
|
this.user.areasServed.splice(index, 1);
|
||||||
}
|
}
|
||||||
get isProfessional() {
|
get isProfessional() {
|
||||||
return this.user.customerType === 'professional';
|
return this.user.customerType === 'professional';
|
||||||
}
|
}
|
||||||
uploadCompanyLogo() {
|
uploadCompanyLogo() {
|
||||||
this.uploadParams = { type: 'uploadCompanyLogo', imagePath: emailToDirName(this.user.email) };
|
this.uploadParams = { type: 'uploadCompanyLogo', imagePath: emailToDirName(this.user.email) };
|
||||||
}
|
}
|
||||||
uploadProfile() {
|
uploadProfile() {
|
||||||
this.uploadParams = { type: 'uploadProfile', imagePath: emailToDirName(this.user.email) };
|
this.uploadParams = { type: 'uploadProfile', imagePath: emailToDirName(this.user.email) };
|
||||||
}
|
}
|
||||||
async uploadFinished(response: UploadReponse) {
|
async uploadFinished(response: UploadReponse) {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
if (response.type === 'uploadCompanyLogo') {
|
if (response.type === 'uploadCompanyLogo') {
|
||||||
this.user.hasCompanyLogo = true; //
|
this.user.hasCompanyLogo = true; //
|
||||||
this.companyLogoUrl = `${this.env.imageBaseUrl}/pictures/logo/${emailToDirName(this.user.email)}.avif?_ts=${new Date().getTime()}`;
|
this.companyLogoUrl = `${this.env.imageBaseUrl}/pictures/logo/${emailToDirName(this.user.email)}.avif?_ts=${new Date().getTime()}`;
|
||||||
} else {
|
} else {
|
||||||
this.user.hasProfile = true;
|
this.user.hasProfile = true;
|
||||||
this.profileUrl = `${this.env.imageBaseUrl}/pictures/profile/${emailToDirName(this.user.email)}.avif?_ts=${new Date().getTime()}`;
|
this.profileUrl = `${this.env.imageBaseUrl}/pictures/profile/${emailToDirName(this.user.email)}.avif?_ts=${new Date().getTime()}`;
|
||||||
this.sharedService.changeProfilePhoto(this.profileUrl);
|
this.sharedService.changeProfilePhoto(this.profileUrl);
|
||||||
}
|
}
|
||||||
this.userService.changeUser(this.user);
|
this.userService.changeUser(this.user);
|
||||||
await this.userService.saveGuaranteed(this.user);
|
await this.userService.saveGuaranteed(this.user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async deleteConfirm(type: 'profile' | 'logo') {
|
async deleteConfirm(type: 'profile' | 'logo') {
|
||||||
const confirmed = await this.confirmationService.showConfirmation({ message: `Do you want to delete your ${type === 'logo' ? 'Logo' : 'Profile'} image` });
|
const confirmed = await this.confirmationService.showConfirmation({ message: `Do you want to delete your ${type === 'logo' ? 'Logo' : 'Profile'} image` });
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
if (type === 'profile') {
|
if (type === 'profile') {
|
||||||
this.user.hasProfile = false;
|
this.user.hasProfile = false;
|
||||||
await Promise.all([this.imageService.deleteProfileImagesByMail(this.user.email), this.userService.saveGuaranteed(this.user)]);
|
await Promise.all([this.imageService.deleteProfileImagesByMail(this.user.email), this.userService.saveGuaranteed(this.user)]);
|
||||||
} else {
|
} else {
|
||||||
this.user.hasCompanyLogo = false;
|
this.user.hasCompanyLogo = false;
|
||||||
await Promise.all([this.imageService.deleteLogoImagesByMail(this.user.email), this.userService.saveGuaranteed(this.user)]);
|
await Promise.all([this.imageService.deleteLogoImagesByMail(this.user.email), this.userService.saveGuaranteed(this.user)]);
|
||||||
}
|
}
|
||||||
this.user = await this.userService.getById(this.user.id);
|
this.user = await this.userService.getById(this.user.id);
|
||||||
// this.messageService.showMessage('Image deleted');
|
// this.messageService.showMessage('Image deleted');
|
||||||
this.messageService.addMessage({
|
this.messageService.addMessage({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
text: 'Image deleted.',
|
text: 'Image deleted.',
|
||||||
duration: 3000, // 3 seconds
|
duration: 3000, // 3 seconds
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
getValidationMessage(fieldName: string): string {
|
getValidationMessage(fieldName: string): string {
|
||||||
const message = this.validationMessages.find(msg => msg.field === fieldName);
|
const message = this.validationMessages.find(msg => msg.field === fieldName);
|
||||||
return message ? message.message : '';
|
return message ? message.message : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(index: number, state: string) {
|
setState(index: number, state: string) {
|
||||||
if (state === null) {
|
if (state === null) {
|
||||||
this.user.areasServed[index].county = null;
|
this.user.areasServed[index].county = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// getLevel(i: number) {
|
// getLevel(i: number) {
|
||||||
// return this.subscriptions[i].metadata.plan;
|
// return this.subscriptions[i].metadata.plan;
|
||||||
// }
|
// }
|
||||||
// getStartDate(i: number) {
|
// getStartDate(i: number) {
|
||||||
// return this.datePipe.transform(new Date(this.subscriptions[i].start_date * 1000));
|
// return this.datePipe.transform(new Date(this.subscriptions[i].start_date * 1000));
|
||||||
// }
|
// }
|
||||||
// getEndDate(i: number) {
|
// getEndDate(i: number) {
|
||||||
// return this.subscriptions[i].status === 'trialing' ? this.datePipe.transform(new Date(this.subscriptions[i].current_period_end * 1000)) : '---';
|
// return this.subscriptions[i].status === 'trialing' ? this.datePipe.transform(new Date(this.subscriptions[i].current_period_end * 1000)) : '---';
|
||||||
// }
|
// }
|
||||||
// getNextSettlement(i: number) {
|
// getNextSettlement(i: number) {
|
||||||
// return this.subscriptions[i].status === 'active' ? this.datePipe.transform(new Date(this.subscriptions[i].current_period_end * 1000)) : '---';
|
// return this.subscriptions[i].status === 'active' ? this.datePipe.transform(new Date(this.subscriptions[i].current_period_end * 1000)) : '---';
|
||||||
// }
|
// }
|
||||||
// getStatus(i: number) {
|
// getStatus(i: number) {
|
||||||
// return this.subscriptions[i].status ? this.subscriptions[i].status : '';
|
// return this.subscriptions[i].status ? this.subscriptions[i].status : '';
|
||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,178 +1,178 @@
|
||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
|
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
|
||||||
import { lastValueFrom } from 'rxjs';
|
import { lastValueFrom } from 'rxjs';
|
||||||
import { ListingsService } from '../../../services/listings.service';
|
import { ListingsService } from '../../../services/listings.service';
|
||||||
import { SelectOptionsService } from '../../../services/select-options.service';
|
import { SelectOptionsService } from '../../../services/select-options.service';
|
||||||
import { map2User, routeListingWithState } from '../../../utils/utils';
|
import { map2User, routeListingWithState } from '../../../utils/utils';
|
||||||
|
|
||||||
import { DragDropModule } from '@angular/cdk/drag-drop';
|
import { DragDropModule } from '@angular/cdk/drag-drop';
|
||||||
import { faTrash } from '@fortawesome/free-solid-svg-icons';
|
import { faTrash } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { QuillModule } from 'ngx-quill';
|
import { QuillModule } from 'ngx-quill';
|
||||||
|
|
||||||
import { NgSelectModule } from '@ng-select/ng-select';
|
import { NgSelectModule } from '@ng-select/ng-select';
|
||||||
import { NgxCurrencyDirective } from 'ngx-currency';
|
import { NgxCurrencyDirective } from 'ngx-currency';
|
||||||
import { BusinessListing, CommercialPropertyListing, User } from '../../../../../../bizmatch-server/src/models/db.model';
|
import { BusinessListing, CommercialPropertyListing, User } from '../../../../../../bizmatch-server/src/models/db.model';
|
||||||
import { AutoCompleteCompleteEvent, ImageProperty, createDefaultBusinessListing, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
|
import { AutoCompleteCompleteEvent, ImageProperty, createDefaultBusinessListing, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
|
||||||
|
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
import { MessageService } from '../../../components/message/message.service';
|
import { MessageService } from '../../../components/message/message.service';
|
||||||
import { ValidatedCityComponent } from '../../../components/validated-city/validated-city.component';
|
import { ValidatedCityComponent } from '../../../components/validated-city/validated-city.component';
|
||||||
import { ValidatedInputComponent } from '../../../components/validated-input/validated-input.component';
|
import { ValidatedInputComponent } from '../../../components/validated-input/validated-input.component';
|
||||||
import { ValidatedLocationComponent } from '../../../components/validated-location/validated-location.component';
|
import { ValidatedLocationComponent } from '../../../components/validated-location/validated-location.component';
|
||||||
import { ValidatedNgSelectComponent } from '../../../components/validated-ng-select/validated-ng-select.component';
|
import { ValidatedNgSelectComponent } from '../../../components/validated-ng-select/validated-ng-select.component';
|
||||||
import { ValidatedPriceComponent } from '../../../components/validated-price/validated-price.component';
|
import { ValidatedPriceComponent } from '../../../components/validated-price/validated-price.component';
|
||||||
import { ValidatedQuillComponent } from '../../../components/validated-quill/validated-quill.component';
|
import { ValidatedQuillComponent } from '../../../components/validated-quill/validated-quill.component';
|
||||||
import { ValidatedTextareaComponent } from '../../../components/validated-textarea/validated-textarea.component';
|
import { ValidatedTextareaComponent } from '../../../components/validated-textarea/validated-textarea.component';
|
||||||
import { ValidationMessagesService } from '../../../components/validation-messages.service';
|
import { ValidationMessagesService } from '../../../components/validation-messages.service';
|
||||||
import { ArrayToStringPipe } from '../../../pipes/array-to-string.pipe';
|
import { ArrayToStringPipe } from '../../../pipes/array-to-string.pipe';
|
||||||
import { AuthService } from '../../../services/auth.service';
|
import { AuthService } from '../../../services/auth.service';
|
||||||
import { GeoService } from '../../../services/geo.service';
|
import { GeoService } from '../../../services/geo.service';
|
||||||
import { ImageService } from '../../../services/image.service';
|
import { ImageService } from '../../../services/image.service';
|
||||||
import { LoadingService } from '../../../services/loading.service';
|
import { LoadingService } from '../../../services/loading.service';
|
||||||
import { UserService } from '../../../services/user.service';
|
import { UserService } from '../../../services/user.service';
|
||||||
import { SharedModule } from '../../../shared/shared/shared.module';
|
import { SharedModule } from '../../../shared/shared/shared.module';
|
||||||
import { TOOLBAR_OPTIONS } from '../../utils/defaults';
|
import { TOOLBAR_OPTIONS } from '../../utils/defaults';
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'business-listing',
|
selector: 'business-listing',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
SharedModule,
|
SharedModule,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
QuillModule,
|
QuillModule,
|
||||||
NgSelectModule,
|
NgSelectModule,
|
||||||
ValidatedInputComponent,
|
ValidatedInputComponent,
|
||||||
ValidatedQuillComponent,
|
ValidatedQuillComponent,
|
||||||
ValidatedNgSelectComponent,
|
ValidatedNgSelectComponent,
|
||||||
ValidatedPriceComponent,
|
ValidatedPriceComponent,
|
||||||
ValidatedTextareaComponent,
|
ValidatedTextareaComponent,
|
||||||
ValidatedLocationComponent,
|
ValidatedLocationComponent,
|
||||||
],
|
],
|
||||||
providers: [],
|
providers: [],
|
||||||
templateUrl: './edit-business-listing.component.html',
|
templateUrl: './edit-business-listing.component.html',
|
||||||
styleUrl: './edit-business-listing.component.scss',
|
styleUrl: './edit-business-listing.component.scss',
|
||||||
})
|
})
|
||||||
export class EditBusinessListingComponent {
|
export class EditBusinessListingComponent {
|
||||||
listingsCategory = 'business';
|
listingsCategory = 'business';
|
||||||
category: string;
|
category: string;
|
||||||
location: string;
|
location: string;
|
||||||
mode: 'edit' | 'create';
|
mode: 'edit' | 'create';
|
||||||
separator: '\n\n';
|
separator: '\n\n';
|
||||||
listing: BusinessListing;
|
listing: BusinessListing;
|
||||||
private id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined;
|
private id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined;
|
||||||
user: User;
|
user: User;
|
||||||
environment = environment;
|
environment = environment;
|
||||||
config = { aspectRatio: 16 / 9 };
|
config = { aspectRatio: 16 / 9 };
|
||||||
editorModules = TOOLBAR_OPTIONS;
|
editorModules = TOOLBAR_OPTIONS;
|
||||||
draggedImage: ImageProperty;
|
draggedImage: ImageProperty;
|
||||||
faTrash = faTrash;
|
faTrash = faTrash;
|
||||||
data: CommercialPropertyListing;
|
data: CommercialPropertyListing;
|
||||||
typesOfBusiness = [];
|
typesOfBusiness = [];
|
||||||
quillModules = {
|
quillModules = {
|
||||||
toolbar: [['bold', 'italic', 'underline', 'strike'], [{ list: 'ordered' }, { list: 'bullet' }], [{ header: [1, 2, 3, 4, 5, 6, false] }], [{ color: [] }, { background: [] }], ['clean']],
|
toolbar: [['bold', 'italic', 'underline', 'strike'], [{ list: 'ordered' }, { list: 'bullet' }], [{ header: [1, 2, 3, 4, 5, 6, false] }], [{ color: [] }, { background: [] }], ['clean']],
|
||||||
};
|
};
|
||||||
listingUser: User;
|
listingUser: User;
|
||||||
constructor(
|
constructor(
|
||||||
public selectOptions: SelectOptionsService,
|
public selectOptions: SelectOptionsService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private activatedRoute: ActivatedRoute,
|
private activatedRoute: ActivatedRoute,
|
||||||
private listingsService: ListingsService,
|
private listingsService: ListingsService,
|
||||||
public userService: UserService,
|
public userService: UserService,
|
||||||
private geoService: GeoService,
|
private geoService: GeoService,
|
||||||
private imageService: ImageService,
|
private imageService: ImageService,
|
||||||
private loadingService: LoadingService,
|
private loadingService: LoadingService,
|
||||||
private messageService: MessageService,
|
private messageService: MessageService,
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private validationMessagesService: ValidationMessagesService,
|
private validationMessagesService: ValidationMessagesService,
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
) {
|
) {
|
||||||
this.router.events.subscribe(event => {
|
this.router.events.subscribe(event => {
|
||||||
if (event instanceof NavigationEnd) {
|
if (event instanceof NavigationEnd) {
|
||||||
this.mode = event.url.startsWith('/createBusinessListing') ? 'create' : 'edit';
|
this.mode = event.url.startsWith('/createBusinessListing') ? 'create' : 'edit';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.route.data.subscribe(async () => {
|
this.route.data.subscribe(async () => {
|
||||||
if (this.router.getCurrentNavigation().extras.state) {
|
if (this.router.getCurrentNavigation().extras.state) {
|
||||||
this.data = this.router.getCurrentNavigation().extras.state['data'];
|
this.data = this.router.getCurrentNavigation().extras.state['data'];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.typesOfBusiness = selectOptions.typesOfBusiness.map(e => {
|
this.typesOfBusiness = selectOptions.typesOfBusiness.map(e => {
|
||||||
return { name: e.name, value: e.value };
|
return { name: e.name, value: e.value };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
const token = await this.authService.getToken();
|
const token = await this.authService.getToken();
|
||||||
const keycloakUser = map2User(token);
|
const keycloakUser = map2User(token);
|
||||||
this.listingUser = await this.userService.getByMail(keycloakUser.email);
|
this.listingUser = await this.userService.getByMail(keycloakUser.email);
|
||||||
if (this.mode === 'edit') {
|
if (this.mode === 'edit') {
|
||||||
this.listing = await lastValueFrom(this.listingsService.getListingById(this.id, 'business'));
|
this.listing = await lastValueFrom(this.listingsService.getListingById(this.id, 'business'));
|
||||||
} else {
|
} else {
|
||||||
this.listing = createDefaultBusinessListing();
|
this.listing = createDefaultBusinessListing();
|
||||||
this.listing.email = this.listingUser.email;
|
this.listing.email = this.listingUser.email;
|
||||||
this.listing.imageName = emailToDirName(keycloakUser.email);
|
this.listing.imageName = emailToDirName(keycloakUser.email);
|
||||||
if (this.data) {
|
if (this.data) {
|
||||||
this.listing.title = this.data?.title;
|
this.listing.title = this.data?.title;
|
||||||
this.listing.description = this.data?.description;
|
this.listing.description = this.data?.description;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
|
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
|
||||||
}
|
}
|
||||||
async save() {
|
async save() {
|
||||||
try {
|
try {
|
||||||
this.listing = await this.listingsService.save(this.listing, this.listing.listingsCategory);
|
this.listing = await this.listingsService.save(this.listing, this.listing.listingsCategory);
|
||||||
this.router.navigate(['editBusinessListing', this.listing.id]);
|
this.router.navigate(['editBusinessListing', this.listing.id]);
|
||||||
this.messageService.addMessage({ severity: 'success', text: 'Listing changes have been persisted', duration: 3000 });
|
this.messageService.addMessage({ severity: 'success', text: 'Listing changes have been persisted', duration: 3000 });
|
||||||
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
|
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving listing:', error);
|
console.error('Error saving listing:', error);
|
||||||
let errorText = 'An error occurred while saving the listing - Please check your inputs';
|
let errorText = 'An error occurred while saving the listing - Please check your inputs';
|
||||||
|
|
||||||
if (error.error && Array.isArray(error.error?.message)) {
|
if (error.error && Array.isArray(error.error?.message)) {
|
||||||
this.validationMessagesService.updateMessages(error.error.message);
|
this.validationMessagesService.updateMessages(error.error.message);
|
||||||
errorText = 'Please fix the validation errors highlighted in the form';
|
errorText = 'Please fix the validation errors highlighted in the form';
|
||||||
} else if (error.error?.message) {
|
} else if (error.error?.message) {
|
||||||
errorText = `Error: ${error.error.message}`;
|
errorText = `Error: ${error.error.message}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.messageService.addMessage({
|
this.messageService.addMessage({
|
||||||
severity: 'danger',
|
severity: 'danger',
|
||||||
text: errorText,
|
text: errorText,
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suggestions: string[] | undefined;
|
suggestions: string[] | undefined;
|
||||||
|
|
||||||
async search(event: AutoCompleteCompleteEvent) {
|
async search(event: AutoCompleteCompleteEvent) {
|
||||||
const result = await lastValueFrom(this.geoService.findCitiesStartingWith(event.query));
|
const result = await lastValueFrom(this.geoService.findCitiesStartingWith(event.query));
|
||||||
this.suggestions = result.map(r => r.name).slice(0, 5);
|
this.suggestions = result.map(r => r.name).slice(0, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
changeListingCategory(value: 'business' | 'commercialProperty') {
|
changeListingCategory(value: 'business' | 'commercialProperty') {
|
||||||
routeListingWithState(this.router, value, this.listing);
|
routeListingWithState(this.router, value, this.listing);
|
||||||
}
|
}
|
||||||
onNumericInputChange(value: string, modelProperty: string): void {
|
onNumericInputChange(value: string, modelProperty: string): void {
|
||||||
const newValue = value === '' ? null : +value;
|
const newValue = value === '' ? null : +value;
|
||||||
this.setPropertyByPath(this, modelProperty, newValue);
|
this.setPropertyByPath(this, modelProperty, newValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
private setPropertyByPath(obj: any, path: string, value: any): void {
|
private setPropertyByPath(obj: any, path: string, value: any): void {
|
||||||
const keys = path.split('.');
|
const keys = path.split('.');
|
||||||
let target = obj;
|
let target = obj;
|
||||||
for (let i = 0; i < keys.length - 1; i++) {
|
for (let i = 0; i < keys.length - 1; i++) {
|
||||||
target = target[keys[i]];
|
target = target[keys[i]];
|
||||||
}
|
}
|
||||||
target[keys[keys.length - 1]] = value;
|
target[keys[keys.length - 1]] = value;
|
||||||
}
|
}
|
||||||
onCheckboxChange(checkbox: string, value: boolean) {
|
onCheckboxChange(checkbox: string, value: boolean) {
|
||||||
// Deaktivieren Sie alle Checkboxes
|
// Deaktivieren Sie alle Checkboxes
|
||||||
this.listing.realEstateIncluded = false;
|
this.listing.realEstateIncluded = false;
|
||||||
this.listing.leasedLocation = false;
|
this.listing.leasedLocation = false;
|
||||||
this.listing.franchiseResale = false;
|
this.listing.franchiseResale = false;
|
||||||
|
|
||||||
// Aktivieren Sie nur die aktuell ausgewählte Checkbox
|
// Aktivieren Sie nur die aktuell ausgewählte Checkbox
|
||||||
this.listing[checkbox] = value;
|
this.listing[checkbox] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,228 +1,228 @@
|
||||||
import { ChangeDetectorRef, Component, ElementRef, ViewChild } from '@angular/core';
|
import { ChangeDetectorRef, Component, ElementRef, ViewChild } from '@angular/core';
|
||||||
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
|
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
|
||||||
import { lastValueFrom } from 'rxjs';
|
import { lastValueFrom } from 'rxjs';
|
||||||
import { ListingsService } from '../../../services/listings.service';
|
import { ListingsService } from '../../../services/listings.service';
|
||||||
import { SelectOptionsService } from '../../../services/select-options.service';
|
import { SelectOptionsService } from '../../../services/select-options.service';
|
||||||
import { map2User, routeListingWithState } from '../../../utils/utils';
|
import { map2User, routeListingWithState } from '../../../utils/utils';
|
||||||
|
|
||||||
import { DragDropModule } from '@angular/cdk/drag-drop';
|
import { DragDropModule } from '@angular/cdk/drag-drop';
|
||||||
import { ViewportRuler } from '@angular/cdk/scrolling';
|
import { ViewportRuler } from '@angular/cdk/scrolling';
|
||||||
import { faTrash } from '@fortawesome/free-solid-svg-icons';
|
import { faTrash } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { NgSelectModule } from '@ng-select/ng-select';
|
import { NgSelectModule } from '@ng-select/ng-select';
|
||||||
import { NgxCurrencyDirective } from 'ngx-currency';
|
import { NgxCurrencyDirective } from 'ngx-currency';
|
||||||
import { ImageCropperComponent } from 'ngx-image-cropper';
|
import { ImageCropperComponent } from 'ngx-image-cropper';
|
||||||
import { QuillModule } from 'ngx-quill';
|
import { QuillModule } from 'ngx-quill';
|
||||||
import { BusinessListing, CommercialPropertyListing, User } from '../../../../../../bizmatch-server/src/models/db.model';
|
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 { AutoCompleteCompleteEvent, ImageProperty, UploadParams, createDefaultCommercialPropertyListing, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
|
||||||
|
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
import { ConfirmationComponent } from '../../../components/confirmation/confirmation.component';
|
import { ConfirmationComponent } from '../../../components/confirmation/confirmation.component';
|
||||||
import { ConfirmationService } from '../../../components/confirmation/confirmation.service';
|
import { ConfirmationService } from '../../../components/confirmation/confirmation.service';
|
||||||
import { DragDropMixedComponent } from '../../../components/drag-drop-mixed/drag-drop-mixed.component';
|
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 { ImageCropAndUploadComponent, UploadReponse } from '../../../components/image-crop-and-upload/image-crop-and-upload.component';
|
||||||
import { MessageService } from '../../../components/message/message.service';
|
import { MessageService } from '../../../components/message/message.service';
|
||||||
import { ValidatedCityComponent } from '../../../components/validated-city/validated-city.component';
|
import { ValidatedCityComponent } from '../../../components/validated-city/validated-city.component';
|
||||||
import { ValidatedInputComponent } from '../../../components/validated-input/validated-input.component';
|
import { ValidatedInputComponent } from '../../../components/validated-input/validated-input.component';
|
||||||
import { ValidatedLocationComponent } from '../../../components/validated-location/validated-location.component';
|
import { ValidatedLocationComponent } from '../../../components/validated-location/validated-location.component';
|
||||||
import { ValidatedNgSelectComponent } from '../../../components/validated-ng-select/validated-ng-select.component';
|
import { ValidatedNgSelectComponent } from '../../../components/validated-ng-select/validated-ng-select.component';
|
||||||
import { ValidatedPriceComponent } from '../../../components/validated-price/validated-price.component';
|
import { ValidatedPriceComponent } from '../../../components/validated-price/validated-price.component';
|
||||||
import { ValidatedQuillComponent } from '../../../components/validated-quill/validated-quill.component';
|
import { ValidatedQuillComponent } from '../../../components/validated-quill/validated-quill.component';
|
||||||
import { ValidationMessagesService } from '../../../components/validation-messages.service';
|
import { ValidationMessagesService } from '../../../components/validation-messages.service';
|
||||||
import { ArrayToStringPipe } from '../../../pipes/array-to-string.pipe';
|
import { ArrayToStringPipe } from '../../../pipes/array-to-string.pipe';
|
||||||
import { AuthService } from '../../../services/auth.service';
|
import { AuthService } from '../../../services/auth.service';
|
||||||
import { GeoService } from '../../../services/geo.service';
|
import { GeoService } from '../../../services/geo.service';
|
||||||
import { ImageService } from '../../../services/image.service';
|
import { ImageService } from '../../../services/image.service';
|
||||||
import { LoadingService } from '../../../services/loading.service';
|
import { LoadingService } from '../../../services/loading.service';
|
||||||
import { UserService } from '../../../services/user.service';
|
import { UserService } from '../../../services/user.service';
|
||||||
import { SharedModule } from '../../../shared/shared/shared.module';
|
import { SharedModule } from '../../../shared/shared/shared.module';
|
||||||
import { TOOLBAR_OPTIONS } from '../../utils/defaults';
|
import { TOOLBAR_OPTIONS } from '../../utils/defaults';
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'commercial-property-listing',
|
selector: 'commercial-property-listing',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
SharedModule,
|
SharedModule,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
QuillModule,
|
QuillModule,
|
||||||
NgSelectModule,
|
NgSelectModule,
|
||||||
ConfirmationComponent,
|
ConfirmationComponent,
|
||||||
DragDropMixedComponent,
|
DragDropMixedComponent,
|
||||||
ValidatedInputComponent,
|
ValidatedInputComponent,
|
||||||
ValidatedQuillComponent,
|
ValidatedQuillComponent,
|
||||||
ValidatedNgSelectComponent,
|
ValidatedNgSelectComponent,
|
||||||
ValidatedPriceComponent,
|
ValidatedPriceComponent,
|
||||||
ValidatedLocationComponent,
|
ValidatedLocationComponent,
|
||||||
ImageCropAndUploadComponent,
|
ImageCropAndUploadComponent,
|
||||||
],
|
],
|
||||||
providers: [],
|
providers: [],
|
||||||
templateUrl: './edit-commercial-property-listing.component.html',
|
templateUrl: './edit-commercial-property-listing.component.html',
|
||||||
styleUrl: './edit-commercial-property-listing.component.scss',
|
styleUrl: './edit-commercial-property-listing.component.scss',
|
||||||
})
|
})
|
||||||
export class EditCommercialPropertyListingComponent {
|
export class EditCommercialPropertyListingComponent {
|
||||||
@ViewChild('fileInput') fileInput!: ElementRef<HTMLInputElement>;
|
@ViewChild('fileInput') fileInput!: ElementRef<HTMLInputElement>;
|
||||||
|
|
||||||
listingsCategory = 'commercialProperty';
|
listingsCategory = 'commercialProperty';
|
||||||
category: string;
|
category: string;
|
||||||
location: string;
|
location: string;
|
||||||
mode: 'edit' | 'create';
|
mode: 'edit' | 'create';
|
||||||
separator: '\n\n';
|
separator: '\n\n';
|
||||||
listing: CommercialPropertyListing;
|
listing: CommercialPropertyListing;
|
||||||
private id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined;
|
private id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined;
|
||||||
user: User;
|
user: User;
|
||||||
maxFileSize = 3000000;
|
maxFileSize = 3000000;
|
||||||
environment = environment;
|
environment = environment;
|
||||||
responsiveOptions = [
|
responsiveOptions = [
|
||||||
{
|
{
|
||||||
breakpoint: '1199px',
|
breakpoint: '1199px',
|
||||||
numVisible: 1,
|
numVisible: 1,
|
||||||
numScroll: 1,
|
numScroll: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
breakpoint: '991px',
|
breakpoint: '991px',
|
||||||
numVisible: 2,
|
numVisible: 2,
|
||||||
numScroll: 1,
|
numScroll: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
breakpoint: '767px',
|
breakpoint: '767px',
|
||||||
numVisible: 1,
|
numVisible: 1,
|
||||||
numScroll: 1,
|
numScroll: 1,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
config = { aspectRatio: 16 / 9 };
|
config = { aspectRatio: 16 / 9 };
|
||||||
editorModules = TOOLBAR_OPTIONS;
|
editorModules = TOOLBAR_OPTIONS;
|
||||||
|
|
||||||
draggedImage: ImageProperty;
|
draggedImage: ImageProperty;
|
||||||
faTrash = faTrash;
|
faTrash = faTrash;
|
||||||
suggestions: string[] | undefined;
|
suggestions: string[] | undefined;
|
||||||
data: BusinessListing;
|
data: BusinessListing;
|
||||||
userId: string;
|
userId: string;
|
||||||
typesOfCommercialProperty = [];
|
typesOfCommercialProperty = [];
|
||||||
listingCategories = [];
|
listingCategories = [];
|
||||||
env = environment;
|
env = environment;
|
||||||
ts = new Date().getTime();
|
ts = new Date().getTime();
|
||||||
quillModules = {
|
quillModules = {
|
||||||
toolbar: [['bold', 'italic', 'underline', 'strike'], [{ list: 'ordered' }, { list: 'bullet' }], [{ header: [1, 2, 3, 4, 5, 6, false] }], [{ color: [] }, { background: [] }], ['clean']],
|
toolbar: [['bold', 'italic', 'underline', 'strike'], [{ list: 'ordered' }, { list: 'bullet' }], [{ header: [1, 2, 3, 4, 5, 6, false] }], [{ color: [] }, { background: [] }], ['clean']],
|
||||||
};
|
};
|
||||||
//showModal = false;
|
//showModal = false;
|
||||||
imageChangedEvent: any = '';
|
imageChangedEvent: any = '';
|
||||||
croppedImage: Blob | null = null;
|
croppedImage: Blob | null = null;
|
||||||
uploadParams: UploadParams;
|
uploadParams: UploadParams;
|
||||||
constructor(
|
constructor(
|
||||||
public selectOptions: SelectOptionsService,
|
public selectOptions: SelectOptionsService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private activatedRoute: ActivatedRoute,
|
private activatedRoute: ActivatedRoute,
|
||||||
private listingsService: ListingsService,
|
private listingsService: ListingsService,
|
||||||
public userService: UserService,
|
public userService: UserService,
|
||||||
|
|
||||||
private geoService: GeoService,
|
private geoService: GeoService,
|
||||||
private imageService: ImageService,
|
private imageService: ImageService,
|
||||||
private loadingService: LoadingService,
|
private loadingService: LoadingService,
|
||||||
|
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private cdr: ChangeDetectorRef,
|
private cdr: ChangeDetectorRef,
|
||||||
private confirmationService: ConfirmationService,
|
private confirmationService: ConfirmationService,
|
||||||
private messageService: MessageService,
|
private messageService: MessageService,
|
||||||
private viewportRuler: ViewportRuler,
|
private viewportRuler: ViewportRuler,
|
||||||
private validationMessagesService: ValidationMessagesService,
|
private validationMessagesService: ValidationMessagesService,
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
) {
|
) {
|
||||||
// Abonniere Router-Events, um den aktiven Link zu ermitteln
|
// Abonniere Router-Events, um den aktiven Link zu ermitteln
|
||||||
this.router.events.subscribe(event => {
|
this.router.events.subscribe(event => {
|
||||||
if (event instanceof NavigationEnd) {
|
if (event instanceof NavigationEnd) {
|
||||||
this.mode = event.url.startsWith('/createCommercialPropertyListing') ? 'create' : 'edit';
|
this.mode = event.url.startsWith('/createCommercialPropertyListing') ? 'create' : 'edit';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.route.data.subscribe(async () => {
|
this.route.data.subscribe(async () => {
|
||||||
if (this.router.getCurrentNavigation().extras.state) {
|
if (this.router.getCurrentNavigation().extras.state) {
|
||||||
this.data = this.router.getCurrentNavigation().extras.state['data'];
|
this.data = this.router.getCurrentNavigation().extras.state['data'];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.typesOfCommercialProperty = selectOptions.typesOfCommercialProperty.map(e => {
|
this.typesOfCommercialProperty = selectOptions.typesOfCommercialProperty.map(e => {
|
||||||
return { name: e.name, value: e.value };
|
return { name: e.name, value: e.value };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
const token = await this.authService.getToken();
|
const token = await this.authService.getToken();
|
||||||
const keycloakUser = map2User(token);
|
const keycloakUser = map2User(token);
|
||||||
const email = keycloakUser.email;
|
const email = keycloakUser.email;
|
||||||
this.user = await this.userService.getByMail(email);
|
this.user = await this.userService.getByMail(email);
|
||||||
this.listingCategories = this.selectOptions.listingCategories
|
this.listingCategories = this.selectOptions.listingCategories
|
||||||
.filter(lc => lc.value === 'commercialProperty' || this.user.customerType === 'professional')
|
.filter(lc => lc.value === 'commercialProperty' || this.user.customerType === 'professional')
|
||||||
.map(e => {
|
.map(e => {
|
||||||
return { name: e.name, value: e.value };
|
return { name: e.name, value: e.value };
|
||||||
});
|
});
|
||||||
if (this.mode === 'edit') {
|
if (this.mode === 'edit') {
|
||||||
this.listing = (await lastValueFrom(this.listingsService.getListingById(this.id, 'commercialProperty'))) as CommercialPropertyListing;
|
this.listing = (await lastValueFrom(this.listingsService.getListingById(this.id, 'commercialProperty'))) as CommercialPropertyListing;
|
||||||
} else {
|
} else {
|
||||||
this.listing = createDefaultCommercialPropertyListing();
|
this.listing = createDefaultCommercialPropertyListing();
|
||||||
const listingUser = await this.userService.getByMail(keycloakUser.email);
|
const listingUser = await this.userService.getByMail(keycloakUser.email);
|
||||||
this.listing.email = listingUser.email;
|
this.listing.email = listingUser.email;
|
||||||
this.listing.imagePath = `${emailToDirName(keycloakUser.email)}`;
|
this.listing.imagePath = `${emailToDirName(keycloakUser.email)}`;
|
||||||
if (this.data) {
|
if (this.data) {
|
||||||
this.listing.title = this.data?.title;
|
this.listing.title = this.data?.title;
|
||||||
this.listing.description = this.data?.description;
|
this.listing.description = this.data?.description;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
|
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
|
||||||
}
|
}
|
||||||
async save() {
|
async save() {
|
||||||
try {
|
try {
|
||||||
this.listing = (await this.listingsService.save(this.listing, this.listing.listingsCategory)) as CommercialPropertyListing;
|
this.listing = (await this.listingsService.save(this.listing, this.listing.listingsCategory)) as CommercialPropertyListing;
|
||||||
this.router.navigate(['editCommercialPropertyListing', this.listing.id]);
|
this.router.navigate(['editCommercialPropertyListing', this.listing.id]);
|
||||||
this.messageService.addMessage({ severity: 'success', text: 'Listing changes have been persisted', duration: 3000 });
|
this.messageService.addMessage({ severity: 'success', text: 'Listing changes have been persisted', duration: 3000 });
|
||||||
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
|
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving listing:', error);
|
console.error('Error saving listing:', error);
|
||||||
let errorText = 'An error occurred while saving the listing - Please check your inputs';
|
let errorText = 'An error occurred while saving the listing - Please check your inputs';
|
||||||
|
|
||||||
if (error.error && Array.isArray(error.error?.message)) {
|
if (error.error && Array.isArray(error.error?.message)) {
|
||||||
this.validationMessagesService.updateMessages(error.error.message);
|
this.validationMessagesService.updateMessages(error.error.message);
|
||||||
errorText = 'Please fix the validation errors highlighted in the form';
|
errorText = 'Please fix the validation errors highlighted in the form';
|
||||||
} else if (error.error?.message) {
|
} else if (error.error?.message) {
|
||||||
errorText = `Error: ${error.error.message}`;
|
errorText = `Error: ${error.error.message}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.messageService.addMessage({
|
this.messageService.addMessage({
|
||||||
severity: 'danger',
|
severity: 'danger',
|
||||||
text: errorText,
|
text: errorText,
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(event: AutoCompleteCompleteEvent) {
|
async search(event: AutoCompleteCompleteEvent) {
|
||||||
const result = await lastValueFrom(this.geoService.findCitiesStartingWith(event.query));
|
const result = await lastValueFrom(this.geoService.findCitiesStartingWith(event.query));
|
||||||
this.suggestions = result.map(r => r.name).slice(0, 5);
|
this.suggestions = result.map(r => r.name).slice(0, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadPropertyPicture() {
|
uploadPropertyPicture() {
|
||||||
this.uploadParams = { type: 'uploadPropertyPicture', imagePath: this.listing.imagePath, serialId: this.listing.serialId };
|
this.uploadParams = { type: 'uploadPropertyPicture', imagePath: this.listing.imagePath, serialId: this.listing.serialId };
|
||||||
}
|
}
|
||||||
async uploadFinished(response: UploadReponse) {
|
async uploadFinished(response: UploadReponse) {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
this.listing = (await lastValueFrom(this.listingsService.getListingById(this.id, 'commercialProperty'))) as CommercialPropertyListing;
|
this.listing = (await lastValueFrom(this.listingsService.getListingById(this.id, 'commercialProperty'))) as CommercialPropertyListing;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteConfirm(imageName: string) {
|
async deleteConfirm(imageName: string) {
|
||||||
const confirmed = await this.confirmationService.showConfirmation({ message: 'Are you sure you want to delete this image?' });
|
const confirmed = await this.confirmationService.showConfirmation({ message: 'Are you sure you want to delete this image?' });
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
this.listing.imageOrder = this.listing.imageOrder.filter(item => item !== imageName);
|
this.listing.imageOrder = this.listing.imageOrder.filter(item => item !== imageName);
|
||||||
await this.imageService.deleteListingImage(this.listing.imagePath, this.listing.serialId, imageName);
|
await this.imageService.deleteListingImage(this.listing.imagePath, this.listing.serialId, imageName);
|
||||||
await this.listingsService.save(this.listing, 'commercialProperty');
|
await this.listingsService.save(this.listing, 'commercialProperty');
|
||||||
this.listing = (await lastValueFrom(this.listingsService.getListingById(this.id, 'commercialProperty'))) as CommercialPropertyListing;
|
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.messageService.addMessage({ severity: 'success', text: 'Image has been deleted', duration: 3000 });
|
||||||
this.ts = new Date().getTime();
|
this.ts = new Date().getTime();
|
||||||
} else {
|
} else {
|
||||||
console.log('deny');
|
console.log('deny');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
changeListingCategory(value: 'business' | 'commercialProperty') {
|
changeListingCategory(value: 'business' | 'commercialProperty') {
|
||||||
routeListingWithState(this.router, value, this.listing);
|
routeListingWithState(this.router, value, this.listing);
|
||||||
}
|
}
|
||||||
imageOrderChanged(imageOrder: string[]) {
|
imageOrderChanged(imageOrder: string[]) {
|
||||||
this.listing.imageOrder = imageOrder;
|
this.listing.imageOrder = imageOrder;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,132 +1,132 @@
|
||||||
<div class="container mx-auto p-4">
|
<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">
|
<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>
|
<h1 class="text-2xl font-bold md:mb-4">My Favorites</h1>
|
||||||
|
|
||||||
<!-- Desktop view -->
|
<!-- Desktop view -->
|
||||||
<div class="hidden md:block">
|
<div class="hidden md:block">
|
||||||
<table class="w-full bg-white drop-shadow-inner-faint rounded-lg overflow-hidden">
|
<table class="w-full bg-white drop-shadow-inner-faint rounded-lg overflow-hidden">
|
||||||
<thead class="bg-gray-100">
|
<thead class="bg-gray-100">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="py-2 px-4 text-left">Title</th>
|
<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">Category</th>
|
||||||
<th class="py-2 px-4 text-left">Located in</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">Price</th>
|
||||||
<th class="py-2 px-4 text-left">Action</th>
|
<th class="py-2 px-4 text-left">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for(listing of favorites; track listing){
|
@for(listing of favorites; track listing){
|
||||||
@if(isBusinessOrCommercial(listing)){
|
@if(isBusinessOrCommercial(listing)){
|
||||||
<tr class="border-b">
|
<tr class="border-b">
|
||||||
<td class="py-2 px-4">{{ $any(listing).title }}</td>
|
<td class="py-2 px-4">{{ $any(listing).title }}</td>
|
||||||
<td class="py-2 px-4">{{ $any(listing).listingsCategory === 'commercialProperty' ? 'Commercial Property' :
|
<td class="py-2 px-4">{{ $any(listing).listingsCategory === 'commercialProperty' ? 'Commercial Property' :
|
||||||
'Business' }}</td>
|
'Business' }}</td>
|
||||||
<td class="py-2 px-4">{{ listing.location.name ? listing.location.name : listing.location.county }}, {{
|
<td class="py-2 px-4">{{ listing.location.name ? listing.location.name : listing.location.county }}, {{
|
||||||
listing.location.state }}</td>
|
listing.location.state }}</td>
|
||||||
<td class="py-2 px-4">${{ $any(listing).price.toLocaleString() }}</td>
|
<td class="py-2 px-4">${{ $any(listing).price.toLocaleString() }}</td>
|
||||||
<td class="py-2 px-4 flex">
|
<td class="py-2 px-4 flex">
|
||||||
@if($any(listing).listingsCategory==='business'){
|
@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"
|
<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]">
|
[routerLink]="['/business', $any(listing).slug || listing.id]">
|
||||||
<i class="fa-regular fa-eye"></i>
|
<i class="fa-regular fa-eye"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
} @if($any(listing).listingsCategory==='commercialProperty'){
|
} @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"
|
<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]">
|
[routerLink]="['/commercial-property', $any(listing).slug || listing.id]">
|
||||||
<i class="fa-regular fa-eye"></i>
|
<i class="fa-regular fa-eye"></i>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<button class="bg-red-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
|
<button class="bg-red-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
|
||||||
(click)="confirmDelete(listing)">
|
(click)="confirmDelete(listing)">
|
||||||
<i class="fa-solid fa-trash"></i>
|
<i class="fa-solid fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
} @else {
|
} @else {
|
||||||
<tr class="border-b">
|
<tr class="border-b">
|
||||||
<td class="py-2 px-4">{{ $any(listing).firstname }} {{ $any(listing).lastname }}</td>
|
<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">Professional</td>
|
||||||
<td class="py-2 px-4">{{ listing.location?.name ? listing.location.name : listing.location?.county
|
<td class="py-2 px-4">{{ listing.location?.name ? listing.location.name : listing.location?.county
|
||||||
}}, {{ listing.location?.state }}</td>
|
}}, {{ listing.location?.state }}</td>
|
||||||
<td class="py-2 px-4">-</td>
|
<td class="py-2 px-4">-</td>
|
||||||
<td class="py-2 px-4 flex">
|
<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"
|
<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]">
|
[routerLink]="['/details-user', listing.id]">
|
||||||
<i class="fa-regular fa-eye"></i>
|
<i class="fa-regular fa-eye"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="bg-red-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
|
<button class="bg-red-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
|
||||||
(click)="confirmDelete(listing)">
|
(click)="confirmDelete(listing)">
|
||||||
<i class="fa-solid fa-trash"></i>
|
<i class="fa-solid fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile view -->
|
<!-- Mobile view -->
|
||||||
<div class="md:hidden">
|
<div class="md:hidden">
|
||||||
<div *ngFor="let listing of favorites" class="bg-white drop-shadow-inner-faint rounded-lg p-4 mb-4">
|
<div *ngFor="let listing of favorites" class="bg-white drop-shadow-inner-faint rounded-lg p-4 mb-4">
|
||||||
@if(isBusinessOrCommercial(listing)){
|
@if(isBusinessOrCommercial(listing)){
|
||||||
<h2 class="text-xl font-semibold mb-2">{{ $any(listing).title }}</h2>
|
<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
|
<p class="text-gray-600 mb-2">Category: {{ $any(listing).listingsCategory === 'commercialProperty' ? 'Commercial
|
||||||
Property' : 'Business' }}</p>
|
Property' : 'Business' }}</p>
|
||||||
<p class="text-gray-600 mb-2">Located in: {{ listing.location.name ? listing.location.name :
|
<p class="text-gray-600 mb-2">Located in: {{ listing.location.name ? listing.location.name :
|
||||||
listing.location.county }}, {{ listing.location.state }}</p>
|
listing.location.county }}, {{ listing.location.state }}</p>
|
||||||
<p class="text-gray-600 mb-2">Price: ${{ $any(listing).price.toLocaleString() }}</p>
|
<p class="text-gray-600 mb-2">Price: ${{ $any(listing).price.toLocaleString() }}</p>
|
||||||
<div class="flex justify-start">
|
<div class="flex justify-start">
|
||||||
@if($any(listing).listingsCategory==='business'){
|
@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"
|
<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]">
|
[routerLink]="['/business', $any(listing).slug || listing.id]">
|
||||||
<i class="fa-regular fa-eye"></i>
|
<i class="fa-regular fa-eye"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
} @if($any(listing).listingsCategory==='commercialProperty'){
|
} @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"
|
<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]">
|
[routerLink]="['/commercial-property', $any(listing).slug || listing.id]">
|
||||||
<i class="fa-regular fa-eye"></i>
|
<i class="fa-regular fa-eye"></i>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<button class="bg-red-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
|
<button class="bg-red-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
|
||||||
(click)="confirmDelete(listing)">
|
(click)="confirmDelete(listing)">
|
||||||
<i class="fa-solid fa-trash"></i>
|
<i class="fa-solid fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<h2 class="text-xl font-semibold mb-2">{{ $any(listing).firstname }} {{ $any(listing).lastname }}</h2>
|
<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">Category: Professional</p>
|
||||||
<p class="text-gray-600 mb-2">Located in: {{ listing.location?.name ? listing.location.name :
|
<p class="text-gray-600 mb-2">Located in: {{ listing.location?.name ? listing.location.name :
|
||||||
listing.location?.county }}, {{ listing.location?.state }}</p>
|
listing.location?.county }}, {{ listing.location?.state }}</p>
|
||||||
<div class="flex justify-start">
|
<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"
|
<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]">
|
[routerLink]="['/details-user', listing.id]">
|
||||||
<i class="fa-regular fa-eye"></i>
|
<i class="fa-regular fa-eye"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="bg-red-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
|
<button class="bg-red-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
|
||||||
(click)="confirmDelete(listing)">
|
(click)="confirmDelete(listing)">
|
||||||
<i class="fa-solid fa-trash"></i>
|
<i class="fa-solid fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- <div class="flex items-center justify-between mt-4">
|
<!-- <div class="flex items-center justify-between mt-4">
|
||||||
<p class="text-sm text-gray-600">Showing 1 to 2 of 2 entries</p>
|
<p class="text-sm text-gray-600">Showing 1 to 2 of 2 entries</p>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<button class="px-2 py-1 border rounded-l-md bg-gray-100"><<</button>
|
<button class="px-2 py-1 border rounded-l-md bg-gray-100"><<</button>
|
||||||
<button class="px-2 py-1 border-t border-b bg-gray-100"><</button>
|
<button class="px-2 py-1 border-t border-b bg-gray-100"><</button>
|
||||||
<button class="px-2 py-1 border bg-blue-500 text-white">1</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">></button>
|
<button class="px-2 py-1 border-t border-b bg-gray-100">></button>
|
||||||
<button class="px-2 py-1 border rounded-r-md bg-gray-100">>></button>
|
<button class="px-2 py-1 border rounded-r-md bg-gray-100">>></button>
|
||||||
<select class="ml-2 border rounded-md px-2 py-1">
|
<select class="ml-2 border rounded-md px-2 py-1">
|
||||||
<option>10</option>
|
<option>10</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div> -->
|
</div> -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<app-confirmation></app-confirmation>
|
<app-confirmation></app-confirmation>
|
||||||
|
|
@ -1,54 +1,54 @@
|
||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { BusinessListing, CommercialPropertyListing, User } from '../../../../../../bizmatch-server/src/models/db.model';
|
import { BusinessListing, CommercialPropertyListing, User } from '../../../../../../bizmatch-server/src/models/db.model';
|
||||||
import { KeycloakUser } from '../../../../../../bizmatch-server/src/models/main.model';
|
import { KeycloakUser } from '../../../../../../bizmatch-server/src/models/main.model';
|
||||||
import { ConfirmationComponent } from '../../../components/confirmation/confirmation.component';
|
import { ConfirmationComponent } from '../../../components/confirmation/confirmation.component';
|
||||||
import { ConfirmationService } from '../../../components/confirmation/confirmation.service';
|
import { ConfirmationService } from '../../../components/confirmation/confirmation.service';
|
||||||
import { AuthService } from '../../../services/auth.service';
|
import { AuthService } from '../../../services/auth.service';
|
||||||
import { ListingsService } from '../../../services/listings.service';
|
import { ListingsService } from '../../../services/listings.service';
|
||||||
import { SelectOptionsService } from '../../../services/select-options.service';
|
import { SelectOptionsService } from '../../../services/select-options.service';
|
||||||
import { SharedModule } from '../../../shared/shared/shared.module';
|
import { SharedModule } from '../../../shared/shared/shared.module';
|
||||||
import { map2User } from '../../../utils/utils';
|
import { map2User } from '../../../utils/utils';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-favorites',
|
selector: 'app-favorites',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [SharedModule, ConfirmationComponent],
|
imports: [SharedModule, ConfirmationComponent],
|
||||||
templateUrl: './favorites.component.html',
|
templateUrl: './favorites.component.html',
|
||||||
styleUrl: './favorites.component.scss',
|
styleUrl: './favorites.component.scss',
|
||||||
})
|
})
|
||||||
export class FavoritesComponent {
|
export class FavoritesComponent {
|
||||||
user: KeycloakUser;
|
user: KeycloakUser;
|
||||||
// listings: Array<ListingType> = []; //= dataListings as unknown as Array<BusinessListing>;
|
// listings: Array<ListingType> = []; //= dataListings as unknown as Array<BusinessListing>;
|
||||||
favorites: Array<BusinessListing | CommercialPropertyListing | User>;
|
favorites: Array<BusinessListing | CommercialPropertyListing | User>;
|
||||||
constructor(private listingsService: ListingsService, public selectOptions: SelectOptionsService, private confirmationService: ConfirmationService, private authService: AuthService) { }
|
constructor(private listingsService: ListingsService, public selectOptions: SelectOptionsService, private confirmationService: ConfirmationService, private authService: AuthService) { }
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
const token = await this.authService.getToken();
|
const token = await this.authService.getToken();
|
||||||
this.user = map2User(token);
|
this.user = map2User(token);
|
||||||
const result = await Promise.all([await this.listingsService.getFavoriteListings('business'), await this.listingsService.getFavoriteListings('commercialProperty'), await this.listingsService.getFavoriteListings('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>;
|
this.favorites = [...result[0], ...result[1], ...result[2]] as Array<BusinessListing | CommercialPropertyListing | User>;
|
||||||
}
|
}
|
||||||
async confirmDelete(listing: 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?` });
|
const confirmed = await this.confirmationService.showConfirmation({ message: `Are you sure you want to remove this listing from your Favorites?` });
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
// this.messageService.showMessage('Listing has been deleted');
|
// this.messageService.showMessage('Listing has been deleted');
|
||||||
this.deleteListing(listing);
|
this.deleteListing(listing);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async deleteListing(listing: BusinessListing | CommercialPropertyListing | User) {
|
async deleteListing(listing: BusinessListing | CommercialPropertyListing | User) {
|
||||||
if ('listingsCategory' in listing) {
|
if ('listingsCategory' in listing) {
|
||||||
if (listing.listingsCategory === 'business') {
|
if (listing.listingsCategory === 'business') {
|
||||||
await this.listingsService.removeFavorite(listing.id, 'business');
|
await this.listingsService.removeFavorite(listing.id, 'business');
|
||||||
} else {
|
} else {
|
||||||
await this.listingsService.removeFavorite(listing.id, 'commercialProperty');
|
await this.listingsService.removeFavorite(listing.id, 'commercialProperty');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await this.listingsService.removeFavorite(listing.id, 'user');
|
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')]);
|
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>;
|
this.favorites = [...result[0], ...result[1], ...result[2]] as Array<BusinessListing | CommercialPropertyListing | User>;
|
||||||
}
|
}
|
||||||
|
|
||||||
isBusinessOrCommercial(listing: any): boolean {
|
isBusinessOrCommercial(listing: any): boolean {
|
||||||
return !!listing.listingsCategory;
|
return !!listing.listingsCategory;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,116 +1,116 @@
|
||||||
import { ChangeDetectorRef, Component } from '@angular/core';
|
import { ChangeDetectorRef, Component } from '@angular/core';
|
||||||
import { CommercialPropertyListing, User } from '../../../../../../bizmatch-server/src/models/db.model';
|
import { CommercialPropertyListing, User } from '../../../../../../bizmatch-server/src/models/db.model';
|
||||||
import { ListingType } from '../../../../../../bizmatch-server/src/models/main.model';
|
import { ListingType } from '../../../../../../bizmatch-server/src/models/main.model';
|
||||||
import { ConfirmationComponent } from '../../../components/confirmation/confirmation.component';
|
import { ConfirmationComponent } from '../../../components/confirmation/confirmation.component';
|
||||||
import { ConfirmationService } from '../../../components/confirmation/confirmation.service';
|
import { ConfirmationService } from '../../../components/confirmation/confirmation.service';
|
||||||
import { MessageComponent } from '../../../components/message/message.component';
|
import { MessageComponent } from '../../../components/message/message.component';
|
||||||
import { MessageService } from '../../../components/message/message.service';
|
import { MessageService } from '../../../components/message/message.service';
|
||||||
import { AuthService } from '../../../services/auth.service';
|
import { AuthService } from '../../../services/auth.service';
|
||||||
import { ListingsService } from '../../../services/listings.service';
|
import { ListingsService } from '../../../services/listings.service';
|
||||||
import { SelectOptionsService } from '../../../services/select-options.service';
|
import { SelectOptionsService } from '../../../services/select-options.service';
|
||||||
import { UserService } from '../../../services/user.service';
|
import { UserService } from '../../../services/user.service';
|
||||||
import { SharedModule } from '../../../shared/shared/shared.module';
|
import { SharedModule } from '../../../shared/shared/shared.module';
|
||||||
import { map2User } from '../../../utils/utils';
|
import { map2User } from '../../../utils/utils';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-my-listing',
|
selector: 'app-my-listing',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [SharedModule, ConfirmationComponent],
|
imports: [SharedModule, ConfirmationComponent],
|
||||||
providers: [],
|
providers: [],
|
||||||
templateUrl: './my-listing.component.html',
|
templateUrl: './my-listing.component.html',
|
||||||
styleUrl: './my-listing.component.scss',
|
styleUrl: './my-listing.component.scss',
|
||||||
})
|
})
|
||||||
export class MyListingComponent {
|
export class MyListingComponent {
|
||||||
// Vollständige, ungefilterte Daten
|
// Vollständige, ungefilterte Daten
|
||||||
listings: Array<ListingType> = [];
|
listings: Array<ListingType> = [];
|
||||||
// Aktuell angezeigte (gefilterte) Daten
|
// Aktuell angezeigte (gefilterte) Daten
|
||||||
myListings: Array<ListingType> = [];
|
myListings: Array<ListingType> = [];
|
||||||
|
|
||||||
user: User;
|
user: User;
|
||||||
|
|
||||||
// VERY small filter state
|
// VERY small filter state
|
||||||
filters = {
|
filters = {
|
||||||
title: '',
|
title: '',
|
||||||
internalListingNumber: '',
|
internalListingNumber: '',
|
||||||
location: '',
|
location: '',
|
||||||
status: '' as '' | 'published' | 'draft',
|
status: '' as '' | 'published' | 'draft',
|
||||||
category: '' as '' | 'business' | 'commercialProperty', // <── NEU
|
category: '' as '' | 'business' | 'commercialProperty', // <── NEU
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public userService: UserService,
|
public userService: UserService,
|
||||||
private listingsService: ListingsService,
|
private listingsService: ListingsService,
|
||||||
private cdRef: ChangeDetectorRef,
|
private cdRef: ChangeDetectorRef,
|
||||||
public selectOptions: SelectOptionsService,
|
public selectOptions: SelectOptionsService,
|
||||||
private messageService: MessageService,
|
private messageService: MessageService,
|
||||||
private confirmationService: ConfirmationService,
|
private confirmationService: ConfirmationService,
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
const token = await this.authService.getToken();
|
const token = await this.authService.getToken();
|
||||||
const keycloakUser = map2User(token);
|
const keycloakUser = map2User(token);
|
||||||
const email = keycloakUser.email;
|
const email = keycloakUser.email;
|
||||||
this.user = await this.userService.getByMail(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')]);
|
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.listings = [...result[0], ...result[1]];
|
||||||
this.myListings = this.listings;
|
this.myListings = this.listings;
|
||||||
}
|
}
|
||||||
private normalize(s: string | undefined | null): string {
|
private normalize(s: string | undefined | null): string {
|
||||||
return (s ?? '')
|
return (s ?? '')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]+/g, ' ') // Kommas, Bindestriche etc. neutralisieren
|
.replace(/[^a-z0-9]+/g, ' ') // Kommas, Bindestriche etc. neutralisieren
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/\s+/g, ' '); // Mehrfach-Spaces zu einem Space
|
.replace(/\s+/g, ' '); // Mehrfach-Spaces zu einem Space
|
||||||
}
|
}
|
||||||
applyFilters() {
|
applyFilters() {
|
||||||
const titleQ = this.normalize(this.filters.title);
|
const titleQ = this.normalize(this.filters.title);
|
||||||
const locQ = this.normalize(this.filters.location);
|
const locQ = this.normalize(this.filters.location);
|
||||||
const intQ = this.normalize(this.filters.internalListingNumber);
|
const intQ = this.normalize(this.filters.internalListingNumber);
|
||||||
const catQ = this.filters.category; // <── NEU
|
const catQ = this.filters.category; // <── NEU
|
||||||
const status = this.filters.status;
|
const status = this.filters.status;
|
||||||
|
|
||||||
this.myListings = this.listings.filter(l => {
|
this.myListings = this.listings.filter(l => {
|
||||||
const okTitle = !titleQ || this.normalize(l.title).includes(titleQ);
|
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 locStr = this.normalize(`${l.location?.name ? l.location.name : l.location?.county} ${l.location?.state}`);
|
||||||
const okLoc = !locQ || locStr.includes(locQ);
|
const okLoc = !locQ || locStr.includes(locQ);
|
||||||
|
|
||||||
const ilnStr = this.normalize((l as any).internalListingNumber?.toString());
|
const ilnStr = this.normalize((l as any).internalListingNumber?.toString());
|
||||||
const okInt = !intQ || ilnStr.includes(intQ);
|
const okInt = !intQ || ilnStr.includes(intQ);
|
||||||
|
|
||||||
const okCat = !catQ || l.listingsCategory === catQ; // <── NEU
|
const okCat = !catQ || l.listingsCategory === catQ; // <── NEU
|
||||||
|
|
||||||
const isDraft = !!(l as any).draft;
|
const isDraft = !!(l as any).draft;
|
||||||
const okStatus = !status || (status === 'published' && !isDraft) || (status === 'draft' && isDraft);
|
const okStatus = !status || (status === 'published' && !isDraft) || (status === 'draft' && isDraft);
|
||||||
|
|
||||||
return okTitle && okLoc && okInt && okCat && okStatus; // <── NEU
|
return okTitle && okLoc && okInt && okCat && okStatus; // <── NEU
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
clearFilters() {
|
clearFilters() {
|
||||||
this.filters = { title: '', internalListingNumber: '', location: '', status: '', category: '' };
|
this.filters = { title: '', internalListingNumber: '', location: '', status: '', category: '' };
|
||||||
this.myListings = this.listings;
|
this.myListings = this.listings;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteListing(listing: ListingType) {
|
async deleteListing(listing: ListingType) {
|
||||||
if (listing.listingsCategory === 'business') {
|
if (listing.listingsCategory === 'business') {
|
||||||
await this.listingsService.deleteBusinessListing(listing.id);
|
await this.listingsService.deleteBusinessListing(listing.id);
|
||||||
} else {
|
} else {
|
||||||
await this.listingsService.deleteCommercialPropertyListing(listing.id, (listing as CommercialPropertyListing).imagePath);
|
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')]);
|
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.listings = [...result[0], ...result[1]];
|
||||||
this.applyFilters(); // Filter beibehalten nach Löschen
|
this.applyFilters(); // Filter beibehalten nach Löschen
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirm(listing: ListingType) {
|
async confirm(listing: ListingType) {
|
||||||
const confirmed = await this.confirmationService.showConfirmation({ message: `Are you sure you want to delete this listing?` });
|
const confirmed = await this.confirmationService.showConfirmation({ message: `Are you sure you want to delete this listing?` });
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
// this.messageService.showMessage('Listing has been deleted');
|
// this.messageService.showMessage('Listing has been deleted');
|
||||||
this.deleteListing(listing);
|
this.deleteListing(listing);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
import { isPlatformBrowser } from '@angular/common';
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
import { inject, PLATFORM_ID } from '@angular/core';
|
import { inject, PLATFORM_ID } from '@angular/core';
|
||||||
import { ResolveFn } from '@angular/router';
|
import { ResolveFn } from '@angular/router';
|
||||||
import { KeycloakService } from '../services/keycloak.service';
|
import { KeycloakService } from '../services/keycloak.service';
|
||||||
|
|
||||||
export const authResolver: ResolveFn<boolean> = async (route, state) => {
|
export const authResolver: ResolveFn<boolean> = async (route, state) => {
|
||||||
const keycloakService: KeycloakService = inject(KeycloakService);
|
const keycloakService: KeycloakService = inject(KeycloakService);
|
||||||
const platformId = inject(PLATFORM_ID);
|
const platformId = inject(PLATFORM_ID);
|
||||||
const isBrowser = isPlatformBrowser(platformId);
|
const isBrowser = isPlatformBrowser(platformId);
|
||||||
|
|
||||||
if (!keycloakService.isLoggedIn() && isBrowser) {
|
if (!keycloakService.isLoggedIn() && isBrowser) {
|
||||||
await keycloakService.login({
|
await keycloakService.login({
|
||||||
redirectUri: window.location.href,
|
redirectUri: window.location.href,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,120 +1,120 @@
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { SelectOptionsService } from './select-options.service';
|
import { SelectOptionsService } from './select-options.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for generating SEO-optimized alt text for images
|
* Service for generating SEO-optimized alt text for images
|
||||||
* Ensures consistent, keyword-rich alt text across the application
|
* Ensures consistent, keyword-rich alt text across the application
|
||||||
*/
|
*/
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class AltTextService {
|
export class AltTextService {
|
||||||
private selectOptions = inject(SelectOptionsService);
|
private selectOptions = inject(SelectOptionsService);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate alt text for business listing images
|
* Generate alt text for business listing images
|
||||||
* Format: "Business Name - Business Type for sale in City, State"
|
* Format: "Business Name - Business Type for sale in City, State"
|
||||||
* Example: "Italian Restaurant - Restaurant for sale in Austin, TX"
|
* Example: "Italian Restaurant - Restaurant for sale in Austin, TX"
|
||||||
*/
|
*/
|
||||||
generateBusinessListingAlt(listing: any): string {
|
generateBusinessListingAlt(listing: any): string {
|
||||||
const location = this.getLocationString(listing.location);
|
const location = this.getLocationString(listing.location);
|
||||||
const businessType = listing.type ? this.selectOptions.getBusiness(listing.type) : 'Business';
|
const businessType = listing.type ? this.selectOptions.getBusiness(listing.type) : 'Business';
|
||||||
|
|
||||||
return `${listing.title} - ${businessType} for sale in ${location}`;
|
return `${listing.title} - ${businessType} for sale in ${location}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate alt text for commercial property listing images
|
* Generate alt text for commercial property listing images
|
||||||
* Format: "Property Type for sale - Title in City, State"
|
* Format: "Property Type for sale - Title in City, State"
|
||||||
* Example: "Retail Space for sale - Downtown storefront in Miami, FL"
|
* Example: "Retail Space for sale - Downtown storefront in Miami, FL"
|
||||||
*/
|
*/
|
||||||
generatePropertyListingAlt(listing: any): string {
|
generatePropertyListingAlt(listing: any): string {
|
||||||
const location = this.getLocationString(listing.location);
|
const location = this.getLocationString(listing.location);
|
||||||
const propertyType = listing.type ? this.selectOptions.getCommercialProperty(listing.type) : 'Commercial Property';
|
const propertyType = listing.type ? this.selectOptions.getCommercialProperty(listing.type) : 'Commercial Property';
|
||||||
|
|
||||||
return `${propertyType} for sale - ${listing.title} in ${location}`;
|
return `${propertyType} for sale - ${listing.title} in ${location}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate alt text for broker/user profile photos
|
* Generate alt text for broker/user profile photos
|
||||||
* Format: "First Last - Customer Type broker profile photo"
|
* Format: "First Last - Customer Type broker profile photo"
|
||||||
* Example: "John Smith - Business broker profile photo"
|
* Example: "John Smith - Business broker profile photo"
|
||||||
*/
|
*/
|
||||||
generateBrokerProfileAlt(user: any): string {
|
generateBrokerProfileAlt(user: any): string {
|
||||||
const name = `${user.firstname || ''} ${user.lastname || ''}`.trim() || 'Broker';
|
const name = `${user.firstname || ''} ${user.lastname || ''}`.trim() || 'Broker';
|
||||||
const customerType = user.customerSubType ? this.selectOptions.getCustomerSubType(user.customerSubType) : 'Business';
|
const customerType = user.customerSubType ? this.selectOptions.getCustomerSubType(user.customerSubType) : 'Business';
|
||||||
|
|
||||||
return `${name} - ${customerType} broker profile photo`;
|
return `${name} - ${customerType} broker profile photo`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate alt text for company logos
|
* Generate alt text for company logos
|
||||||
* Format: "Company Name company logo" or "Owner Name company logo"
|
* Format: "Company Name company logo" or "Owner Name company logo"
|
||||||
* Example: "ABC Realty company logo"
|
* Example: "ABC Realty company logo"
|
||||||
*/
|
*/
|
||||||
generateCompanyLogoAlt(companyName?: string, ownerName?: string): string {
|
generateCompanyLogoAlt(companyName?: string, ownerName?: string): string {
|
||||||
const name = companyName || ownerName || 'Company';
|
const name = companyName || ownerName || 'Company';
|
||||||
return `${name} company logo`;
|
return `${name} company logo`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate alt text for listing card logo images
|
* Generate alt text for listing card logo images
|
||||||
* Includes business name and location for context
|
* Includes business name and location for context
|
||||||
*/
|
*/
|
||||||
generateListingCardLogoAlt(listing: any): string {
|
generateListingCardLogoAlt(listing: any): string {
|
||||||
const location = this.getLocationString(listing.location);
|
const location = this.getLocationString(listing.location);
|
||||||
return `${listing.title} - Business for sale in ${location}`;
|
return `${listing.title} - Business for sale in ${location}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate alt text for property images in detail view
|
* Generate alt text for property images in detail view
|
||||||
* Format: "Title - Property Type image (index)"
|
* Format: "Title - Property Type image (index)"
|
||||||
* Example: "Downtown Office Space - Office building image 1 of 5"
|
* Example: "Downtown Office Space - Office building image 1 of 5"
|
||||||
*/
|
*/
|
||||||
generatePropertyImageAlt(title: string, propertyType: string, imageIndex?: number, totalImages?: number): string {
|
generatePropertyImageAlt(title: string, propertyType: string, imageIndex?: number, totalImages?: number): string {
|
||||||
let alt = `${title} - ${propertyType}`;
|
let alt = `${title} - ${propertyType}`;
|
||||||
|
|
||||||
if (imageIndex !== undefined && totalImages !== undefined && totalImages > 1) {
|
if (imageIndex !== undefined && totalImages !== undefined && totalImages > 1) {
|
||||||
alt += ` image ${imageIndex + 1} of ${totalImages}`;
|
alt += ` image ${imageIndex + 1} of ${totalImages}`;
|
||||||
} else {
|
} else {
|
||||||
alt += ' image';
|
alt += ' image';
|
||||||
}
|
}
|
||||||
|
|
||||||
return alt;
|
return alt;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate alt text for business images in detail view
|
* Generate alt text for business images in detail view
|
||||||
* Format: "Title - Business Type image (index)"
|
* Format: "Title - Business Type image (index)"
|
||||||
*/
|
*/
|
||||||
generateBusinessImageAlt(title: string, businessType: string, imageIndex?: number, totalImages?: number): string {
|
generateBusinessImageAlt(title: string, businessType: string, imageIndex?: number, totalImages?: number): string {
|
||||||
let alt = `${title} - ${businessType}`;
|
let alt = `${title} - ${businessType}`;
|
||||||
|
|
||||||
if (imageIndex !== undefined && totalImages !== undefined && totalImages > 1) {
|
if (imageIndex !== undefined && totalImages !== undefined && totalImages > 1) {
|
||||||
alt += ` image ${imageIndex + 1} of ${totalImages}`;
|
alt += ` image ${imageIndex + 1} of ${totalImages}`;
|
||||||
} else {
|
} else {
|
||||||
alt += ' image';
|
alt += ' image';
|
||||||
}
|
}
|
||||||
|
|
||||||
return alt;
|
return alt;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper: Get location string from location object
|
* Helper: Get location string from location object
|
||||||
* Returns: "City, STATE" or "County, STATE" or "STATE"
|
* Returns: "City, STATE" or "County, STATE" or "STATE"
|
||||||
*/
|
*/
|
||||||
private getLocationString(location: any): string {
|
private getLocationString(location: any): string {
|
||||||
if (!location) return 'United States';
|
if (!location) return 'United States';
|
||||||
|
|
||||||
const city = location.name || location.county;
|
const city = location.name || location.county;
|
||||||
const state = location.state || '';
|
const state = location.state || '';
|
||||||
|
|
||||||
if (city && state) {
|
if (city && state) {
|
||||||
return `${city}, ${state}`;
|
return `${city}, ${state}`;
|
||||||
} else if (state) {
|
} else if (state) {
|
||||||
return this.selectOptions.getState(state) || state;
|
return this.selectOptions.getState(state) || state;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'United States';
|
return 'United States';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,42 @@
|
||||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { lastValueFrom } from 'rxjs';
|
import { lastValueFrom } from 'rxjs';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { EventTypeEnum, ListingEvent } from '../../../../bizmatch-server/src/models/db.model';
|
import { EventTypeEnum, ListingEvent } from '../../../../bizmatch-server/src/models/db.model';
|
||||||
import { LogMessage } from '../../../../bizmatch-server/src/models/main.model';
|
import { LogMessage } from '../../../../bizmatch-server/src/models/main.model';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
import { GeoService } from './geo.service';
|
import { GeoService } from './geo.service';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class AuditService {
|
export class AuditService {
|
||||||
private apiBaseUrl = environment.apiBaseUrl;
|
private apiBaseUrl = environment.apiBaseUrl;
|
||||||
private apiKey = environment.ipinfo_token;
|
private apiKey = environment.ipinfo_token;
|
||||||
|
|
||||||
constructor(private http: HttpClient, private geoService: GeoService) {}
|
constructor(private http: HttpClient, private geoService: GeoService) {}
|
||||||
|
|
||||||
async log(message: LogMessage): Promise<void> {
|
async log(message: LogMessage): Promise<void> {
|
||||||
lastValueFrom(this.http.post(`${this.apiBaseUrl}/bizmatch/log`, message));
|
lastValueFrom(this.http.post(`${this.apiBaseUrl}/bizmatch/log`, message));
|
||||||
}
|
}
|
||||||
async createEvent(id: string, eventType: EventTypeEnum, userId: string, additionalData?): Promise<void> {
|
async createEvent(id: string, eventType: EventTypeEnum, userId: string, additionalData?): Promise<void> {
|
||||||
const ipInfo = await this.geoService.getIpInfo();
|
const ipInfo = await this.geoService.getIpInfo();
|
||||||
const [latitude, longitude] = ipInfo.loc ? ipInfo.loc.split(',') : [null, null]; //.map(Number);
|
const [latitude, longitude] = ipInfo.loc ? ipInfo.loc.split(',') : [null, null]; //.map(Number);
|
||||||
const listingEvent: ListingEvent = {
|
const listingEvent: ListingEvent = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
listingId: id,
|
listingId: id,
|
||||||
eventType,
|
eventType,
|
||||||
eventTimestamp: new Date(),
|
eventTimestamp: new Date(),
|
||||||
userAgent: navigator.userAgent,
|
userAgent: navigator.userAgent,
|
||||||
email: userId,
|
email: userId,
|
||||||
userIp: ipInfo.ip,
|
userIp: ipInfo.ip,
|
||||||
locationCountry: ipInfo.country,
|
locationCountry: ipInfo.country,
|
||||||
locationCity: ipInfo.city,
|
locationCity: ipInfo.city,
|
||||||
locationLat: latitude,
|
locationLat: latitude,
|
||||||
locationLng: longitude,
|
locationLng: longitude,
|
||||||
additionalData,
|
additionalData,
|
||||||
};
|
};
|
||||||
let headers = new HttpHeaders().set('X-Hide-Loading', 'true');
|
let headers = new HttpHeaders().set('X-Hide-Loading', 'true');
|
||||||
lastValueFrom(this.http.post(`${this.apiBaseUrl}/bizmatch/event`, listingEvent, { headers }));
|
lastValueFrom(this.http.post(`${this.apiBaseUrl}/bizmatch/event`, listingEvent, { headers }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,356 +1,356 @@
|
||||||
// auth.service.ts
|
// auth.service.ts
|
||||||
import { isPlatformBrowser } from '@angular/common';
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
import { HttpClient, HttpBackend, HttpHeaders, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpBackend, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||||
import { Injectable, inject, PLATFORM_ID } from '@angular/core';
|
import { Injectable, inject, PLATFORM_ID } from '@angular/core';
|
||||||
import { FirebaseApp } from '@angular/fire/app';
|
import { FirebaseApp } from '@angular/fire/app';
|
||||||
import { GoogleAuthProvider, UserCredential, createUserWithEmailAndPassword, getAuth, signInWithCustomToken, signInWithEmailAndPassword, signInWithPopup } from 'firebase/auth';
|
import { GoogleAuthProvider, UserCredential, createUserWithEmailAndPassword, getAuth, signInWithCustomToken, signInWithEmailAndPassword, signInWithPopup } from 'firebase/auth';
|
||||||
import { BehaviorSubject, Observable, catchError, firstValueFrom, map, of, shareReplay, take, tap } from 'rxjs';
|
import { BehaviorSubject, Observable, catchError, firstValueFrom, map, of, shareReplay, take, tap } from 'rxjs';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
import { MailService } from './mail.service';
|
import { MailService } from './mail.service';
|
||||||
|
|
||||||
export type UserRole = 'admin' | 'pro' | 'guest';
|
export type UserRole = 'admin' | 'pro' | 'guest';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private app = inject(FirebaseApp);
|
private app = inject(FirebaseApp);
|
||||||
private platformId = inject(PLATFORM_ID);
|
private platformId = inject(PLATFORM_ID);
|
||||||
private isBrowser = isPlatformBrowser(this.platformId);
|
private isBrowser = isPlatformBrowser(this.platformId);
|
||||||
private auth = this.isBrowser ? getAuth(this.app) : null;
|
private auth = this.isBrowser ? getAuth(this.app) : null;
|
||||||
private http = new HttpClient(inject(HttpBackend));
|
private http = new HttpClient(inject(HttpBackend));
|
||||||
private mailService = inject(MailService);
|
private mailService = inject(MailService);
|
||||||
// Add a BehaviorSubject to track the current user role
|
// Add a BehaviorSubject to track the current user role
|
||||||
private userRoleSubject = new BehaviorSubject<UserRole | null>(null);
|
private userRoleSubject = new BehaviorSubject<UserRole | null>(null);
|
||||||
public userRole$ = this.userRoleSubject.asObservable();
|
public userRole$ = this.userRoleSubject.asObservable();
|
||||||
// Referenz für den gecachten API-Aufruf
|
// Referenz für den gecachten API-Aufruf
|
||||||
private cachedUserRole$: Observable<UserRole | null> | null = null;
|
private cachedUserRole$: Observable<UserRole | null> | null = null;
|
||||||
|
|
||||||
// Zeitraum in ms, nach dem der Cache zurückgesetzt werden soll (z.B. 5 Minuten)
|
// Zeitraum in ms, nach dem der Cache zurückgesetzt werden soll (z.B. 5 Minuten)
|
||||||
private cacheDuration = 5 * 60 * 1000;
|
private cacheDuration = 5 * 60 * 1000;
|
||||||
private lastCacheTime = 0;
|
private lastCacheTime = 0;
|
||||||
constructor() {
|
constructor() {
|
||||||
// Load role from token when service is initialized
|
// Load role from token when service is initialized
|
||||||
this.loadRoleFromToken();
|
this.loadRoleFromToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper methods for localStorage access (only in browser)
|
// Helper methods for localStorage access (only in browser)
|
||||||
private setLocalStorageItem(key: string, value: string): void {
|
private setLocalStorageItem(key: string, value: string): void {
|
||||||
if (this.isBrowser) {
|
if (this.isBrowser) {
|
||||||
localStorage.setItem(key, value);
|
localStorage.setItem(key, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLocalStorageItem(key: string): string | null {
|
private getLocalStorageItem(key: string): string | null {
|
||||||
if (this.isBrowser) {
|
if (this.isBrowser) {
|
||||||
return localStorage.getItem(key);
|
return localStorage.getItem(key);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private removeLocalStorageItem(key: string): void {
|
private removeLocalStorageItem(key: string): void {
|
||||||
if (this.isBrowser) {
|
if (this.isBrowser) {
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadRoleFromToken(): void {
|
private loadRoleFromToken(): void {
|
||||||
this.getToken().then(token => {
|
this.getToken().then(token => {
|
||||||
if (token) {
|
if (token) {
|
||||||
const role = this.extractRoleFromToken(token);
|
const role = this.extractRoleFromToken(token);
|
||||||
this.userRoleSubject.next(role);
|
this.userRoleSubject.next(role);
|
||||||
} else {
|
} else {
|
||||||
this.userRoleSubject.next(null);
|
this.userRoleSubject.next(null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractRoleFromToken(token: string): UserRole | null {
|
private extractRoleFromToken(token: string): UserRole | null {
|
||||||
try {
|
try {
|
||||||
const payloadBase64 = token.split('.')[1];
|
const payloadBase64 = token.split('.')[1];
|
||||||
const payloadJson = atob(payloadBase64.replace(/-/g, '+').replace(/_/g, '/'));
|
const payloadJson = atob(payloadBase64.replace(/-/g, '+').replace(/_/g, '/'));
|
||||||
const payload = JSON.parse(payloadJson);
|
const payload = JSON.parse(payloadJson);
|
||||||
return (payload.role as UserRole) || null;
|
return (payload.role as UserRole) || null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Registrierung mit Email und Passwort
|
// Registrierung mit Email und Passwort
|
||||||
async registerWithEmail(email: string, password: string): Promise<UserCredential> {
|
async registerWithEmail(email: string, password: string): Promise<UserCredential> {
|
||||||
if (!this.isBrowser || !this.auth) {
|
if (!this.isBrowser || !this.auth) {
|
||||||
throw new Error('Auth is only available in browser context');
|
throw new Error('Auth is only available in browser context');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bestimmen der aktuellen Umgebung/Domain für die Verifizierungs-URL
|
// Bestimmen der aktuellen Umgebung/Domain für die Verifizierungs-URL
|
||||||
let verificationUrl = 'https://www.bizmatch.net/email-authorized';
|
let verificationUrl = 'https://www.bizmatch.net/email-authorized';
|
||||||
|
|
||||||
// Prüfen der aktuellen Umgebung basierend auf dem Host (nur im Browser)
|
// Prüfen der aktuellen Umgebung basierend auf dem Host (nur im Browser)
|
||||||
if (this.isBrowser) {
|
if (this.isBrowser) {
|
||||||
const currentHost = window.location.hostname;
|
const currentHost = window.location.hostname;
|
||||||
|
|
||||||
if (currentHost.includes('localhost')) {
|
if (currentHost.includes('localhost')) {
|
||||||
verificationUrl = 'http://localhost:4200/email-authorized';
|
verificationUrl = 'http://localhost:4200/email-authorized';
|
||||||
} else if (currentHost.includes('dev.bizmatch.net')) {
|
} else if (currentHost.includes('dev.bizmatch.net')) {
|
||||||
verificationUrl = 'https://dev.bizmatch.net/email-authorized';
|
verificationUrl = 'https://dev.bizmatch.net/email-authorized';
|
||||||
} else {
|
} else {
|
||||||
verificationUrl = 'https://www.bizmatch.net/email-authorized';
|
verificationUrl = 'https://www.bizmatch.net/email-authorized';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActionCode-Einstellungen mit der dynamischen URL
|
// ActionCode-Einstellungen mit der dynamischen URL
|
||||||
const actionCodeSettings = {
|
const actionCodeSettings = {
|
||||||
url: `${verificationUrl}?email=${email}`,
|
url: `${verificationUrl}?email=${email}`,
|
||||||
handleCodeInApp: true,
|
handleCodeInApp: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Benutzer erstellen
|
// Benutzer erstellen
|
||||||
const userCredential = await createUserWithEmailAndPassword(this.auth, email, password);
|
const userCredential = await createUserWithEmailAndPassword(this.auth, email, password);
|
||||||
|
|
||||||
// E-Mail-Verifizierung mit den angepassten ActionCode-Einstellungen senden
|
// E-Mail-Verifizierung mit den angepassten ActionCode-Einstellungen senden
|
||||||
if (userCredential.user) {
|
if (userCredential.user) {
|
||||||
//await sendEmailVerification(userCredential.user, actionCodeSettings);
|
//await sendEmailVerification(userCredential.user, actionCodeSettings);
|
||||||
this.mailService.sendVerificationEmail(userCredential.user.email).subscribe({
|
this.mailService.sendVerificationEmail(userCredential.user.email).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
console.log('Verification email sent successfully');
|
console.log('Verification email sent successfully');
|
||||||
// Erfolgsmeldung anzeigen
|
// Erfolgsmeldung anzeigen
|
||||||
},
|
},
|
||||||
error: error => {
|
error: error => {
|
||||||
console.error('Error sending verification email', error);
|
console.error('Error sending verification email', error);
|
||||||
// Fehlermeldung anzeigen
|
// Fehlermeldung anzeigen
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// const token = await userCredential.user.getIdToken();
|
// const token = await userCredential.user.getIdToken();
|
||||||
// this.setLocalStorageItem('authToken', token);
|
// this.setLocalStorageItem('authToken', token);
|
||||||
// this.setLocalStorageItem('refreshToken', userCredential.user.refreshToken);
|
// this.setLocalStorageItem('refreshToken', userCredential.user.refreshToken);
|
||||||
// if (userCredential.user.photoURL) {
|
// if (userCredential.user.photoURL) {
|
||||||
// this.setLocalStorageItem('photoURL', userCredential.user.photoURL);
|
// this.setLocalStorageItem('photoURL', userCredential.user.photoURL);
|
||||||
// }
|
// }
|
||||||
|
|
||||||
return userCredential;
|
return userCredential;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login mit Email und Passwort
|
// Login mit Email und Passwort
|
||||||
loginWithEmail(email: string, password: string): Promise<UserCredential> {
|
loginWithEmail(email: string, password: string): Promise<UserCredential> {
|
||||||
if (!this.isBrowser || !this.auth) {
|
if (!this.isBrowser || !this.auth) {
|
||||||
throw new Error('Auth is only available in browser context');
|
throw new Error('Auth is only available in browser context');
|
||||||
}
|
}
|
||||||
return signInWithEmailAndPassword(this.auth, email, password).then(async userCredential => {
|
return signInWithEmailAndPassword(this.auth, email, password).then(async userCredential => {
|
||||||
if (userCredential.user) {
|
if (userCredential.user) {
|
||||||
const token = await userCredential.user.getIdToken();
|
const token = await userCredential.user.getIdToken();
|
||||||
this.setLocalStorageItem('authToken', token);
|
this.setLocalStorageItem('authToken', token);
|
||||||
this.setLocalStorageItem('refreshToken', userCredential.user.refreshToken);
|
this.setLocalStorageItem('refreshToken', userCredential.user.refreshToken);
|
||||||
if (userCredential.user.photoURL) {
|
if (userCredential.user.photoURL) {
|
||||||
this.setLocalStorageItem('photoURL', userCredential.user.photoURL);
|
this.setLocalStorageItem('photoURL', userCredential.user.photoURL);
|
||||||
}
|
}
|
||||||
this.loadRoleFromToken();
|
this.loadRoleFromToken();
|
||||||
}
|
}
|
||||||
return userCredential;
|
return userCredential;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login mit Google
|
// Login mit Google
|
||||||
loginWithGoogle(): Promise<UserCredential> {
|
loginWithGoogle(): Promise<UserCredential> {
|
||||||
if (!this.isBrowser || !this.auth) {
|
if (!this.isBrowser || !this.auth) {
|
||||||
throw new Error('Auth is only available in browser context');
|
throw new Error('Auth is only available in browser context');
|
||||||
}
|
}
|
||||||
const provider = new GoogleAuthProvider();
|
const provider = new GoogleAuthProvider();
|
||||||
return signInWithPopup(this.auth, provider).then(async userCredential => {
|
return signInWithPopup(this.auth, provider).then(async userCredential => {
|
||||||
if (userCredential.user) {
|
if (userCredential.user) {
|
||||||
const token = await userCredential.user.getIdToken();
|
const token = await userCredential.user.getIdToken();
|
||||||
this.setLocalStorageItem('authToken', token);
|
this.setLocalStorageItem('authToken', token);
|
||||||
this.setLocalStorageItem('refreshToken', userCredential.user.refreshToken);
|
this.setLocalStorageItem('refreshToken', userCredential.user.refreshToken);
|
||||||
if (userCredential.user.photoURL) {
|
if (userCredential.user.photoURL) {
|
||||||
this.setLocalStorageItem('photoURL', userCredential.user.photoURL);
|
this.setLocalStorageItem('photoURL', userCredential.user.photoURL);
|
||||||
}
|
}
|
||||||
this.loadRoleFromToken();
|
this.loadRoleFromToken();
|
||||||
}
|
}
|
||||||
return userCredential;
|
return userCredential;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logout: Token, RefreshToken und photoURL entfernen
|
// Logout: Token, RefreshToken und photoURL entfernen
|
||||||
logout(): Promise<void> {
|
logout(): Promise<void> {
|
||||||
this.removeLocalStorageItem('authToken');
|
this.removeLocalStorageItem('authToken');
|
||||||
this.removeLocalStorageItem('refreshToken');
|
this.removeLocalStorageItem('refreshToken');
|
||||||
this.removeLocalStorageItem('photoURL');
|
this.removeLocalStorageItem('photoURL');
|
||||||
this.clearRoleCache();
|
this.clearRoleCache();
|
||||||
this.userRoleSubject.next(null);
|
this.userRoleSubject.next(null);
|
||||||
if (this.auth) {
|
if (this.auth) {
|
||||||
return this.auth.signOut();
|
return this.auth.signOut();
|
||||||
}
|
}
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
isAdmin(): Observable<boolean> {
|
isAdmin(): Observable<boolean> {
|
||||||
return this.userRole$.pipe(
|
return this.userRole$.pipe(
|
||||||
map(role => role === 'admin'),
|
map(role => role === 'admin'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Get current user's role from the server with caching
|
// Get current user's role from the server with caching
|
||||||
getUserRole(): Observable<UserRole | null> {
|
getUserRole(): Observable<UserRole | null> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// Cache zurücksetzen, wenn die Caching-Zeit abgelaufen ist oder kein Cache existiert
|
// Cache zurücksetzen, wenn die Caching-Zeit abgelaufen ist oder kein Cache existiert
|
||||||
if (!this.cachedUserRole$ || now - this.lastCacheTime > this.cacheDuration) {
|
if (!this.cachedUserRole$ || now - this.lastCacheTime > this.cacheDuration) {
|
||||||
if (!this.getLocalStorageItem('authToken')) {
|
if (!this.getLocalStorageItem('authToken')) {
|
||||||
return of(null);
|
return of(null);
|
||||||
}
|
}
|
||||||
this.lastCacheTime = now;
|
this.lastCacheTime = now;
|
||||||
let headers = new HttpHeaders().set('X-Hide-Loading', 'true').set('Accept-Language', 'en-US');
|
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(
|
this.cachedUserRole$ = this.http.get<{ role: UserRole | null }>(`${environment.apiBaseUrl}/bizmatch/auth/me/role`, { headers }).pipe(
|
||||||
map(response => response.role),
|
map(response => response.role),
|
||||||
tap(role => this.userRoleSubject.next(role)),
|
tap(role => this.userRoleSubject.next(role)),
|
||||||
catchError(error => {
|
catchError(error => {
|
||||||
console.error('Error fetching user role', error);
|
console.error('Error fetching user role', error);
|
||||||
return of(null);
|
return of(null);
|
||||||
}),
|
}),
|
||||||
// Cache für mehrere Subscriber und behalte den letzten Wert
|
// Cache für mehrere Subscriber und behalte den letzten Wert
|
||||||
// Der Parameter 1 gibt an, dass der letzte Wert gecacht werden soll
|
// 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
|
// refCount: false bedeutet, dass der Cache nicht zurückgesetzt wird, wenn keine Subscriber mehr da sind
|
||||||
shareReplay({ bufferSize: 1, refCount: false }),
|
shareReplay({ bufferSize: 1, refCount: false }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.cachedUserRole$;
|
return this.cachedUserRole$;
|
||||||
}
|
}
|
||||||
clearRoleCache(): void {
|
clearRoleCache(): void {
|
||||||
this.cachedUserRole$ = null;
|
this.cachedUserRole$ = null;
|
||||||
this.lastCacheTime = 0;
|
this.lastCacheTime = 0;
|
||||||
}
|
}
|
||||||
// Check if user has a specific role
|
// Check if user has a specific role
|
||||||
hasRole(role: UserRole): Observable<boolean> {
|
hasRole(role: UserRole): Observable<boolean> {
|
||||||
return this.userRole$.pipe(
|
return this.userRole$.pipe(
|
||||||
map(userRole => {
|
map(userRole => {
|
||||||
if (role === 'guest') {
|
if (role === 'guest') {
|
||||||
// Any authenticated user can access guest features
|
// Any authenticated user can access guest features
|
||||||
return userRole !== null;
|
return userRole !== null;
|
||||||
} else if (role === 'pro') {
|
} else if (role === 'pro') {
|
||||||
// Both pro and admin can access pro features
|
// Both pro and admin can access pro features
|
||||||
return userRole === 'pro' || userRole === 'admin';
|
return userRole === 'pro' || userRole === 'admin';
|
||||||
} else if (role === 'admin') {
|
} else if (role === 'admin') {
|
||||||
// Only admin can access admin features
|
// Only admin can access admin features
|
||||||
return userRole === 'admin';
|
return userRole === 'admin';
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force refresh the token to get updated custom claims
|
// Force refresh the token to get updated custom claims
|
||||||
async refreshUserClaims(): Promise<void> {
|
async refreshUserClaims(): Promise<void> {
|
||||||
this.clearRoleCache();
|
this.clearRoleCache();
|
||||||
if (this.auth && this.auth.currentUser) {
|
if (this.auth && this.auth.currentUser) {
|
||||||
await this.auth.currentUser.getIdToken(true);
|
await this.auth.currentUser.getIdToken(true);
|
||||||
const token = await this.auth.currentUser.getIdToken();
|
const token = await this.auth.currentUser.getIdToken();
|
||||||
this.setLocalStorageItem('authToken', token);
|
this.setLocalStorageItem('authToken', token);
|
||||||
this.loadRoleFromToken();
|
this.loadRoleFromToken();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Prüft, ob ein Token noch gültig ist (über die "exp"-Eigenschaft)
|
// Prüft, ob ein Token noch gültig ist (über die "exp"-Eigenschaft)
|
||||||
private isTokenValid(token: string): boolean {
|
private isTokenValid(token: string): boolean {
|
||||||
try {
|
try {
|
||||||
const payloadBase64 = token.split('.')[1];
|
const payloadBase64 = token.split('.')[1];
|
||||||
const payloadJson = atob(payloadBase64.replace(/-/g, '+').replace(/_/g, '/'));
|
const payloadJson = atob(payloadBase64.replace(/-/g, '+').replace(/_/g, '/'));
|
||||||
const payload = JSON.parse(payloadJson);
|
const payload = JSON.parse(payloadJson);
|
||||||
const exp = payload.exp;
|
const exp = payload.exp;
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
return exp > now;
|
return exp > now;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private isEMailVerified(token: string): boolean {
|
private isEMailVerified(token: string): boolean {
|
||||||
try {
|
try {
|
||||||
const payloadBase64 = token.split('.')[1];
|
const payloadBase64 = token.split('.')[1];
|
||||||
const payloadJson = atob(payloadBase64.replace(/-/g, '+').replace(/_/g, '/'));
|
const payloadJson = atob(payloadBase64.replace(/-/g, '+').replace(/_/g, '/'));
|
||||||
const payload = JSON.parse(payloadJson);
|
const payload = JSON.parse(payloadJson);
|
||||||
return payload.email_verified;
|
return payload.email_verified;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Versucht, mit dem RefreshToken einen neuen Access Token zu erhalten
|
// Versucht, mit dem RefreshToken einen neuen Access Token zu erhalten
|
||||||
async refreshToken(): Promise<string | null> {
|
async refreshToken(): Promise<string | null> {
|
||||||
const storedRefreshToken = this.getLocalStorageItem('refreshToken');
|
const storedRefreshToken = this.getLocalStorageItem('refreshToken');
|
||||||
// SSR protection: refreshToken should only run in browser
|
// SSR protection: refreshToken should only run in browser
|
||||||
if (!this.isBrowser) {
|
if (!this.isBrowser) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!storedRefreshToken) {
|
if (!storedRefreshToken) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const apiKey = environment.firebaseConfig.apiKey; // Stelle sicher, dass dieser Wert in Deiner environment.ts gesetzt ist
|
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 url = `https://securetoken.googleapis.com/v1/token?key=${apiKey}`;
|
||||||
|
|
||||||
const body = new HttpParams().set('grant_type', 'refresh_token').set('refresh_token', storedRefreshToken);
|
const body = new HttpParams().set('grant_type', 'refresh_token').set('refresh_token', storedRefreshToken);
|
||||||
|
|
||||||
const headers = new HttpHeaders({ 'Content-Type': 'application/x-www-form-urlencoded' });
|
const headers = new HttpHeaders({ 'Content-Type': 'application/x-www-form-urlencoded' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response: any = await firstValueFrom(this.http.post(url, body.toString(), { headers }));
|
const response: any = await firstValueFrom(this.http.post(url, body.toString(), { headers }));
|
||||||
// response enthält z. B. id_token, refresh_token, expires_in etc.
|
// response enthält z. B. id_token, refresh_token, expires_in etc.
|
||||||
const newToken = response.id_token;
|
const newToken = response.id_token;
|
||||||
const newRefreshToken = response.refresh_token;
|
const newRefreshToken = response.refresh_token;
|
||||||
this.setLocalStorageItem('authToken', newToken);
|
this.setLocalStorageItem('authToken', newToken);
|
||||||
this.setLocalStorageItem('refreshToken', newRefreshToken);
|
this.setLocalStorageItem('refreshToken', newRefreshToken);
|
||||||
return newToken;
|
return newToken;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error refreshing token:', error);
|
console.error('Error refreshing token:', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gibt einen gültigen Token zurück.
|
* Gibt einen gültigen Token zurück.
|
||||||
* Falls der gespeicherte Token noch gültig ist, wird er zurückgegeben.
|
* Falls der gespeicherte Token noch gültig ist, wird er zurückgegeben.
|
||||||
* Ansonsten wird versucht, einen neuen Token mit dem RefreshToken zu holen.
|
* Ansonsten wird versucht, einen neuen Token mit dem RefreshToken zu holen.
|
||||||
* Ist auch das nicht möglich, wird null zurückgegeben.
|
* Ist auch das nicht möglich, wird null zurückgegeben.
|
||||||
*/
|
*/
|
||||||
async getToken(): Promise<string | null> {
|
async getToken(): Promise<string | null> {
|
||||||
const token = this.getLocalStorageItem('authToken');
|
const token = this.getLocalStorageItem('authToken');
|
||||||
// SSR protection: return null on server
|
// SSR protection: return null on server
|
||||||
if (!this.isBrowser) {
|
if (!this.isBrowser) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token && !this.isEMailVerified(token)) {
|
if (token && !this.isEMailVerified(token)) {
|
||||||
return null;
|
return null;
|
||||||
} else if (token && this.isTokenValid(token) && this.isEMailVerified(token)) {
|
} else if (token && this.isTokenValid(token) && this.isEMailVerified(token)) {
|
||||||
return token;
|
return token;
|
||||||
} else {
|
} else {
|
||||||
return await this.refreshToken();
|
return await this.refreshToken();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add this new method to sign in with a custom token
|
// Add this new method to sign in with a custom token
|
||||||
async signInWithCustomToken(token: string): Promise<void> {
|
async signInWithCustomToken(token: string): Promise<void> {
|
||||||
if (!this.isBrowser || !this.auth) {
|
if (!this.isBrowser || !this.auth) {
|
||||||
throw new Error('Auth is only available in browser context');
|
throw new Error('Auth is only available in browser context');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Sign in to Firebase with the custom token
|
// Sign in to Firebase with the custom token
|
||||||
const userCredential = await signInWithCustomToken(this.auth, token);
|
const userCredential = await signInWithCustomToken(this.auth, token);
|
||||||
|
|
||||||
// Store the authentication token
|
// Store the authentication token
|
||||||
if (userCredential.user) {
|
if (userCredential.user) {
|
||||||
const idToken = await userCredential.user.getIdToken();
|
const idToken = await userCredential.user.getIdToken();
|
||||||
this.setLocalStorageItem('authToken', idToken);
|
this.setLocalStorageItem('authToken', idToken);
|
||||||
this.setLocalStorageItem('refreshToken', userCredential.user.refreshToken);
|
this.setLocalStorageItem('refreshToken', userCredential.user.refreshToken);
|
||||||
|
|
||||||
if (userCredential.user.photoURL) {
|
if (userCredential.user.photoURL) {
|
||||||
this.setLocalStorageItem('photoURL', userCredential.user.photoURL);
|
this.setLocalStorageItem('photoURL', userCredential.user.photoURL);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load user role from the token
|
// Load user role from the token
|
||||||
this.loadRoleFromToken();
|
this.loadRoleFromToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error signing in with custom token:', error);
|
console.error('Error signing in with custom token:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,254 +1,254 @@
|
||||||
import { isPlatformBrowser } from '@angular/common';
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
|
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
|
||||||
import { BehaviorSubject, Observable } from 'rxjs';
|
import { BehaviorSubject, Observable } from 'rxjs';
|
||||||
import { SortByOptions } from '../../../../bizmatch-server/src/models/db.model';
|
import { SortByOptions } from '../../../../bizmatch-server/src/models/db.model';
|
||||||
import { BusinessListingCriteria, CommercialPropertyListingCriteria, UserListingCriteria } from '../../../../bizmatch-server/src/models/main.model';
|
import { BusinessListingCriteria, CommercialPropertyListingCriteria, UserListingCriteria } from '../../../../bizmatch-server/src/models/main.model';
|
||||||
|
|
||||||
type CriteriaType = BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
|
type CriteriaType = BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
|
||||||
type ListingType = 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
|
type ListingType = 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
|
||||||
|
|
||||||
interface FilterState {
|
interface FilterState {
|
||||||
businessListings: {
|
businessListings: {
|
||||||
criteria: BusinessListingCriteria;
|
criteria: BusinessListingCriteria;
|
||||||
sortBy: SortByOptions | null;
|
sortBy: SortByOptions | null;
|
||||||
};
|
};
|
||||||
commercialPropertyListings: {
|
commercialPropertyListings: {
|
||||||
criteria: CommercialPropertyListingCriteria;
|
criteria: CommercialPropertyListingCriteria;
|
||||||
sortBy: SortByOptions | null;
|
sortBy: SortByOptions | null;
|
||||||
};
|
};
|
||||||
brokerListings: {
|
brokerListings: {
|
||||||
criteria: UserListingCriteria;
|
criteria: UserListingCriteria;
|
||||||
sortBy: SortByOptions | null;
|
sortBy: SortByOptions | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class FilterStateService {
|
export class FilterStateService {
|
||||||
private state: FilterState;
|
private state: FilterState;
|
||||||
private stateSubjects: Map<ListingType, BehaviorSubject<any>> = new Map();
|
private stateSubjects: Map<ListingType, BehaviorSubject<any>> = new Map();
|
||||||
private platformId = inject(PLATFORM_ID);
|
private platformId = inject(PLATFORM_ID);
|
||||||
private isBrowser = isPlatformBrowser(this.platformId);
|
private isBrowser = isPlatformBrowser(this.platformId);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Initialize state from sessionStorage or with defaults
|
// Initialize state from sessionStorage or with defaults
|
||||||
this.state = this.loadStateFromStorage();
|
this.state = this.loadStateFromStorage();
|
||||||
|
|
||||||
// Create BehaviorSubjects for each listing type
|
// Create BehaviorSubjects for each listing type
|
||||||
this.stateSubjects.set('businessListings', new BehaviorSubject(this.state.businessListings));
|
this.stateSubjects.set('businessListings', new BehaviorSubject(this.state.businessListings));
|
||||||
this.stateSubjects.set('commercialPropertyListings', new BehaviorSubject(this.state.commercialPropertyListings));
|
this.stateSubjects.set('commercialPropertyListings', new BehaviorSubject(this.state.commercialPropertyListings));
|
||||||
this.stateSubjects.set('brokerListings', new BehaviorSubject(this.state.brokerListings));
|
this.stateSubjects.set('brokerListings', new BehaviorSubject(this.state.brokerListings));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get observable for specific listing type
|
// Get observable for specific listing type
|
||||||
getState$(type: ListingType): Observable<any> {
|
getState$(type: ListingType): Observable<any> {
|
||||||
return this.stateSubjects.get(type)!.asObservable();
|
return this.stateSubjects.get(type)!.asObservable();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current criteria
|
// Get current criteria
|
||||||
getCriteria(type: ListingType): CriteriaType {
|
getCriteria(type: ListingType): CriteriaType {
|
||||||
return { ...this.state[type].criteria };
|
return { ...this.state[type].criteria };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update criteria
|
// Update criteria
|
||||||
updateCriteria(type: ListingType, criteria: Partial<CriteriaType>): void {
|
updateCriteria(type: ListingType, criteria: Partial<CriteriaType>): void {
|
||||||
// Type-safe update basierend auf dem Listing-Typ
|
// Type-safe update basierend auf dem Listing-Typ
|
||||||
if (type === 'businessListings') {
|
if (type === 'businessListings') {
|
||||||
this.state.businessListings.criteria = {
|
this.state.businessListings.criteria = {
|
||||||
...this.state.businessListings.criteria,
|
...this.state.businessListings.criteria,
|
||||||
...criteria,
|
...criteria,
|
||||||
} as BusinessListingCriteria;
|
} as BusinessListingCriteria;
|
||||||
} else if (type === 'commercialPropertyListings') {
|
} else if (type === 'commercialPropertyListings') {
|
||||||
this.state.commercialPropertyListings.criteria = {
|
this.state.commercialPropertyListings.criteria = {
|
||||||
...this.state.commercialPropertyListings.criteria,
|
...this.state.commercialPropertyListings.criteria,
|
||||||
...criteria,
|
...criteria,
|
||||||
} as CommercialPropertyListingCriteria;
|
} as CommercialPropertyListingCriteria;
|
||||||
} else if (type === 'brokerListings') {
|
} else if (type === 'brokerListings') {
|
||||||
this.state.brokerListings.criteria = {
|
this.state.brokerListings.criteria = {
|
||||||
...this.state.brokerListings.criteria,
|
...this.state.brokerListings.criteria,
|
||||||
...criteria,
|
...criteria,
|
||||||
} as UserListingCriteria;
|
} as UserListingCriteria;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.saveToStorage(type);
|
this.saveToStorage(type);
|
||||||
this.emitState(type);
|
this.emitState(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set complete criteria (for reset operations)
|
// Set complete criteria (for reset operations)
|
||||||
setCriteria(type: ListingType, criteria: CriteriaType): void {
|
setCriteria(type: ListingType, criteria: CriteriaType): void {
|
||||||
if (type === 'businessListings') {
|
if (type === 'businessListings') {
|
||||||
this.state.businessListings.criteria = criteria as BusinessListingCriteria;
|
this.state.businessListings.criteria = criteria as BusinessListingCriteria;
|
||||||
} else if (type === 'commercialPropertyListings') {
|
} else if (type === 'commercialPropertyListings') {
|
||||||
this.state.commercialPropertyListings.criteria = criteria as CommercialPropertyListingCriteria;
|
this.state.commercialPropertyListings.criteria = criteria as CommercialPropertyListingCriteria;
|
||||||
} else if (type === 'brokerListings') {
|
} else if (type === 'brokerListings') {
|
||||||
this.state.brokerListings.criteria = criteria as UserListingCriteria;
|
this.state.brokerListings.criteria = criteria as UserListingCriteria;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.saveToStorage(type);
|
this.saveToStorage(type);
|
||||||
this.emitState(type);
|
this.emitState(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current sortBy
|
// Get current sortBy
|
||||||
getSortBy(type: ListingType): SortByOptions | null {
|
getSortBy(type: ListingType): SortByOptions | null {
|
||||||
return this.state[type].sortBy;
|
return this.state[type].sortBy;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update sortBy
|
// Update sortBy
|
||||||
updateSortBy(type: ListingType, sortBy: SortByOptions | null): void {
|
updateSortBy(type: ListingType, sortBy: SortByOptions | null): void {
|
||||||
this.state[type].sortBy = sortBy;
|
this.state[type].sortBy = sortBy;
|
||||||
this.saveSortByToStorage(type, sortBy);
|
this.saveSortByToStorage(type, sortBy);
|
||||||
this.emitState(type);
|
this.emitState(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset criteria to defaults
|
// Reset criteria to defaults
|
||||||
resetCriteria(type: ListingType): void {
|
resetCriteria(type: ListingType): void {
|
||||||
if (type === 'businessListings') {
|
if (type === 'businessListings') {
|
||||||
this.state.businessListings.criteria = this.createEmptyBusinessListingCriteria();
|
this.state.businessListings.criteria = this.createEmptyBusinessListingCriteria();
|
||||||
} else if (type === 'commercialPropertyListings') {
|
} else if (type === 'commercialPropertyListings') {
|
||||||
this.state.commercialPropertyListings.criteria = this.createEmptyCommercialPropertyListingCriteria();
|
this.state.commercialPropertyListings.criteria = this.createEmptyCommercialPropertyListingCriteria();
|
||||||
} else if (type === 'brokerListings') {
|
} else if (type === 'brokerListings') {
|
||||||
this.state.brokerListings.criteria = this.createEmptyUserListingCriteria();
|
this.state.brokerListings.criteria = this.createEmptyUserListingCriteria();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.saveToStorage(type);
|
this.saveToStorage(type);
|
||||||
this.emitState(type);
|
this.emitState(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear all filters but keep sortBy
|
// Clear all filters but keep sortBy
|
||||||
clearFilters(type: ListingType): void {
|
clearFilters(type: ListingType): void {
|
||||||
const sortBy = this.state[type].sortBy;
|
const sortBy = this.state[type].sortBy;
|
||||||
this.resetCriteria(type);
|
this.resetCriteria(type);
|
||||||
this.state[type].sortBy = sortBy;
|
this.state[type].sortBy = sortBy;
|
||||||
this.emitState(type);
|
this.emitState(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
private emitState(type: ListingType): void {
|
private emitState(type: ListingType): void {
|
||||||
this.stateSubjects.get(type)?.next({ ...this.state[type] });
|
this.stateSubjects.get(type)?.next({ ...this.state[type] });
|
||||||
}
|
}
|
||||||
|
|
||||||
private saveToStorage(type: ListingType): void {
|
private saveToStorage(type: ListingType): void {
|
||||||
if (!this.isBrowser) return;
|
if (!this.isBrowser) return;
|
||||||
sessionStorage.setItem(type, JSON.stringify(this.state[type].criteria));
|
sessionStorage.setItem(type, JSON.stringify(this.state[type].criteria));
|
||||||
}
|
}
|
||||||
|
|
||||||
private saveSortByToStorage(type: ListingType, sortBy: SortByOptions | null): void {
|
private saveSortByToStorage(type: ListingType, sortBy: SortByOptions | null): void {
|
||||||
if (!this.isBrowser) return;
|
if (!this.isBrowser) return;
|
||||||
const sortByKey = type === 'businessListings' ? 'businessSortBy' : type === 'commercialPropertyListings' ? 'commercialSortBy' : 'professionalsSortBy';
|
const sortByKey = type === 'businessListings' ? 'businessSortBy' : type === 'commercialPropertyListings' ? 'commercialSortBy' : 'professionalsSortBy';
|
||||||
|
|
||||||
if (sortBy) {
|
if (sortBy) {
|
||||||
sessionStorage.setItem(sortByKey, sortBy);
|
sessionStorage.setItem(sortByKey, sortBy);
|
||||||
} else {
|
} else {
|
||||||
sessionStorage.removeItem(sortByKey);
|
sessionStorage.removeItem(sortByKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadStateFromStorage(): FilterState {
|
private loadStateFromStorage(): FilterState {
|
||||||
return {
|
return {
|
||||||
businessListings: {
|
businessListings: {
|
||||||
criteria: this.loadCriteriaFromStorage('businessListings') as BusinessListingCriteria,
|
criteria: this.loadCriteriaFromStorage('businessListings') as BusinessListingCriteria,
|
||||||
sortBy: this.loadSortByFromStorage('businessSortBy'),
|
sortBy: this.loadSortByFromStorage('businessSortBy'),
|
||||||
},
|
},
|
||||||
commercialPropertyListings: {
|
commercialPropertyListings: {
|
||||||
criteria: this.loadCriteriaFromStorage('commercialPropertyListings') as CommercialPropertyListingCriteria,
|
criteria: this.loadCriteriaFromStorage('commercialPropertyListings') as CommercialPropertyListingCriteria,
|
||||||
sortBy: this.loadSortByFromStorage('commercialSortBy'),
|
sortBy: this.loadSortByFromStorage('commercialSortBy'),
|
||||||
},
|
},
|
||||||
brokerListings: {
|
brokerListings: {
|
||||||
criteria: this.loadCriteriaFromStorage('brokerListings') as UserListingCriteria,
|
criteria: this.loadCriteriaFromStorage('brokerListings') as UserListingCriteria,
|
||||||
sortBy: this.loadSortByFromStorage('professionalsSortBy'),
|
sortBy: this.loadSortByFromStorage('professionalsSortBy'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadCriteriaFromStorage(key: ListingType): CriteriaType {
|
private loadCriteriaFromStorage(key: ListingType): CriteriaType {
|
||||||
if (this.isBrowser) {
|
if (this.isBrowser) {
|
||||||
const stored = sessionStorage.getItem(key);
|
const stored = sessionStorage.getItem(key);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
return JSON.parse(stored);
|
return JSON.parse(stored);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'businessListings':
|
case 'businessListings':
|
||||||
return this.createEmptyBusinessListingCriteria();
|
return this.createEmptyBusinessListingCriteria();
|
||||||
case 'commercialPropertyListings':
|
case 'commercialPropertyListings':
|
||||||
return this.createEmptyCommercialPropertyListingCriteria();
|
return this.createEmptyCommercialPropertyListingCriteria();
|
||||||
case 'brokerListings':
|
case 'brokerListings':
|
||||||
return this.createEmptyUserListingCriteria();
|
return this.createEmptyUserListingCriteria();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadSortByFromStorage(key: string): SortByOptions | null {
|
private loadSortByFromStorage(key: string): SortByOptions | null {
|
||||||
if (!this.isBrowser) return null;
|
if (!this.isBrowser) return null;
|
||||||
const stored = sessionStorage.getItem(key);
|
const stored = sessionStorage.getItem(key);
|
||||||
return stored && stored !== 'null' ? (stored as SortByOptions) : null;
|
return stored && stored !== 'null' ? (stored as SortByOptions) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper methods to create empty criteria
|
// Helper methods to create empty criteria
|
||||||
private createEmptyBusinessListingCriteria(): BusinessListingCriteria {
|
private createEmptyBusinessListingCriteria(): BusinessListingCriteria {
|
||||||
return {
|
return {
|
||||||
criteriaType: 'businessListings',
|
criteriaType: 'businessListings',
|
||||||
types: [],
|
types: [],
|
||||||
state: null,
|
state: null,
|
||||||
city: null,
|
city: null,
|
||||||
radius: null,
|
radius: null,
|
||||||
searchType: 'exact' as const,
|
searchType: 'exact' as const,
|
||||||
minPrice: null,
|
minPrice: null,
|
||||||
maxPrice: null,
|
maxPrice: null,
|
||||||
minRevenue: null,
|
minRevenue: null,
|
||||||
maxRevenue: null,
|
maxRevenue: null,
|
||||||
minCashFlow: null,
|
minCashFlow: null,
|
||||||
maxCashFlow: null,
|
maxCashFlow: null,
|
||||||
minNumberEmployees: null,
|
minNumberEmployees: null,
|
||||||
maxNumberEmployees: null,
|
maxNumberEmployees: null,
|
||||||
establishedMin: null,
|
establishedMin: null,
|
||||||
brokerName: null,
|
brokerName: null,
|
||||||
title: null,
|
title: null,
|
||||||
realEstateChecked: false,
|
realEstateChecked: false,
|
||||||
leasedLocation: false,
|
leasedLocation: false,
|
||||||
franchiseResale: false,
|
franchiseResale: false,
|
||||||
email: null,
|
email: null,
|
||||||
prompt: null,
|
prompt: null,
|
||||||
page: 1,
|
page: 1,
|
||||||
start: 0,
|
start: 0,
|
||||||
length: 12,
|
length: 12,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private createEmptyCommercialPropertyListingCriteria(): CommercialPropertyListingCriteria {
|
private createEmptyCommercialPropertyListingCriteria(): CommercialPropertyListingCriteria {
|
||||||
return {
|
return {
|
||||||
criteriaType: 'commercialPropertyListings',
|
criteriaType: 'commercialPropertyListings',
|
||||||
types: [],
|
types: [],
|
||||||
state: null,
|
state: null,
|
||||||
city: null,
|
city: null,
|
||||||
radius: null,
|
radius: null,
|
||||||
searchType: 'exact' as const,
|
searchType: 'exact' as const,
|
||||||
minPrice: null,
|
minPrice: null,
|
||||||
maxPrice: null,
|
maxPrice: null,
|
||||||
title: null,
|
title: null,
|
||||||
brokerName: null,
|
brokerName: null,
|
||||||
prompt: null,
|
prompt: null,
|
||||||
page: 1,
|
page: 1,
|
||||||
start: 0,
|
start: 0,
|
||||||
length: 12,
|
length: 12,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private createEmptyUserListingCriteria(): UserListingCriteria {
|
private createEmptyUserListingCriteria(): UserListingCriteria {
|
||||||
return {
|
return {
|
||||||
criteriaType: 'brokerListings',
|
criteriaType: 'brokerListings',
|
||||||
types: [],
|
types: [],
|
||||||
state: null,
|
state: null,
|
||||||
city: null,
|
city: null,
|
||||||
radius: null,
|
radius: null,
|
||||||
searchType: 'exact' as const,
|
searchType: 'exact' as const,
|
||||||
brokerName: null,
|
brokerName: null,
|
||||||
companyName: null,
|
companyName: null,
|
||||||
counties: [],
|
counties: [],
|
||||||
prompt: null,
|
prompt: null,
|
||||||
page: 1,
|
page: 1,
|
||||||
start: 0,
|
start: 0,
|
||||||
length: 12,
|
length: 12,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,170 +1,170 @@
|
||||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||||
import { Injectable, PLATFORM_ID, inject } from '@angular/core';
|
import { Injectable, PLATFORM_ID, inject } from '@angular/core';
|
||||||
import { isPlatformBrowser } from '@angular/common';
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
import { lastValueFrom, Observable, of } from 'rxjs';
|
import { lastValueFrom, Observable, of } from 'rxjs';
|
||||||
import { tap } from 'rxjs/operators';
|
import { tap } from 'rxjs/operators';
|
||||||
import { CityAndStateResult, CountyResult, GeoResult, IpInfo } from '../../../../bizmatch-server/src/models/main.model';
|
import { CityAndStateResult, CountyResult, GeoResult, IpInfo } from '../../../../bizmatch-server/src/models/main.model';
|
||||||
import { Place } from '../../../../bizmatch-server/src/models/server.model';
|
import { Place } from '../../../../bizmatch-server/src/models/server.model';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
interface CachedBoundary {
|
interface CachedBoundary {
|
||||||
data: any;
|
data: any;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class GeoService {
|
export class GeoService {
|
||||||
private apiBaseUrl = environment.apiBaseUrl;
|
private apiBaseUrl = environment.apiBaseUrl;
|
||||||
private baseUrl: string = 'https://nominatim.openstreetmap.org/search';
|
private baseUrl: string = 'https://nominatim.openstreetmap.org/search';
|
||||||
private fetchingData: Observable<IpInfo> | null = null;
|
private fetchingData: Observable<IpInfo> | null = null;
|
||||||
private readonly storageKey = 'ipInfo';
|
private readonly storageKey = 'ipInfo';
|
||||||
private readonly boundaryStoragePrefix = 'nominatim_boundary_';
|
private readonly boundaryStoragePrefix = 'nominatim_boundary_';
|
||||||
private readonly cacheExpiration = 30 * 24 * 60 * 60 * 1000; // 30 days in milliseconds
|
private readonly cacheExpiration = 30 * 24 * 60 * 60 * 1000; // 30 days in milliseconds
|
||||||
private platformId = inject(PLATFORM_ID);
|
private platformId = inject(PLATFORM_ID);
|
||||||
private isBrowser = isPlatformBrowser(this.platformId);
|
private isBrowser = isPlatformBrowser(this.platformId);
|
||||||
|
|
||||||
constructor(private http: HttpClient) {}
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get cached boundary data from localStorage
|
* Get cached boundary data from localStorage
|
||||||
*/
|
*/
|
||||||
private getCachedBoundary(cacheKey: string): any | null {
|
private getCachedBoundary(cacheKey: string): any | null {
|
||||||
if (!this.isBrowser) return null;
|
if (!this.isBrowser) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cached = localStorage.getItem(this.boundaryStoragePrefix + cacheKey);
|
const cached = localStorage.getItem(this.boundaryStoragePrefix + cacheKey);
|
||||||
if (!cached) {
|
if (!cached) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cachedData: CachedBoundary = JSON.parse(cached);
|
const cachedData: CachedBoundary = JSON.parse(cached);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
// Check if cache has expired
|
// Check if cache has expired
|
||||||
if (now - cachedData.timestamp > this.cacheExpiration) {
|
if (now - cachedData.timestamp > this.cacheExpiration) {
|
||||||
localStorage.removeItem(this.boundaryStoragePrefix + cacheKey);
|
localStorage.removeItem(this.boundaryStoragePrefix + cacheKey);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return cachedData.data;
|
return cachedData.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error reading boundary cache:', error);
|
console.error('Error reading boundary cache:', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save boundary data to localStorage
|
* Save boundary data to localStorage
|
||||||
*/
|
*/
|
||||||
private setCachedBoundary(cacheKey: string, data: any): void {
|
private setCachedBoundary(cacheKey: string, data: any): void {
|
||||||
if (!this.isBrowser) return;
|
if (!this.isBrowser) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cachedData: CachedBoundary = {
|
const cachedData: CachedBoundary = {
|
||||||
data: data,
|
data: data,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
};
|
};
|
||||||
localStorage.setItem(this.boundaryStoragePrefix + cacheKey, JSON.stringify(cachedData));
|
localStorage.setItem(this.boundaryStoragePrefix + cacheKey, JSON.stringify(cachedData));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving boundary cache:', error);
|
console.error('Error saving boundary cache:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all cached boundary data
|
* Clear all cached boundary data
|
||||||
*/
|
*/
|
||||||
clearBoundaryCache(): void {
|
clearBoundaryCache(): void {
|
||||||
if (!this.isBrowser) return;
|
if (!this.isBrowser) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const keys = Object.keys(localStorage);
|
const keys = Object.keys(localStorage);
|
||||||
keys.forEach(key => {
|
keys.forEach(key => {
|
||||||
if (key.startsWith(this.boundaryStoragePrefix)) {
|
if (key.startsWith(this.boundaryStoragePrefix)) {
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem(key);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error clearing boundary cache:', error);
|
console.error('Error clearing boundary cache:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
findCitiesStartingWith(prefix: string, state?: string): Observable<GeoResult[]> {
|
findCitiesStartingWith(prefix: string, state?: string): Observable<GeoResult[]> {
|
||||||
const stateString = state ? `/${state}` : '';
|
const stateString = state ? `/${state}` : '';
|
||||||
return this.http.get<GeoResult[]>(`${this.apiBaseUrl}/bizmatch/geo/${prefix}${stateString}`);
|
return this.http.get<GeoResult[]>(`${this.apiBaseUrl}/bizmatch/geo/${prefix}${stateString}`);
|
||||||
}
|
}
|
||||||
findCitiesAndStatesStartingWith(prefix: string): Observable<CityAndStateResult[]> {
|
findCitiesAndStatesStartingWith(prefix: string): Observable<CityAndStateResult[]> {
|
||||||
return this.http.get<CityAndStateResult[]>(`${this.apiBaseUrl}/bizmatch/geo/citiesandstates/${prefix}`);
|
return this.http.get<CityAndStateResult[]>(`${this.apiBaseUrl}/bizmatch/geo/citiesandstates/${prefix}`);
|
||||||
}
|
}
|
||||||
findCountiesStartingWith(prefix: string, states?: string[]): Observable<CountyResult[]> {
|
findCountiesStartingWith(prefix: string, states?: string[]): Observable<CountyResult[]> {
|
||||||
return this.http.post<CountyResult[]>(`${this.apiBaseUrl}/bizmatch/geo/counties`, { prefix, states });
|
return this.http.post<CountyResult[]>(`${this.apiBaseUrl}/bizmatch/geo/counties`, { prefix, states });
|
||||||
}
|
}
|
||||||
findLocationStartingWith(prefix: string): Observable<Place[]> {
|
findLocationStartingWith(prefix: string): Observable<Place[]> {
|
||||||
let headers = new HttpHeaders().set('X-Hide-Loading', 'true').set('Accept-Language', 'en-US');
|
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[]>;
|
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> {
|
getCityBoundary(cityName: string, state: string): Observable<any> {
|
||||||
const cacheKey = `city_${cityName}_${state}`.toLowerCase().replace(/\s+/g, '_');
|
const cacheKey = `city_${cityName}_${state}`.toLowerCase().replace(/\s+/g, '_');
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
const cached = this.getCachedBoundary(cacheKey);
|
const cached = this.getCachedBoundary(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return of(cached);
|
return of(cached);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not in cache, fetch from API
|
// If not in cache, fetch from API
|
||||||
const query = `${cityName}, ${state}, USA`;
|
const query = `${cityName}, ${state}, USA`;
|
||||||
let headers = new HttpHeaders().set('X-Hide-Loading', 'true').set('Accept-Language', 'en-US');
|
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(
|
return this.http.get(`${this.baseUrl}?q=${encodeURIComponent(query)}&format=json&polygon_geojson=1&limit=1`, { headers }).pipe(
|
||||||
tap(data => this.setCachedBoundary(cacheKey, data))
|
tap(data => this.setCachedBoundary(cacheKey, data))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getStateBoundary(state: string): Observable<any> {
|
getStateBoundary(state: string): Observable<any> {
|
||||||
const cacheKey = `state_${state}`.toLowerCase().replace(/\s+/g, '_');
|
const cacheKey = `state_${state}`.toLowerCase().replace(/\s+/g, '_');
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
const cached = this.getCachedBoundary(cacheKey);
|
const cached = this.getCachedBoundary(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return of(cached);
|
return of(cached);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not in cache, fetch from API
|
// If not in cache, fetch from API
|
||||||
const query = `${state}, USA`;
|
const query = `${state}, USA`;
|
||||||
let headers = new HttpHeaders().set('X-Hide-Loading', 'true').set('Accept-Language', 'en-US');
|
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(
|
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))
|
tap(data => this.setCachedBoundary(cacheKey, data))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetchIpAndGeoLocation(): Observable<IpInfo> {
|
private fetchIpAndGeoLocation(): Observable<IpInfo> {
|
||||||
return this.http.get<IpInfo>(`${this.apiBaseUrl}/bizmatch/geo/ipinfo/georesult/wysiwyg`);
|
return this.http.get<IpInfo>(`${this.apiBaseUrl}/bizmatch/geo/ipinfo/georesult/wysiwyg`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getIpInfo(): Promise<IpInfo | null> {
|
async getIpInfo(): Promise<IpInfo | null> {
|
||||||
// Versuche zuerst, die Daten aus dem sessionStorage zu holen
|
// Versuche zuerst, die Daten aus dem sessionStorage zu holen
|
||||||
if (this.isBrowser) {
|
if (this.isBrowser) {
|
||||||
const storedData = sessionStorage.getItem(this.storageKey);
|
const storedData = sessionStorage.getItem(this.storageKey);
|
||||||
if (storedData) {
|
if (storedData) {
|
||||||
return JSON.parse(storedData);
|
return JSON.parse(storedData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Wenn keine Daten im Storage, hole sie vom Server
|
// Wenn keine Daten im Storage, hole sie vom Server
|
||||||
const data = await lastValueFrom(this.http.get<IpInfo>(`${this.apiBaseUrl}/bizmatch/geo/ipinfo/georesult/wysiwyg`));
|
const data = await lastValueFrom(this.http.get<IpInfo>(`${this.apiBaseUrl}/bizmatch/geo/ipinfo/georesult/wysiwyg`));
|
||||||
|
|
||||||
// Speichere die Daten im sessionStorage
|
// Speichere die Daten im sessionStorage
|
||||||
if (this.isBrowser) {
|
if (this.isBrowser) {
|
||||||
sessionStorage.setItem(this.storageKey, JSON.stringify(data));
|
sessionStorage.setItem(this.storageKey, JSON.stringify(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching IP info:', error);
|
console.error('Error fetching IP info:', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,103 +1,103 @@
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Observable, lastValueFrom } from 'rxjs';
|
import { Observable, lastValueFrom } from 'rxjs';
|
||||||
import { BusinessListing, CommercialPropertyListing } from '../../../../bizmatch-server/src/models/db.model';
|
import { BusinessListing, CommercialPropertyListing } from '../../../../bizmatch-server/src/models/db.model';
|
||||||
import { ListingType, ResponseBusinessListingArray, ResponseCommercialPropertyListingArray } from '../../../../bizmatch-server/src/models/main.model';
|
import { ListingType, ResponseBusinessListingArray, ResponseCommercialPropertyListingArray } from '../../../../bizmatch-server/src/models/main.model';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
import { getCriteriaByListingCategory, getSortByListingCategory } from '../utils/utils';
|
import { getCriteriaByListingCategory, getSortByListingCategory } from '../utils/utils';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class ListingsService {
|
export class ListingsService {
|
||||||
private apiBaseUrl = environment.apiBaseUrl;
|
private apiBaseUrl = environment.apiBaseUrl;
|
||||||
constructor(private http: HttpClient) { }
|
constructor(private http: HttpClient) { }
|
||||||
|
|
||||||
async getListings(listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty'): Promise<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray> {
|
async getListings(listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty'): Promise<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray> {
|
||||||
const criteria = getCriteriaByListingCategory(listingsCategory);
|
const criteria = getCriteriaByListingCategory(listingsCategory);
|
||||||
const sortBy = getSortByListingCategory(listingsCategory);
|
const sortBy = getSortByListingCategory(listingsCategory);
|
||||||
const body = { ...criteria, sortBy }; // Merge sortBy in Body
|
const body = { ...criteria, sortBy }; // Merge sortBy in Body
|
||||||
const result = await lastValueFrom(this.http.post<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/find`, body));
|
const result = await lastValueFrom(this.http.post<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/find`, body));
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
getNumberOfListings(listingsCategory: 'business' | 'commercialProperty', crit?: any): Observable<number> {
|
getNumberOfListings(listingsCategory: 'business' | 'commercialProperty', crit?: any): Observable<number> {
|
||||||
const criteria = crit ? crit : getCriteriaByListingCategory(listingsCategory);
|
const criteria = crit ? crit : getCriteriaByListingCategory(listingsCategory);
|
||||||
const sortBy = getSortByListingCategory(listingsCategory);
|
const sortBy = getSortByListingCategory(listingsCategory);
|
||||||
const body = { ...criteria, sortBy }; // Merge, falls relevant (wenn Backend sortBy für Count braucht; sonst ignorieren)
|
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);
|
return this.http.post<number>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/findTotal`, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
getListingById(id: string, listingsCategory?: 'business' | 'commercialProperty'): Observable<ListingType> {
|
getListingById(id: string, listingsCategory?: 'business' | 'commercialProperty'): Observable<ListingType> {
|
||||||
const result = this.http.get<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/${id}`);
|
const result = this.http.get<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/${id}`);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
getListingsByEmail(email: string, listingsCategory: 'business' | 'commercialProperty'): Promise<ListingType[]> {
|
getListingsByEmail(email: string, listingsCategory: 'business' | 'commercialProperty'): Promise<ListingType[]> {
|
||||||
return lastValueFrom(this.http.get<BusinessListing[]>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/user/${email}`));
|
return lastValueFrom(this.http.get<BusinessListing[]>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/user/${email}`));
|
||||||
}
|
}
|
||||||
getFavoriteListings(listingsCategory: 'business' | 'commercialProperty' | 'user'): Promise<ListingType[]> {
|
getFavoriteListings(listingsCategory: 'business' | 'commercialProperty' | 'user'): Promise<ListingType[]> {
|
||||||
return lastValueFrom(this.http.post<BusinessListing[] | CommercialPropertyListing[] | any[]>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorites/all`, {}));
|
return lastValueFrom(this.http.post<BusinessListing[] | CommercialPropertyListing[] | any[]>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorites/all`, {}));
|
||||||
}
|
}
|
||||||
async save(listing: any, listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty') {
|
async save(listing: any, listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty') {
|
||||||
if (listing.id) {
|
if (listing.id) {
|
||||||
return await lastValueFrom(this.http.put<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}`, listing));
|
return await lastValueFrom(this.http.put<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}`, listing));
|
||||||
} else {
|
} else {
|
||||||
return await lastValueFrom(this.http.post<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}`, listing));
|
return await lastValueFrom(this.http.post<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}`, listing));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async deleteBusinessListing(id: string) {
|
async deleteBusinessListing(id: string) {
|
||||||
await lastValueFrom(this.http.delete<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/business/listing/${id}`));
|
await lastValueFrom(this.http.delete<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/business/listing/${id}`));
|
||||||
}
|
}
|
||||||
async deleteCommercialPropertyListing(id: string, imagePath: string) {
|
async deleteCommercialPropertyListing(id: string, imagePath: string) {
|
||||||
await lastValueFrom(this.http.delete<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/commercialProperty/listing/${id}/${imagePath}`));
|
await lastValueFrom(this.http.delete<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/commercialProperty/listing/${id}/${imagePath}`));
|
||||||
}
|
}
|
||||||
async addToFavorites(id: string, listingsCategory?: 'business' | 'commercialProperty' | 'user') {
|
async addToFavorites(id: string, listingsCategory?: 'business' | 'commercialProperty' | 'user') {
|
||||||
const url = `${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorite/${id}`;
|
const url = `${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorite/${id}`;
|
||||||
console.log('[ListingsService] addToFavorites calling URL:', url);
|
console.log('[ListingsService] addToFavorites calling URL:', url);
|
||||||
await lastValueFrom(this.http.post<void>(url, {}));
|
await lastValueFrom(this.http.post<void>(url, {}));
|
||||||
}
|
}
|
||||||
async removeFavorite(id: string, listingsCategory?: 'business' | 'commercialProperty' | 'user') {
|
async removeFavorite(id: string, listingsCategory?: 'business' | 'commercialProperty' | 'user') {
|
||||||
const url = `${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorite/${id}`;
|
const url = `${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorite/${id}`;
|
||||||
console.log('[ListingsService] removeFavorite calling URL:', url);
|
console.log('[ListingsService] removeFavorite calling URL:', url);
|
||||||
await lastValueFrom(this.http.delete<ListingType>(url));
|
await lastValueFrom(this.http.delete<ListingType>(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get related listings based on current listing
|
* Get related listings based on current listing
|
||||||
* Finds listings with same category, same state, and similar price range
|
* Finds listings with same category, same state, and similar price range
|
||||||
* @param currentListing The current listing to find related items for
|
* @param currentListing The current listing to find related items for
|
||||||
* @param listingsCategory Type of listings (business or commercialProperty)
|
* @param listingsCategory Type of listings (business or commercialProperty)
|
||||||
* @param limit Maximum number of related listings to return
|
* @param limit Maximum number of related listings to return
|
||||||
* @returns Array of related listings
|
* @returns Array of related listings
|
||||||
*/
|
*/
|
||||||
async getRelatedListings(currentListing: any, listingsCategory: 'business' | 'commercialProperty', limit: number = 3): Promise<ListingType[]> {
|
async getRelatedListings(currentListing: any, listingsCategory: 'business' | 'commercialProperty', limit: number = 3): Promise<ListingType[]> {
|
||||||
const criteria: any = {
|
const criteria: any = {
|
||||||
types: [currentListing.type], // Same category/type
|
types: [currentListing.type], // Same category/type
|
||||||
state: currentListing.location.state, // Same state
|
state: currentListing.location.state, // Same state
|
||||||
minPrice: currentListing.price ? Math.floor(currentListing.price * 0.5) : undefined, // 50% lower
|
minPrice: currentListing.price ? Math.floor(currentListing.price * 0.5) : undefined, // 50% lower
|
||||||
maxPrice: currentListing.price ? Math.ceil(currentListing.price * 1.5) : undefined, // 50% higher
|
maxPrice: currentListing.price ? Math.ceil(currentListing.price * 1.5) : undefined, // 50% higher
|
||||||
page: 0,
|
page: 0,
|
||||||
resultsPerPage: limit + 1, // Get one extra to filter out current listing
|
resultsPerPage: limit + 1, // Get one extra to filter out current listing
|
||||||
showDraft: false
|
showDraft: false
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await lastValueFrom(
|
const response = await lastValueFrom(
|
||||||
this.http.post<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray>(
|
this.http.post<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray>(
|
||||||
`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/find`,
|
`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/find`,
|
||||||
criteria
|
criteria
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter out the current listing and limit results
|
// Filter out the current listing and limit results
|
||||||
const filtered = response.results
|
const filtered = response.results
|
||||||
.filter(listing => listing.id !== currentListing.id)
|
.filter(listing => listing.id !== currentListing.id)
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching related listings:', error);
|
console.error('Error fetching related listings:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,124 +1,124 @@
|
||||||
import { isPlatformBrowser } from '@angular/common';
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
|
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
|
||||||
import { lastValueFrom } from 'rxjs';
|
import { lastValueFrom } from 'rxjs';
|
||||||
import { KeyValue, KeyValueAsSortBy, KeyValueStyle } from '../../../../bizmatch-server/src/models/main.model';
|
import { KeyValue, KeyValueAsSortBy, KeyValueStyle } from '../../../../bizmatch-server/src/models/main.model';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class SelectOptionsService {
|
export class SelectOptionsService {
|
||||||
private apiBaseUrl = environment.apiBaseUrl;
|
private apiBaseUrl = environment.apiBaseUrl;
|
||||||
private platformId = inject(PLATFORM_ID);
|
private platformId = inject(PLATFORM_ID);
|
||||||
constructor(private http: HttpClient) { }
|
constructor(private http: HttpClient) { }
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
// Skip HTTP call on server-side to avoid blocking SSR
|
// Skip HTTP call on server-side to avoid blocking SSR
|
||||||
if (!isPlatformBrowser(this.platformId)) {
|
if (!isPlatformBrowser(this.platformId)) {
|
||||||
console.log('[SSR] SelectOptionsService.init() - Skipping HTTP call on server');
|
console.log('[SSR] SelectOptionsService.init() - Skipping HTTP call on server');
|
||||||
// Initialize with empty arrays - client will hydrate with real data
|
// Initialize with empty arrays - client will hydrate with real data
|
||||||
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 = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const allSelectOptions = await lastValueFrom(this.http.get<any>(`${this.apiBaseUrl}/bizmatch/select-options`));
|
const allSelectOptions = await lastValueFrom(this.http.get<any>(`${this.apiBaseUrl}/bizmatch/select-options`));
|
||||||
this.typesOfBusiness = allSelectOptions.typesOfBusiness;
|
this.typesOfBusiness = allSelectOptions.typesOfBusiness;
|
||||||
this.prices = allSelectOptions.prices;
|
this.prices = allSelectOptions.prices;
|
||||||
this.listingCategories = allSelectOptions.listingCategories;
|
this.listingCategories = allSelectOptions.listingCategories;
|
||||||
this.customerTypes = allSelectOptions.customerTypes;
|
this.customerTypes = allSelectOptions.customerTypes;
|
||||||
this.customerSubTypes = allSelectOptions.customerSubTypes;
|
this.customerSubTypes = allSelectOptions.customerSubTypes;
|
||||||
this.states = allSelectOptions.locations;
|
this.states = allSelectOptions.locations;
|
||||||
this.gender = allSelectOptions.gender;
|
this.gender = allSelectOptions.gender;
|
||||||
this.typesOfCommercialProperty = allSelectOptions.typesOfCommercialProperty;
|
this.typesOfCommercialProperty = allSelectOptions.typesOfCommercialProperty;
|
||||||
this.distances = allSelectOptions.distances;
|
this.distances = allSelectOptions.distances;
|
||||||
this.sortByOptions = allSelectOptions.sortByOptions;
|
this.sortByOptions = allSelectOptions.sortByOptions;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SelectOptionsService] Failed to load select options:', error);
|
console.error('[SelectOptionsService] Failed to load select options:', error);
|
||||||
// Initialize with empty arrays as fallback
|
// Initialize with empty arrays as fallback
|
||||||
this.typesOfBusiness = this.typesOfBusiness || [];
|
this.typesOfBusiness = this.typesOfBusiness || [];
|
||||||
this.prices = this.prices || [];
|
this.prices = this.prices || [];
|
||||||
this.listingCategories = this.listingCategories || [];
|
this.listingCategories = this.listingCategories || [];
|
||||||
this.customerTypes = this.customerTypes || [];
|
this.customerTypes = this.customerTypes || [];
|
||||||
this.customerSubTypes = this.customerSubTypes || [];
|
this.customerSubTypes = this.customerSubTypes || [];
|
||||||
this.states = this.states || [];
|
this.states = this.states || [];
|
||||||
this.gender = this.gender || [];
|
this.gender = this.gender || [];
|
||||||
this.typesOfCommercialProperty = this.typesOfCommercialProperty || [];
|
this.typesOfCommercialProperty = this.typesOfCommercialProperty || [];
|
||||||
this.distances = this.distances || [];
|
this.distances = this.distances || [];
|
||||||
this.sortByOptions = this.sortByOptions || [];
|
this.sortByOptions = this.sortByOptions || [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public typesOfBusiness: Array<KeyValueStyle>;
|
public typesOfBusiness: Array<KeyValueStyle>;
|
||||||
|
|
||||||
public typesOfCommercialProperty: Array<KeyValueStyle>;
|
public typesOfCommercialProperty: Array<KeyValueStyle>;
|
||||||
|
|
||||||
public prices: Array<KeyValue>;
|
public prices: Array<KeyValue>;
|
||||||
|
|
||||||
public listingCategories: Array<KeyValue>;
|
public listingCategories: Array<KeyValue>;
|
||||||
|
|
||||||
public customerTypes: Array<KeyValue>;
|
public customerTypes: Array<KeyValue>;
|
||||||
|
|
||||||
public gender: Array<KeyValue>;
|
public gender: Array<KeyValue>;
|
||||||
|
|
||||||
public states: Array<any>;
|
public states: Array<any>;
|
||||||
public customerSubTypes: Array<KeyValue>;
|
public customerSubTypes: Array<KeyValue>;
|
||||||
public distances: Array<KeyValue>;
|
public distances: Array<KeyValue>;
|
||||||
public sortByOptions: Array<KeyValueAsSortBy>;
|
public sortByOptions: Array<KeyValueAsSortBy>;
|
||||||
getSortByOption(value: string) {
|
getSortByOption(value: string) {
|
||||||
return this.sortByOptions.find(l => l.value === value)?.name;
|
return this.sortByOptions.find(l => l.value === value)?.name;
|
||||||
}
|
}
|
||||||
getState(value: string): string {
|
getState(value: string): string {
|
||||||
return this.states.find(l => l.value === value)?.name;
|
return this.states.find(l => l.value === value)?.name;
|
||||||
}
|
}
|
||||||
getStateInitials(name: string): string {
|
getStateInitials(name: string): string {
|
||||||
return this.states.find(l => l.name === name?.toUpperCase())?.value;
|
return this.states.find(l => l.name === name?.toUpperCase())?.value;
|
||||||
}
|
}
|
||||||
getBusiness(value: string): string {
|
getBusiness(value: string): string {
|
||||||
return this.typesOfBusiness.find(t => t.value === value)?.name;
|
return this.typesOfBusiness.find(t => t.value === value)?.name;
|
||||||
}
|
}
|
||||||
getCommercialProperty(value: string): string {
|
getCommercialProperty(value: string): string {
|
||||||
return this.typesOfCommercialProperty.find(t => t.value === value)?.name;
|
return this.typesOfCommercialProperty.find(t => t.value === value)?.name;
|
||||||
}
|
}
|
||||||
getListingsCategory(value: string): string {
|
getListingsCategory(value: string): string {
|
||||||
return this.listingCategories.find(l => l.value === value)?.name;
|
return this.listingCategories.find(l => l.value === value)?.name;
|
||||||
}
|
}
|
||||||
getCustomerType(value: string): string {
|
getCustomerType(value: string): string {
|
||||||
return this.customerTypes.find(c => c.value === value)?.name;
|
return this.customerTypes.find(c => c.value === value)?.name;
|
||||||
}
|
}
|
||||||
getCustomerSubType(value: string): string {
|
getCustomerSubType(value: string): string {
|
||||||
return this.customerSubTypes.find(c => c.value === value)?.name;
|
return this.customerSubTypes.find(c => c.value === value)?.name;
|
||||||
}
|
}
|
||||||
getGender(value: string): string {
|
getGender(value: string): string {
|
||||||
return this.gender.find(c => c.value === value)?.name;
|
return this.gender.find(c => c.value === value)?.name;
|
||||||
}
|
}
|
||||||
getIconType(value: string): string {
|
getIconType(value: string): string {
|
||||||
return this.typesOfBusiness.find(c => c.value === value)?.icon;
|
return this.typesOfBusiness.find(c => c.value === value)?.icon;
|
||||||
}
|
}
|
||||||
getTextColorType(value: string): string {
|
getTextColorType(value: string): string {
|
||||||
return this.typesOfBusiness.find(c => c.value === value)?.textColorClass;
|
return this.typesOfBusiness.find(c => c.value === value)?.textColorClass;
|
||||||
}
|
}
|
||||||
getIconAndTextColorType(value: string): string {
|
getIconAndTextColorType(value: string): string {
|
||||||
const category = this.typesOfBusiness.find(c => c.value === value);
|
const category = this.typesOfBusiness.find(c => c.value === value);
|
||||||
return `${category?.icon} ${category?.textColorClass}`;
|
return `${category?.icon} ${category?.textColorClass}`;
|
||||||
}
|
}
|
||||||
getIconTypeOfCommercials(value: string): string {
|
getIconTypeOfCommercials(value: string): string {
|
||||||
return this.typesOfCommercialProperty.find(c => c.value === value)?.icon;
|
return this.typesOfCommercialProperty.find(c => c.value === value)?.icon;
|
||||||
}
|
}
|
||||||
getIconAndTextColorTypeOfCommercials(value: string): string {
|
getIconAndTextColorTypeOfCommercials(value: string): string {
|
||||||
const category = this.typesOfCommercialProperty.find(c => c.value === value);
|
const category = this.typesOfCommercialProperty.find(c => c.value === value);
|
||||||
return `${category?.icon} ${category?.textColorClass}`;
|
return `${category?.icon} ${category?.textColorClass}`;
|
||||||
}
|
}
|
||||||
getTextColorTypeOfCommercial(value: string): string {
|
getTextColorTypeOfCommercial(value: string): string {
|
||||||
return this.typesOfCommercialProperty.find(c => c.value === value)?.textColorClass;
|
return this.typesOfCommercialProperty.find(c => c.value === value)?.textColorClass;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,135 +1,135 @@
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
export interface SitemapUrl {
|
export interface SitemapUrl {
|
||||||
loc: string;
|
loc: string;
|
||||||
lastmod?: string;
|
lastmod?: string;
|
||||||
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
||||||
priority?: number;
|
priority?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class SitemapService {
|
export class SitemapService {
|
||||||
private readonly baseUrl = 'https://biz-match.com';
|
private readonly baseUrl = 'https://biz-match.com';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate XML sitemap content
|
* Generate XML sitemap content
|
||||||
*/
|
*/
|
||||||
generateSitemap(urls: SitemapUrl[]): string {
|
generateSitemap(urls: SitemapUrl[]): string {
|
||||||
const urlElements = urls.map(url => this.generateUrlElement(url)).join('\n ');
|
const urlElements = urls.map(url => this.generateUrlElement(url)).join('\n ');
|
||||||
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
${urlElements}
|
${urlElements}
|
||||||
</urlset>`;
|
</urlset>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a single URL element for the sitemap
|
* Generate a single URL element for the sitemap
|
||||||
*/
|
*/
|
||||||
private generateUrlElement(url: SitemapUrl): string {
|
private generateUrlElement(url: SitemapUrl): string {
|
||||||
let element = `<url>\n <loc>${url.loc}</loc>`;
|
let element = `<url>\n <loc>${url.loc}</loc>`;
|
||||||
|
|
||||||
if (url.lastmod) {
|
if (url.lastmod) {
|
||||||
element += `\n <lastmod>${url.lastmod}</lastmod>`;
|
element += `\n <lastmod>${url.lastmod}</lastmod>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.changefreq) {
|
if (url.changefreq) {
|
||||||
element += `\n <changefreq>${url.changefreq}</changefreq>`;
|
element += `\n <changefreq>${url.changefreq}</changefreq>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.priority !== undefined) {
|
if (url.priority !== undefined) {
|
||||||
element += `\n <priority>${url.priority.toFixed(1)}</priority>`;
|
element += `\n <priority>${url.priority.toFixed(1)}</priority>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
element += '\n </url>';
|
element += '\n </url>';
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate sitemap URLs for static pages
|
* Generate sitemap URLs for static pages
|
||||||
*/
|
*/
|
||||||
getStaticPageUrls(): SitemapUrl[] {
|
getStaticPageUrls(): SitemapUrl[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
loc: `${this.baseUrl}/`,
|
loc: `${this.baseUrl}/`,
|
||||||
changefreq: 'daily',
|
changefreq: 'daily',
|
||||||
priority: 1.0
|
priority: 1.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
loc: `${this.baseUrl}/home`,
|
loc: `${this.baseUrl}/home`,
|
||||||
changefreq: 'daily',
|
changefreq: 'daily',
|
||||||
priority: 1.0
|
priority: 1.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
loc: `${this.baseUrl}/listings`,
|
loc: `${this.baseUrl}/listings`,
|
||||||
changefreq: 'daily',
|
changefreq: 'daily',
|
||||||
priority: 0.9
|
priority: 0.9
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
loc: `${this.baseUrl}/listings-2`,
|
loc: `${this.baseUrl}/listings-2`,
|
||||||
changefreq: 'daily',
|
changefreq: 'daily',
|
||||||
priority: 0.8
|
priority: 0.8
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
loc: `${this.baseUrl}/listings-3`,
|
loc: `${this.baseUrl}/listings-3`,
|
||||||
changefreq: 'daily',
|
changefreq: 'daily',
|
||||||
priority: 0.8
|
priority: 0.8
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
loc: `${this.baseUrl}/listings-4`,
|
loc: `${this.baseUrl}/listings-4`,
|
||||||
changefreq: 'daily',
|
changefreq: 'daily',
|
||||||
priority: 0.8
|
priority: 0.8
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate sitemap URLs for business listings
|
* Generate sitemap URLs for business listings
|
||||||
*/
|
*/
|
||||||
generateBusinessListingUrls(listings: any[]): SitemapUrl[] {
|
generateBusinessListingUrls(listings: any[]): SitemapUrl[] {
|
||||||
return listings.map(listing => ({
|
return listings.map(listing => ({
|
||||||
loc: `${this.baseUrl}/details-business-listing/${listing.id}`,
|
loc: `${this.baseUrl}/details-business-listing/${listing.id}`,
|
||||||
lastmod: this.formatDate(listing.updated || listing.created),
|
lastmod: this.formatDate(listing.updated || listing.created),
|
||||||
changefreq: 'weekly' as const,
|
changefreq: 'weekly' as const,
|
||||||
priority: 0.8
|
priority: 0.8
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate sitemap URLs for commercial property listings
|
* Generate sitemap URLs for commercial property listings
|
||||||
*/
|
*/
|
||||||
generateCommercialPropertyUrls(properties: any[]): SitemapUrl[] {
|
generateCommercialPropertyUrls(properties: any[]): SitemapUrl[] {
|
||||||
return properties.map(property => ({
|
return properties.map(property => ({
|
||||||
loc: `${this.baseUrl}/details-commercial-property/${property.id}`,
|
loc: `${this.baseUrl}/details-commercial-property/${property.id}`,
|
||||||
lastmod: this.formatDate(property.updated || property.created),
|
lastmod: this.formatDate(property.updated || property.created),
|
||||||
changefreq: 'weekly' as const,
|
changefreq: 'weekly' as const,
|
||||||
priority: 0.8
|
priority: 0.8
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format date to ISO 8601 format (YYYY-MM-DD)
|
* Format date to ISO 8601 format (YYYY-MM-DD)
|
||||||
*/
|
*/
|
||||||
private formatDate(date: Date | string): string {
|
private formatDate(date: Date | string): string {
|
||||||
const d = typeof date === 'string' ? new Date(date) : date;
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
return d.toISOString().split('T')[0];
|
return d.toISOString().split('T')[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate complete sitemap with all URLs
|
* Generate complete sitemap with all URLs
|
||||||
*/
|
*/
|
||||||
async generateCompleteSitemap(
|
async generateCompleteSitemap(
|
||||||
businessListings: any[],
|
businessListings: any[],
|
||||||
commercialProperties: any[]
|
commercialProperties: any[]
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const allUrls = [
|
const allUrls = [
|
||||||
...this.getStaticPageUrls(),
|
...this.getStaticPageUrls(),
|
||||||
...this.generateBusinessListingUrls(businessListings),
|
...this.generateBusinessListingUrls(businessListings),
|
||||||
...this.generateCommercialPropertyUrls(commercialProperties)
|
...this.generateCommercialPropertyUrls(commercialProperties)
|
||||||
];
|
];
|
||||||
|
|
||||||
return this.generateSitemap(allUrls);
|
return this.generateSitemap(allUrls);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue