SEO/AEO, Farb schema, breadcrumbs
This commit is contained in:
@@ -31,18 +31,42 @@ export abstract class BaseDetailsComponent {
|
||||
|
||||
if (latitude && longitude) {
|
||||
this.mapCenter = latLng(latitude, longitude);
|
||||
|
||||
// Build address string from available location data
|
||||
const addressParts = [];
|
||||
if (this.listing.location.housenumber) addressParts.push(this.listing.location.housenumber);
|
||||
if (this.listing.location.street) addressParts.push(this.listing.location.street);
|
||||
if (this.listing.location.name) addressParts.push(this.listing.location.name);
|
||||
else if (this.listing.location.county) addressParts.push(this.listing.location.county);
|
||||
if (this.listing.location.state) addressParts.push(this.listing.location.state);
|
||||
if (this.listing.location.zipCode) addressParts.push(this.listing.location.zipCode);
|
||||
|
||||
const fullAddress = addressParts.join(', ');
|
||||
|
||||
const marker = new Marker([latitude, longitude], {
|
||||
icon: icon({
|
||||
...Icon.Default.prototype.options,
|
||||
iconUrl: 'assets/leaflet/marker-icon.png',
|
||||
iconRetinaUrl: 'assets/leaflet/marker-icon-2x.png',
|
||||
shadowUrl: 'assets/leaflet/marker-shadow.png',
|
||||
}),
|
||||
});
|
||||
|
||||
// Add popup to marker with address
|
||||
if (fullAddress) {
|
||||
marker.bindPopup(`
|
||||
<div style="padding: 8px;">
|
||||
<strong>Location:</strong><br/>
|
||||
${fullAddress}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
this.mapLayers = [
|
||||
tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
}),
|
||||
new Marker([latitude, longitude], {
|
||||
icon: icon({
|
||||
...Icon.Default.prototype.options,
|
||||
iconUrl: 'assets/leaflet/marker-icon.png',
|
||||
iconRetinaUrl: 'assets/leaflet/marker-icon-2x.png',
|
||||
shadowUrl: 'assets/leaflet/marker-shadow.png',
|
||||
}),
|
||||
}),
|
||||
marker
|
||||
];
|
||||
this.mapOptions = {
|
||||
...this.mapOptions,
|
||||
@@ -52,17 +76,26 @@ export abstract class BaseDetailsComponent {
|
||||
}
|
||||
}
|
||||
onMapReady(map: Map) {
|
||||
if (this.listing.location.street) {
|
||||
// Build comprehensive address for the control
|
||||
const addressParts = [];
|
||||
if (this.listing.location.housenumber) addressParts.push(this.listing.location.housenumber);
|
||||
if (this.listing.location.street) addressParts.push(this.listing.location.street);
|
||||
if (this.listing.location.name) addressParts.push(this.listing.location.name);
|
||||
else if (this.listing.location.county) addressParts.push(this.listing.location.county);
|
||||
if (this.listing.location.state) addressParts.push(this.listing.location.state);
|
||||
if (this.listing.location.zipCode) addressParts.push(this.listing.location.zipCode);
|
||||
|
||||
if (addressParts.length > 0) {
|
||||
const addressControl = new Control({ position: 'topright' });
|
||||
|
||||
addressControl.onAdd = () => {
|
||||
const container = DomUtil.create('div', 'address-control bg-white p-2 rounded shadow');
|
||||
const address = `${this.listing.location.housenumber ? this.listing.location.housenumber : ''} ${this.listing.location.street}, ${
|
||||
this.listing.location.name ? this.listing.location.name : this.listing.location.county
|
||||
}, ${this.listing.location.state}`;
|
||||
const address = addressParts.join(', ');
|
||||
container.innerHTML = `
|
||||
${address}<br/>
|
||||
<a href="#" id="view-full-map">View larger map</a>
|
||||
<div style="max-width: 250px;">
|
||||
${address}<br/>
|
||||
<a href="#" id="view-full-map" style="color: #2563eb; text-decoration: underline;">View larger map</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Verhindere, dass die Karte durch das Klicken des Links bewegt wird
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<div class="container mx-auto p-4">
|
||||
<!-- Breadcrumbs for SEO and Navigation -->
|
||||
@if(breadcrumbs.length > 0) {
|
||||
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
|
||||
}
|
||||
|
||||
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg overflow-hidden relative">
|
||||
<button
|
||||
(click)="historyService.goBack()"
|
||||
@@ -14,16 +19,16 @@
|
||||
<p class="mb-4" [innerHTML]="description"></p>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div *ngFor="let detail of listingDetails; let i = index" class="flex flex-col sm:flex-row" [ngClass]="{ 'bg-gray-100': i % 2 === 0 }">
|
||||
<div *ngFor="let detail of listingDetails; let i = index" class="flex flex-col sm:flex-row" [ngClass]="{ 'bg-neutral-100': i % 2 === 0 }">
|
||||
<div class="w-full sm:w-1/3 font-semibold p-2">{{ detail.label }}</div>
|
||||
|
||||
<div class="w-full sm:w-2/3 p-2" *ngIf="!detail.isHtml && !detail.isListingBy">{{ detail.value }}</div>
|
||||
|
||||
<div class="w-full sm:w-2/3 p-2 flex space-x-2" [innerHTML]="detail.value" *ngIf="detail.isHtml && !detail.isListingBy"></div>
|
||||
|
||||
<div class="w-full sm:w-2/3 p-2 flex space-x-2" *ngIf="detail.isListingBy">
|
||||
<a routerLink="/details-user/{{ listingUser.id }}" class="text-blue-600 dark:text-blue-500 hover:underline">{{ listingUser.firstname }} {{ listingUser.lastname }}</a>
|
||||
<img src="{{ env.imageBaseUrl }}/pictures/logo/{{ listing.imageName }}.avif?_ts={{ ts }}" class="mr-5 lg:mb-0" style="max-height: 30px; max-width: 100px" />
|
||||
<div class="w-full sm:w-2/3 p-2 flex space-x-2" *ngIf="detail.isListingBy && listingUser">
|
||||
<a routerLink="/details-user/{{ listingUser.id }}" class="text-primary-600 dark:text-primary-500 hover:underline">{{ listingUser.firstname }} {{ listingUser.lastname }}</a>
|
||||
<img *ngIf="listing.imageName" src="{{ env.imageBaseUrl }}/pictures/logo/{{ listing.imageName }}.avif?_ts={{ ts }}" class="mr-5 lg:mb-0" style="max-height: 30px; max-width: 100px" width="100" height="30" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,8 +66,8 @@
|
||||
<share-button button="linkedin" showText="true" (click)="createEvent('linkedin')"></share-button>
|
||||
</div>
|
||||
<!-- Karte hinzufügen, wenn Straße vorhanden ist -->
|
||||
<div *ngIf="listing.location.street" class="mt-6">
|
||||
<h2 class="text-lg font-semibold mb-2">Location Map</h2>
|
||||
<div *ngIf="listing.location.latitude && listing.location.longitude" class="mt-6">
|
||||
<h2 class="text-xl font-semibold mb-2">Location Map</h2>
|
||||
<!-- <div style="height: 300px" leaflet [leafletOptions]="mapOptions" [leafletLayers]="mapLayers" [leafletCenter]="mapCenter" [leafletZoom]="mapZoom"></div> -->
|
||||
<div style="height: 400px" leaflet [leafletOptions]="mapOptions" [leafletLayers]="mapLayers" [leafletCenter]="mapCenter" [leafletZoom]="mapZoom" (leafletMapReady)="onMapReady($event)"></div>
|
||||
</div>
|
||||
@@ -70,8 +75,7 @@
|
||||
|
||||
<!-- Right column -->
|
||||
<div class="w-full lg:w-1/2 mt-6 lg:mt-0 print:hidden">
|
||||
<!-- <h2 class="text-lg font-semibold my-4">Contact the Author of this Listing</h2> -->
|
||||
<div class="md:mt-8 mb-4 text-2xl font-bold mb-4">Contact the Author of this Listing</div>
|
||||
<h2 class="md:mt-8 mb-4 text-xl font-bold">Contact the Author of this Listing</h2>
|
||||
<p class="text-sm mb-4">Please include your contact info below</p>
|
||||
<form class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -88,10 +92,53 @@
|
||||
<div>
|
||||
<app-validated-textarea label="Questions/Comments" name="comments" [(ngModel)]="mailinfo.sender.comments"></app-validated-textarea>
|
||||
</div>
|
||||
<button (click)="mail()" class="w-full sm:w-auto px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50">Submit</button>
|
||||
<button (click)="mail()" class="w-full sm:w-auto px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-opacity-50">Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Related Listings Section for SEO Internal Linking -->
|
||||
@if(relatedListings && relatedListings.length > 0) {
|
||||
<div class="container mx-auto p-4 mt-8">
|
||||
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6">
|
||||
<h2 class="text-2xl font-bold mb-6 text-gray-900">Similar Businesses You May Like</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
@for (related of relatedListings; track related.id) {
|
||||
<a [routerLink]="['/business', related.slug || related.id]" class="block group">
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden hover:shadow-xl transition-all duration-300 hover:scale-[1.02]">
|
||||
<div class="p-4">
|
||||
<div class="flex items-center mb-3">
|
||||
<i [class]="selectOptions.getIconAndTextColorType(related.type)" class="mr-2 text-lg"></i>
|
||||
<span [class]="selectOptions.getTextColorType(related.type)" class="font-semibold text-sm">{{ selectOptions.getBusiness(related.type) }}</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-bold mb-2 text-gray-900 group-hover:text-primary-600 transition-colors line-clamp-2">{{ related.title }}</h3>
|
||||
<div class="space-y-1 text-sm text-gray-600">
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium">Price:</span>
|
||||
<span class="font-bold text-primary-600">${{ related.price?.toLocaleString() || 'Contact' }}</span>
|
||||
</div>
|
||||
@if(related.salesRevenue) {
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium">Revenue:</span>
|
||||
<span>${{ related.salesRevenue?.toLocaleString() }}</span>
|
||||
</div>
|
||||
}
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium">Location:</span>
|
||||
<span>{{ related.location.name || related.location.county }}, {{ selectOptions.getState(related.location.state) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<span class="inline-block bg-primary-100 text-primary-800 text-xs font-medium px-2.5 py-0.5 rounded">View Details →</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -13,24 +13,27 @@ import { ValidatedInputComponent } from '../../../components/validated-input/val
|
||||
import { ValidatedNgSelectComponent } from '../../../components/validated-ng-select/validated-ng-select.component';
|
||||
import { ValidatedTextareaComponent } from '../../../components/validated-textarea/validated-textarea.component';
|
||||
import { ValidationMessagesService } from '../../../components/validation-messages.service';
|
||||
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
|
||||
import { AuditService } from '../../../services/audit.service';
|
||||
import { GeoService } from '../../../services/geo.service';
|
||||
import { HistoryService } from '../../../services/history.service';
|
||||
import { ListingsService } from '../../../services/listings.service';
|
||||
import { MailService } from '../../../services/mail.service';
|
||||
import { SelectOptionsService } from '../../../services/select-options.service';
|
||||
import { SeoService } from '../../../services/seo.service';
|
||||
import { UserService } from '../../../services/user.service';
|
||||
import { SharedModule } from '../../../shared/shared/shared.module';
|
||||
import { createMailInfo, map2User } from '../../../utils/utils';
|
||||
// Import für Leaflet
|
||||
// Benannte Importe für Leaflet
|
||||
import { circle, Circle, Control, DomEvent, DomUtil, latLng, LatLngBounds, polygon, Polygon, tileLayer } from 'leaflet';
|
||||
import dayjs from 'dayjs';
|
||||
import { AuthService } from '../../../services/auth.service';
|
||||
import { BaseDetailsComponent } from '../base-details.component';
|
||||
@Component({
|
||||
selector: 'app-details-business-listing',
|
||||
standalone: true,
|
||||
imports: [SharedModule, ValidatedInputComponent, ValidatedTextareaComponent, ShareButton, ValidatedNgSelectComponent, LeafletModule],
|
||||
imports: [SharedModule, ValidatedInputComponent, ValidatedTextareaComponent, ShareButton, ValidatedNgSelectComponent, LeafletModule, BreadcrumbsComponent],
|
||||
providers: [],
|
||||
templateUrl: './details-business-listing.component.html',
|
||||
styleUrl: '../details.scss',
|
||||
@@ -54,7 +57,7 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent {
|
||||
numScroll: 1,
|
||||
},
|
||||
];
|
||||
private id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined;
|
||||
private id: string | undefined = this.activatedRoute.snapshot.params['slug'] as string | undefined;
|
||||
override listing: BusinessListing;
|
||||
mailinfo: MailInfo;
|
||||
environment = environment;
|
||||
@@ -65,6 +68,8 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent {
|
||||
private history: string[] = [];
|
||||
ts = new Date().getTime();
|
||||
env = environment;
|
||||
breadcrumbs: BreadcrumbItem[] = [];
|
||||
relatedListings: BusinessListing[] = [];
|
||||
|
||||
constructor(
|
||||
private activatedRoute: ActivatedRoute,
|
||||
@@ -82,6 +87,7 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent {
|
||||
private geoService: GeoService,
|
||||
public authService: AuthService,
|
||||
private cdref: ChangeDetectorRef,
|
||||
private seoService: SeoService,
|
||||
) {
|
||||
super();
|
||||
this.router.events.subscribe(event => {
|
||||
@@ -89,11 +95,17 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent {
|
||||
this.history.push(event.urlAfterRedirects);
|
||||
}
|
||||
});
|
||||
this.mailinfo = { sender: {}, email: '', url: environment.mailinfoUrl };
|
||||
this.mailinfo = { sender: { name: '', email: '', phoneNumber: '', state: '', comments: '' }, email: '', url: environment.mailinfoUrl };
|
||||
// Initialisiere die Map-Optionen
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
// Initialize default breadcrumbs first
|
||||
this.breadcrumbs = [
|
||||
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
|
||||
{ label: 'Business Listings', url: '/businessListings' }
|
||||
];
|
||||
|
||||
const token = await this.authService.getToken();
|
||||
this.keycloakUser = map2User(token);
|
||||
if (this.keycloakUser) {
|
||||
@@ -105,17 +117,85 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent {
|
||||
this.auditService.createEvent(this.listing.id, 'view', this.user?.email);
|
||||
this.listingUser = await this.userService.getByMail(this.listing.email);
|
||||
this.description = this.sanitizer.bypassSecurityTrustHtml(this.listing.description);
|
||||
if (this.listing.location.street) {
|
||||
if (this.listing.location.latitude && this.listing.location.longitude) {
|
||||
this.configureMap();
|
||||
}
|
||||
|
||||
// Update SEO meta tags for this business listing
|
||||
const seoData = {
|
||||
businessName: this.listing.title,
|
||||
description: this.listing.description?.replace(/<[^>]*>/g, '').substring(0, 200) || '',
|
||||
askingPrice: this.listing.price,
|
||||
city: this.listing.location.name || this.listing.location.county || '',
|
||||
state: this.listing.location.state,
|
||||
industry: this.selectOptions.getBusiness(this.listing.type),
|
||||
images: this.listing.imageName ? [this.listing.imageName] : [],
|
||||
id: this.listing.id
|
||||
};
|
||||
this.seoService.updateBusinessListingMeta(seoData);
|
||||
|
||||
// Inject structured data (Schema.org JSON-LD) - Using Product schema for better SEO
|
||||
const productSchema = this.seoService.generateProductSchema({
|
||||
businessName: this.listing.title,
|
||||
description: this.listing.description?.replace(/<[^>]*>/g, '') || '',
|
||||
images: this.listing.imageName ? [this.listing.imageName] : [],
|
||||
address: this.listing.location.street,
|
||||
city: this.listing.location.name,
|
||||
state: this.listing.location.state,
|
||||
zip: this.listing.location.zipCode,
|
||||
askingPrice: this.listing.price,
|
||||
annualRevenue: this.listing.salesRevenue,
|
||||
yearEstablished: this.listing.established,
|
||||
category: this.selectOptions.getBusiness(this.listing.type),
|
||||
id: this.listing.id,
|
||||
slug: this.listing.slug
|
||||
});
|
||||
const breadcrumbSchema = this.seoService.generateBreadcrumbSchema([
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: 'Business Listings', url: '/businessListings' },
|
||||
{ name: this.selectOptions.getBusiness(this.listing.type), url: `/business/${this.listing.slug || this.listing.id}` }
|
||||
]);
|
||||
this.seoService.injectMultipleSchemas([productSchema, breadcrumbSchema]);
|
||||
|
||||
// Generate breadcrumbs
|
||||
this.breadcrumbs = [
|
||||
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
|
||||
{ label: 'Business Listings', url: '/businessListings' },
|
||||
{ label: this.selectOptions.getBusiness(this.listing.type), url: '/businessListings' },
|
||||
{ label: this.listing.title }
|
||||
];
|
||||
|
||||
// Load related listings for internal linking (SEO improvement)
|
||||
this.loadRelatedListings();
|
||||
} catch (error) {
|
||||
this.auditService.log({ severity: 'error', text: error.error.message });
|
||||
// Set default breadcrumbs even on error
|
||||
this.breadcrumbs = [
|
||||
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
|
||||
{ label: 'Business Listings', url: '/businessListings' }
|
||||
];
|
||||
|
||||
const errorMessage = error?.error?.message || error?.message || 'An error occurred while loading the listing';
|
||||
this.auditService.log({ severity: 'error', text: errorMessage });
|
||||
this.router.navigate(['notfound']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load related business listings based on same category, location, and price range
|
||||
* Improves SEO through internal linking
|
||||
*/
|
||||
private async loadRelatedListings() {
|
||||
try {
|
||||
this.relatedListings = (await this.listingsService.getRelatedListings(this.listing, 'business', 3)) as BusinessListing[];
|
||||
} catch (error) {
|
||||
console.error('Error loading related listings:', error);
|
||||
this.relatedListings = [];
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
|
||||
this.seoService.clearStructuredData(); // Clean up SEO structured data
|
||||
}
|
||||
|
||||
async mail() {
|
||||
@@ -197,9 +277,9 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
save() {
|
||||
async save() {
|
||||
await this.listingsService.addToFavorites(this.listing.id, 'business');
|
||||
this.listing.favoritesForUser.push(this.user.email);
|
||||
this.listingsService.save(this.listing, 'business');
|
||||
this.auditService.createEvent(this.listing.id, 'favorite', this.user?.email);
|
||||
}
|
||||
isAlreadyFavorite() {
|
||||
@@ -207,8 +287,9 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent {
|
||||
}
|
||||
async showShareByEMail() {
|
||||
const result = await this.emailService.showShareByEMail({
|
||||
yourEmail: this.user ? this.user.email : null,
|
||||
yourName: this.user ? `${this.user.firstname} ${this.user.lastname}` : null,
|
||||
yourEmail: this.user ? this.user.email : '',
|
||||
yourName: this.user ? `${this.user.firstname} ${this.user.lastname}` : '',
|
||||
recipientEmail: '',
|
||||
url: environment.mailinfoUrl,
|
||||
listingTitle: this.listing.title,
|
||||
id: this.listing.id,
|
||||
@@ -233,4 +314,199 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent {
|
||||
dateInserted() {
|
||||
return dayjs(this.listing.created).format('DD/MM/YYYY');
|
||||
}
|
||||
|
||||
/**
|
||||
* Override configureMap to show city boundary polygon for privacy
|
||||
* Business listings show city boundary instead of exact address
|
||||
*/
|
||||
protected override configureMap() {
|
||||
// For business listings, show city boundary polygon instead of exact location
|
||||
// This protects seller privacy (competition, employees, customers)
|
||||
const latitude = this.listing.location.latitude;
|
||||
const longitude = this.listing.location.longitude;
|
||||
const cityName = this.listing.location.name;
|
||||
const county = this.listing.location.county || '';
|
||||
const state = this.listing.location.state;
|
||||
|
||||
if (latitude && longitude && cityName && state) {
|
||||
this.mapCenter = latLng(latitude, longitude);
|
||||
this.mapZoom = 11; // Zoom out to show city area
|
||||
|
||||
// Fetch city boundary from Nominatim API
|
||||
this.geoService.getCityBoundary(cityName, state).subscribe({
|
||||
next: (data) => {
|
||||
if (data && data.length > 0 && data[0].geojson && data[0].geojson.type === 'Polygon') {
|
||||
const coordinates = data[0].geojson.coordinates[0]; // Get outer boundary
|
||||
|
||||
// Convert GeoJSON coordinates [lon, lat] to Leaflet LatLng [lat, lon]
|
||||
const latlngs = coordinates.map((coord: number[]) => latLng(coord[1], coord[0]));
|
||||
|
||||
// Create red outlined polygon for city boundary
|
||||
const cityPolygon = polygon(latlngs, {
|
||||
color: '#ef4444', // Red color (like Google Maps)
|
||||
fillColor: '#ef4444',
|
||||
fillOpacity: 0.1,
|
||||
weight: 2
|
||||
});
|
||||
|
||||
// Add popup to polygon
|
||||
cityPolygon.bindPopup(`
|
||||
<div style="padding: 8px;">
|
||||
<strong>General Area:</strong><br/>
|
||||
${cityName}, ${county ? county + ', ' : ''}${state}<br/>
|
||||
<small style="color: #666;">City boundary shown for privacy.<br/>Exact location provided after contact.</small>
|
||||
</div>
|
||||
`);
|
||||
|
||||
this.mapLayers = [
|
||||
tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
}),
|
||||
cityPolygon
|
||||
];
|
||||
|
||||
// Fit map to polygon bounds
|
||||
const bounds = cityPolygon.getBounds();
|
||||
this.mapOptions = {
|
||||
...this.mapOptions,
|
||||
center: bounds.getCenter(),
|
||||
zoom: this.mapZoom,
|
||||
};
|
||||
} else if (data && data.length > 0 && data[0].geojson && data[0].geojson.type === 'MultiPolygon') {
|
||||
// Handle MultiPolygon case (cities with multiple areas)
|
||||
const allPolygons: Polygon[] = [];
|
||||
|
||||
data[0].geojson.coordinates.forEach((polygonCoords: number[][][]) => {
|
||||
const latlngs = polygonCoords[0].map((coord: number[]) => latLng(coord[1], coord[0]));
|
||||
const cityPolygon = polygon(latlngs, {
|
||||
color: '#ef4444',
|
||||
fillColor: '#ef4444',
|
||||
fillOpacity: 0.1,
|
||||
weight: 2
|
||||
});
|
||||
allPolygons.push(cityPolygon);
|
||||
});
|
||||
|
||||
// Add popup to first polygon
|
||||
if (allPolygons.length > 0) {
|
||||
allPolygons[0].bindPopup(`
|
||||
<div style="padding: 8px;">
|
||||
<strong>General Area:</strong><br/>
|
||||
${cityName}, ${county ? county + ', ' : ''}${state}<br/>
|
||||
<small style="color: #666;">City boundary shown for privacy.<br/>Exact location provided after contact.</small>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
this.mapLayers = [
|
||||
tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
}),
|
||||
...allPolygons
|
||||
];
|
||||
|
||||
// Calculate combined bounds
|
||||
if (allPolygons.length > 0) {
|
||||
const bounds = new LatLngBounds([]);
|
||||
allPolygons.forEach(p => bounds.extend(p.getBounds()));
|
||||
this.mapOptions = {
|
||||
...this.mapOptions,
|
||||
center: bounds.getCenter(),
|
||||
zoom: this.mapZoom,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Fallback: Use circle if no polygon data available
|
||||
this.useFallbackCircle(latitude, longitude, cityName, county, state);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error fetching city boundary:', err);
|
||||
// Fallback: Use circle on error
|
||||
this.useFallbackCircle(latitude, longitude, cityName, county, state);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private useFallbackCircle(latitude: number, longitude: number, cityName: string, county: string, state: string) {
|
||||
this.mapCenter = latLng(latitude, longitude);
|
||||
this.mapZoom = 11;
|
||||
|
||||
const locationCircle = circle([latitude, longitude], {
|
||||
color: '#ef4444', // Red to match polygon style
|
||||
fillColor: '#ef4444',
|
||||
fillOpacity: 0.1,
|
||||
radius: 8000, // 8km radius circle as fallback
|
||||
weight: 2
|
||||
});
|
||||
|
||||
locationCircle.bindPopup(`
|
||||
<div style="padding: 8px;">
|
||||
<strong>General Area:</strong><br/>
|
||||
${cityName}, ${county ? county + ', ' : ''}${state}<br/>
|
||||
<small style="color: #666;">Approximate area shown for privacy.<br/>Exact location provided after contact.</small>
|
||||
</div>
|
||||
`);
|
||||
|
||||
this.mapLayers = [
|
||||
tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
}),
|
||||
locationCircle
|
||||
];
|
||||
|
||||
this.mapOptions = {
|
||||
...this.mapOptions,
|
||||
center: this.mapCenter,
|
||||
zoom: this.mapZoom,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Override onMapReady to show privacy-friendly address control
|
||||
*/
|
||||
override onMapReady(map: any) {
|
||||
// Show only city, county, state - no street address
|
||||
const cityName = this.listing.location.name || '';
|
||||
const county = this.listing.location.county || '';
|
||||
const state = this.listing.location.state || '';
|
||||
|
||||
if (cityName && state) {
|
||||
const addressControl = new Control({ position: 'topright' });
|
||||
|
||||
addressControl.onAdd = () => {
|
||||
const container = DomUtil.create('div', 'address-control bg-white p-2 rounded shadow');
|
||||
const locationText = county ? `${cityName}, ${county}, ${state}` : `${cityName}, ${state}`;
|
||||
container.innerHTML = `
|
||||
<div style="max-width: 250px;">
|
||||
<strong>General Area:</strong><br/>
|
||||
${locationText}<br/>
|
||||
<small style="color: #666; font-size: 11px;">Approximate location shown for privacy</small>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Prevent map dragging when clicking the control
|
||||
DomEvent.disableClickPropagation(container);
|
||||
|
||||
return container;
|
||||
};
|
||||
|
||||
addressControl.addTo(map);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override openFullMap to open city-area map instead of exact location
|
||||
*/
|
||||
override openFullMap() {
|
||||
const latitude = this.listing.location.latitude;
|
||||
const longitude = this.listing.location.longitude;
|
||||
|
||||
if (latitude && longitude) {
|
||||
// Open map with zoom level 11 to show large city area, not exact location
|
||||
const url = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=11/${latitude}/${longitude}`;
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<div class="container mx-auto p-4">
|
||||
<!-- Breadcrumbs for SEO and Navigation -->
|
||||
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
|
||||
|
||||
<div class="bg-white drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg rounded-lg overflow-hidden">
|
||||
@if(listing){
|
||||
<div class="p-6 relative">
|
||||
@@ -14,7 +17,7 @@
|
||||
<p class="mb-4" [innerHTML]="description"></p>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div *ngFor="let detail of propertyDetails; let i = index" class="flex flex-col sm:flex-row" [ngClass]="{ 'bg-gray-100': i % 2 === 0 }">
|
||||
<div *ngFor="let detail of propertyDetails; let i = index" class="flex flex-col sm:flex-row" [ngClass]="{ 'bg-neutral-100': i % 2 === 0 }">
|
||||
<div class="w-full sm:w-1/3 font-semibold p-2">{{ detail.label }}</div>
|
||||
|
||||
<!-- Standard Text -->
|
||||
@@ -24,9 +27,9 @@
|
||||
<div class="w-full sm:w-2/3 p-2 flex space-x-2" [innerHTML]="detail.value" *ngIf="detail.isHtml && !detail.isListingBy"></div>
|
||||
|
||||
<!-- Speziell für Listing By mit RouterLink -->
|
||||
<div class="w-full sm:w-2/3 p-2 flex space-x-2" *ngIf="detail.isListingBy">
|
||||
<a [routerLink]="['/details-user', detail.user.id]" class="text-blue-600 dark:text-blue-500 hover:underline"> {{ detail.user.firstname }} {{ detail.user.lastname }} </a>
|
||||
<img *ngIf="detail.user.hasCompanyLogo" [src]="detail.imageBaseUrl + '/pictures/logo/' + detail.imagePath + '.avif?_ts=' + detail.ts" class="mr-5 lg:mb-0" style="max-height: 30px; max-width: 100px" />
|
||||
<div class="w-full sm:w-2/3 p-2 flex space-x-2" *ngIf="detail.isListingBy && detail.user">
|
||||
<a [routerLink]="['/details-user', detail.user.id]" class="text-primary-600 dark:text-primary-500 hover:underline"> {{ detail.user.firstname }} {{ detail.user.lastname }} </a>
|
||||
<img *ngIf="detail.user.hasCompanyLogo" [src]="detail.imageBaseUrl + '/pictures/logo/' + detail.imagePath + '.avif?_ts=' + detail.ts" class="mr-5 lg:mb-0" style="max-height: 30px; max-width: 100px" width="100" height="30" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,7 +67,7 @@
|
||||
<share-button button="linkedin" showText="true" (click)="createEvent('linkedin')"></share-button>
|
||||
</div>
|
||||
<!-- Karte hinzufügen, wenn Straße vorhanden ist -->
|
||||
<div *ngIf="listing.location.street" class="mt-6">
|
||||
<div *ngIf="listing.location.latitude && listing.location.longitude" class="mt-6">
|
||||
<h2 class="text-lg font-semibold mb-2">Location Map</h2>
|
||||
<!-- <div style="height: 300px" leaflet [leafletOptions]="mapOptions" [leafletLayers]="mapLayers" [leafletCenter]="mapCenter" [leafletZoom]="mapZoom"></div> -->
|
||||
<div style="height: 400px" leaflet [leafletOptions]="mapOptions" [leafletLayers]="mapLayers" [leafletCenter]="mapCenter" [leafletZoom]="mapZoom" (leafletMapReady)="onMapReady($event)"></div>
|
||||
@@ -83,7 +86,7 @@
|
||||
}@else {
|
||||
<div class="text-2xl font-bold mb-4">Contact the Author of this Listing</div>
|
||||
}
|
||||
<p class="text-sm text-gray-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">
|
||||
<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>
|
||||
@@ -99,7 +102,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<button (click)="mail()" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">Submit</button>
|
||||
<button (click)="mail()" class="bg-primary-500 text-white px-4 py-2 rounded hover:bg-primary-600">Submit</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -108,4 +111,41 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Related Listings Section for SEO Internal Linking -->
|
||||
@if(relatedListings && relatedListings.length > 0) {
|
||||
<div class="container mx-auto p-4 mt-8">
|
||||
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6">
|
||||
<h2 class="text-2xl font-bold mb-6 text-gray-900">Similar Properties You May Like</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
@for (related of relatedListings; track related.id) {
|
||||
<a [routerLink]="['/commercial-property', related.slug || related.id]" class="block group">
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden hover:shadow-xl transition-all duration-300 hover:scale-[1.02]">
|
||||
<div class="p-4">
|
||||
<div class="flex items-center mb-3">
|
||||
<i [class]="selectOptions.getIconAndTextColorType(related.type)" class="mr-2 text-lg"></i>
|
||||
<span [class]="selectOptions.getTextColorType(related.type)" class="font-semibold text-sm">{{ selectOptions.getCommercialProperty(related.type) }}</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-bold mb-2 text-gray-900 group-hover:text-primary-600 transition-colors line-clamp-2">{{ related.title }}</h3>
|
||||
<div class="space-y-1 text-sm text-gray-600">
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium">Price:</span>
|
||||
<span class="font-bold text-primary-600">${{ related.price?.toLocaleString() || 'Contact' }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium">Location:</span>
|
||||
<span>{{ related.location.name || related.location.county }}, {{ selectOptions.getState(related.location.state) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<span class="inline-block bg-primary-100 text-primary-800 text-xs font-medium px-2.5 py-0.5 rounded">View Details →</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -23,15 +23,17 @@ import { ImageService } from '../../../services/image.service';
|
||||
import { ListingsService } from '../../../services/listings.service';
|
||||
import { MailService } from '../../../services/mail.service';
|
||||
import { SelectOptionsService } from '../../../services/select-options.service';
|
||||
import { SeoService } from '../../../services/seo.service';
|
||||
import { UserService } from '../../../services/user.service';
|
||||
import { SharedModule } from '../../../shared/shared/shared.module';
|
||||
import { createMailInfo, map2User } from '../../../utils/utils';
|
||||
import { BaseDetailsComponent } from '../base-details.component';
|
||||
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-details-commercial-property-listing',
|
||||
standalone: true,
|
||||
imports: [SharedModule, ValidatedInputComponent, ValidatedTextareaComponent, ShareButton, ValidatedNgSelectComponent, GalleryModule, LeafletModule],
|
||||
imports: [SharedModule, ValidatedInputComponent, ValidatedTextareaComponent, ShareButton, ValidatedNgSelectComponent, GalleryModule, LeafletModule, BreadcrumbsComponent],
|
||||
providers: [],
|
||||
templateUrl: './details-commercial-property-listing.component.html',
|
||||
styleUrl: '../details.scss',
|
||||
@@ -54,7 +56,7 @@ export class DetailsCommercialPropertyListingComponent extends BaseDetailsCompon
|
||||
numScroll: 1,
|
||||
},
|
||||
];
|
||||
private id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined;
|
||||
private id: string | undefined = this.activatedRoute.snapshot.params['slug'] as string | undefined;
|
||||
override listing: CommercialPropertyListing;
|
||||
criteria: CommercialPropertyListingCriteria;
|
||||
mailinfo: MailInfo;
|
||||
@@ -69,6 +71,8 @@ export class DetailsCommercialPropertyListingComponent extends BaseDetailsCompon
|
||||
faTimes = faTimes;
|
||||
propertyDetails = [];
|
||||
images: Array<ImageItem> = [];
|
||||
relatedListings: CommercialPropertyListing[] = [];
|
||||
breadcrumbs: BreadcrumbItem[] = [];
|
||||
constructor(
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private listingsService: ListingsService,
|
||||
@@ -85,12 +89,19 @@ export class DetailsCommercialPropertyListingComponent extends BaseDetailsCompon
|
||||
private auditService: AuditService,
|
||||
private emailService: EMailService,
|
||||
public authService: AuthService,
|
||||
private seoService: SeoService,
|
||||
) {
|
||||
super();
|
||||
this.mailinfo = { sender: {}, email: '', url: environment.mailinfoUrl };
|
||||
this.mailinfo = { sender: { name: '', email: '', phoneNumber: '', state: '', comments: '' }, email: '', url: environment.mailinfoUrl };
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
// Initialize default breadcrumbs first
|
||||
this.breadcrumbs = [
|
||||
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
|
||||
{ label: 'Commercial Properties', url: '/commercialPropertyListings' }
|
||||
];
|
||||
|
||||
const token = await this.authService.getToken();
|
||||
this.keycloakUser = map2User(token);
|
||||
if (this.keycloakUser) {
|
||||
@@ -125,22 +136,86 @@ export class DetailsCommercialPropertyListingComponent extends BaseDetailsCompon
|
||||
if (this.listing.draft) {
|
||||
this.propertyDetails.push({ label: 'Draft', value: this.listing.draft ? 'Yes' : 'No' });
|
||||
}
|
||||
this.listing.imageOrder.forEach(image => {
|
||||
const imageURL = `${this.env.imageBaseUrl}/pictures/property/${this.listing.imagePath}/${this.listing.serialId}/${image}`;
|
||||
this.images.push(new ImageItem({ src: imageURL, thumb: imageURL }));
|
||||
});
|
||||
if (this.listing.location.street) {
|
||||
if (this.listing.imageOrder && Array.isArray(this.listing.imageOrder)) {
|
||||
this.listing.imageOrder.forEach(image => {
|
||||
const imageURL = `${this.env.imageBaseUrl}/pictures/property/${this.listing.imagePath}/${this.listing.serialId}/${image}`;
|
||||
this.images.push(new ImageItem({ src: imageURL, thumb: imageURL }));
|
||||
});
|
||||
}
|
||||
if (this.listing.location.latitude && this.listing.location.longitude) {
|
||||
this.configureMap();
|
||||
}
|
||||
|
||||
// Update SEO meta tags for commercial property
|
||||
const propertyData = {
|
||||
id: this.listing.id,
|
||||
propertyType: this.selectOptions.getCommercialProperty(this.listing.type),
|
||||
propertyDescription: this.listing.description?.replace(/<[^>]*>/g, '').substring(0, 200) || '',
|
||||
askingPrice: this.listing.price,
|
||||
city: this.listing.location.name || this.listing.location.county || '',
|
||||
state: this.listing.location.state,
|
||||
address: this.listing.location.street || '',
|
||||
zip: this.listing.location.zipCode || '',
|
||||
latitude: this.listing.location.latitude,
|
||||
longitude: this.listing.location.longitude,
|
||||
squareFootage: (this.listing as any).squareFeet,
|
||||
yearBuilt: (this.listing as any).yearBuilt,
|
||||
images: this.listing.imageOrder?.length > 0
|
||||
? this.listing.imageOrder.map(img =>
|
||||
`${this.env.imageBaseUrl}/pictures/property/${this.listing.imagePath}/${this.listing.serialId}/${img}`)
|
||||
: []
|
||||
};
|
||||
this.seoService.updateCommercialPropertyMeta(propertyData);
|
||||
|
||||
// Add RealEstateListing structured data
|
||||
const realEstateSchema = this.seoService.generateRealEstateListingSchema(propertyData);
|
||||
const breadcrumbSchema = this.seoService.generateBreadcrumbSchema([
|
||||
{ name: 'Home', url: '/' },
|
||||
{ name: 'Commercial Properties', url: '/commercialPropertyListings' },
|
||||
{ name: propertyData.propertyType, url: `/details-commercial-property/${this.listing.id}` }
|
||||
]);
|
||||
this.seoService.injectMultipleSchemas([realEstateSchema, breadcrumbSchema]);
|
||||
|
||||
// Generate breadcrumbs for navigation
|
||||
this.breadcrumbs = [
|
||||
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
|
||||
{ label: 'Commercial Properties', url: '/commercialPropertyListings' },
|
||||
{ label: propertyData.propertyType, url: '/commercialPropertyListings' },
|
||||
{ label: this.listing.title }
|
||||
];
|
||||
|
||||
// Load related listings for internal linking (SEO improvement)
|
||||
this.loadRelatedListings();
|
||||
} catch (error) {
|
||||
this.auditService.log({ severity: 'error', text: error.error.message });
|
||||
// Set default breadcrumbs even on error
|
||||
this.breadcrumbs = [
|
||||
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
|
||||
{ label: 'Commercial Properties', url: '/commercialPropertyListings' }
|
||||
];
|
||||
|
||||
const errorMessage = error?.error?.message || error?.message || 'An error occurred while loading the listing';
|
||||
this.auditService.log({ severity: 'error', text: errorMessage });
|
||||
this.router.navigate(['notfound']);
|
||||
}
|
||||
|
||||
//this.initFlowbite();
|
||||
}
|
||||
/**
|
||||
* Load related commercial property listings based on same category, location, and price range
|
||||
* Improves SEO through internal linking
|
||||
*/
|
||||
private async loadRelatedListings() {
|
||||
try {
|
||||
this.relatedListings = (await this.listingsService.getRelatedListings(this.listing, 'commercialProperty', 3)) as CommercialPropertyListing[];
|
||||
} catch (error) {
|
||||
console.error('Error loading related listings:', error);
|
||||
this.relatedListings = [];
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
|
||||
this.seoService.clearStructuredData(); // Clean up SEO structured data
|
||||
}
|
||||
private initFlowbite() {
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
@@ -177,9 +252,9 @@ export class DetailsCommercialPropertyListingComponent extends BaseDetailsCompon
|
||||
getImageIndices(): number[] {
|
||||
return this.listing && this.listing.imageOrder ? this.listing.imageOrder.slice(1).map((e, i) => i + 1) : [];
|
||||
}
|
||||
save() {
|
||||
async save() {
|
||||
await this.listingsService.addToFavorites(this.listing.id, 'commercialProperty');
|
||||
this.listing.favoritesForUser.push(this.user.email);
|
||||
this.listingsService.save(this.listing, 'commercialProperty');
|
||||
this.auditService.createEvent(this.listing.id, 'favorite', this.user?.email);
|
||||
}
|
||||
isAlreadyFavorite() {
|
||||
@@ -187,8 +262,9 @@ export class DetailsCommercialPropertyListingComponent extends BaseDetailsCompon
|
||||
}
|
||||
async showShareByEMail() {
|
||||
const result = await this.emailService.showShareByEMail({
|
||||
yourEmail: this.user ? this.user.email : null,
|
||||
yourName: this.user ? `${this.user.firstname} ${this.user.lastname}` : null,
|
||||
yourEmail: this.user ? this.user.email : '',
|
||||
yourName: this.user ? `${this.user.firstname} ${this.user.lastname}` : '',
|
||||
recipientEmail: '',
|
||||
url: environment.mailinfoUrl,
|
||||
listingTitle: this.listing.title,
|
||||
id: this.listing.id,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<div class="container mx-auto p-4">
|
||||
<!-- Breadcrumbs -->
|
||||
<div class="mb-4">
|
||||
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
|
||||
</div>
|
||||
|
||||
@if(user){
|
||||
<div class="bg-white drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg rounded-lg overflow-hidden">
|
||||
<!-- Header -->
|
||||
@@ -6,16 +11,16 @@
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- <img src="https://placehold.co/80x80" alt="Profile picture of Avery Brown smiling" class="w-20 h-20 rounded-full" /> -->
|
||||
@if(user.hasProfile){
|
||||
<img src="{{ env.imageBaseUrl }}/pictures//profile/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}" class="w-20 h-20 rounded-full object-cover" />
|
||||
<img src="{{ env.imageBaseUrl }}/pictures//profile/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}" class="w-20 h-20 rounded-full object-cover" width="80" height="80" />
|
||||
} @else {
|
||||
<img src="assets/images/person_placeholder.jpg" class="w-20 h-20 rounded-full" />
|
||||
<img src="assets/images/person_placeholder.jpg" class="w-20 h-20 rounded-full" width="80" height="80" />
|
||||
}
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold flex items-center">
|
||||
{{ user.firstname }} {{ user.lastname }}
|
||||
<span class="text-yellow-400 ml-2">★</span>
|
||||
</h1>
|
||||
<p class="text-gray-600">
|
||||
<p class="text-neutral-600">
|
||||
Company
|
||||
<span class="mx-1">-</span>
|
||||
{{ user.companyName }}
|
||||
@@ -27,7 +32,7 @@
|
||||
</p>
|
||||
</div>
|
||||
@if(user.hasCompanyLogo){
|
||||
<img src="{{ env.imageBaseUrl }}/pictures/logo/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}" class="w-11 h-14" />
|
||||
<img src="{{ env.imageBaseUrl }}/pictures/logo/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}" class="w-11 h-14" width="44" height="56" />
|
||||
}
|
||||
<!-- <img src="https://placehold.co/45x60" class="w-11 h-14" /> -->
|
||||
</div>
|
||||
@@ -40,16 +45,16 @@
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="p-4 text-gray-700">{{ user.description }}</p>
|
||||
<p class="p-4 text-neutral-700">{{ user.description }}</p>
|
||||
|
||||
<!-- Company Profile -->
|
||||
<div class="p-4">
|
||||
<h2 class="text-xl font-semibold mb-4">Company Profile</h2>
|
||||
<p class="text-gray-700 mb-4" [innerHTML]="companyOverview"></p>
|
||||
<p class="text-neutral-700 mb-4" [innerHTML]="companyOverview"></p>
|
||||
|
||||
<!-- Profile Details -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center bg-gray-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="p-2 flex-grow">{{ user.firstname }} {{ user.lastname }}</span>
|
||||
</div>
|
||||
@@ -58,7 +63,7 @@
|
||||
<span class="p-2 flex-grow">{{ user.email }}</span>
|
||||
</div>
|
||||
@if(user.customerType==='professional'){
|
||||
<div class="flex flex-col sm:flex-row sm:items-center bg-gray-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="p-2 flex-grow">{{ formatPhoneNumber(user.phoneNumber) }}</span>
|
||||
</div>
|
||||
@@ -67,7 +72,7 @@
|
||||
<span class="font-semibold w-40 p-2">Company Location</span>
|
||||
<span class="p-2 flex-grow">{{ user.location?.name }} - {{ user.location?.state }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center bg-gray-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="p-2 flex-grow">{{ selectOptions.getCustomerSubType(user.customerSubType) }}</span>
|
||||
</div>
|
||||
@@ -77,7 +82,7 @@
|
||||
<!-- Services -->
|
||||
<div class="mt-6">
|
||||
<h3 class="font-semibold mb-2">Services we offer</h3>
|
||||
<p class="text-gray-700 mb-4" [innerHTML]="offeredServices"></p>
|
||||
<p class="text-neutral-700 mb-4" [innerHTML]="offeredServices"></p>
|
||||
</div>
|
||||
|
||||
<!-- Areas Served -->
|
||||
@@ -85,7 +90,7 @@
|
||||
<h3 class="font-semibold mb-2">Areas (Counties) we serve</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@for (area of user.areasServed; track area) {
|
||||
<span class="bg-blue-100 text-blue-800 px-2 py-1 rounded-full text-sm">{{ area.county }}{{ area.county ? '-' : '' }}{{ area.state }}</span>
|
||||
<span class="bg-primary-100 text-primary-800 px-2 py-1 rounded-full text-sm">{{ area.county }}{{ area.county ? '-' : '' }}{{ area.state }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,7 +99,7 @@
|
||||
<div class="mt-6">
|
||||
<h3 class="font-semibold mb-2">Licensed In</h3>
|
||||
@for (license of user.licensedIn; track license) {
|
||||
<span class="bg-green-100 text-green-800 px-2 py-1 rounded-full text-sm">{{ license.registerNo }}-{{ license.state }}</span>
|
||||
<span class="bg-success-100 text-success-800 px-2 py-1 rounded-full text-sm">{{ license.registerNo }}-{{ license.state }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -107,12 +112,12 @@
|
||||
<h2 class="text-xl font-semibold mb-4">My Business Listings For Sale</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@for (listing of businessListings; track listing) {
|
||||
<div class="border rounded-lg p-4 hover:cursor-pointer" [routerLink]="['/details-business-listing', listing.id]">
|
||||
<div class="border rounded-lg p-4 hover:cursor-pointer" [routerLink]="['/business', listing.slug || listing.id]">
|
||||
<div class="flex items-center mb-2">
|
||||
<i [class]="selectOptions.getIconAndTextColorType(listing.type)" class="mr-2"></i>
|
||||
<span class="font-medium">{{ selectOptions.getBusiness(listing.type) }}</span>
|
||||
</div>
|
||||
<p class="text-gray-700">{{ listing.title }}</p>
|
||||
<p class="text-neutral-700">{{ listing.title }}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -122,7 +127,7 @@
|
||||
<h2 class="text-xl font-semibold mb-4">My Commercial Property Listings For Sale</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@for (listing of commercialPropListings; track listing) {
|
||||
<div class="border rounded-lg p-4 hover:cursor-pointer" [routerLink]="['/details-commercial-property-listing', listing.id]">
|
||||
<div class="border rounded-lg p-4 hover:cursor-pointer" [routerLink]="['/commercial-property', listing.slug || listing.id]">
|
||||
<div class="flex items-center space-x-4">
|
||||
@if (listing.imageOrder?.length>0){
|
||||
<img src="{{ env.imageBaseUrl }}/pictures/property/{{ listing.imagePath }}/{{ listing.serialId }}/{{ listing.imageOrder[0] }}?_ts={{ ts }}" class="w-12 h-12 object-cover rounded" />
|
||||
@@ -131,14 +136,14 @@
|
||||
}
|
||||
<div>
|
||||
<p class="font-medium">{{ selectOptions.getCommercialProperty(listing.type) }}</p>
|
||||
<p class="text-gray-700">{{ listing.title }}</p>
|
||||
<p class="text-neutral-700">{{ listing.title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @if( user?.email===keycloakUser?.email || (authService.isAdmin() | async)){
|
||||
<button class="mt-4 bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600" [routerLink]="['/account', user.id]">Edit</button>
|
||||
<button class="mt-4 bg-primary-500 text-white px-4 py-2 rounded hover:bg-primary-600" [routerLink]="['/account', user.id]">Edit</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Observable } from 'rxjs';
|
||||
import { BusinessListing, CommercialPropertyListing, User } from '../../../../../../bizmatch-server/src/models/db.model';
|
||||
import { KeycloakUser, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
|
||||
import { AuthService } from '../../../services/auth.service';
|
||||
import { HistoryService } from '../../../services/history.service';
|
||||
import { ImageService } from '../../../services/image.service';
|
||||
@@ -17,13 +18,18 @@ import { formatPhoneNumber, map2User } from '../../../utils/utils';
|
||||
@Component({
|
||||
selector: 'app-details-user',
|
||||
standalone: true,
|
||||
imports: [SharedModule],
|
||||
imports: [SharedModule, BreadcrumbsComponent],
|
||||
templateUrl: './details-user.component.html',
|
||||
styleUrl: '../details.scss',
|
||||
})
|
||||
export class DetailsUserComponent {
|
||||
private id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined;
|
||||
user: User;
|
||||
breadcrumbs: BreadcrumbItem[] = [
|
||||
{ label: 'Home', url: '/home', icon: 'fas fa-home' },
|
||||
{ label: 'Professionals', url: '/brokerListings' },
|
||||
{ label: 'Profile' }
|
||||
];
|
||||
user$: Observable<KeycloakUser>;
|
||||
keycloakUser: KeycloakUser;
|
||||
environment = environment;
|
||||
|
||||
Reference in New Issue
Block a user