SEO/AEO, Farb schema, breadcrumbs

This commit is contained in:
2025-11-29 23:41:54 +01:00
parent 4fa24c8f3d
commit d2953fd0d9
87 changed files with 5672 additions and 579 deletions

View File

@@ -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: '&copy; 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

View File

@@ -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>

View File

@@ -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: '&copy; 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: '&copy; 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: '&copy; 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');
}
}
}

View File

@@ -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>

View File

@@ -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,

View File

@@ -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">&#9733;</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>

View File

@@ -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;