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

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