SEO/AEO, Farb schema, breadcrumbs
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user