bizmatch-project/bizmatch/src/app/pages/details/details-business-listing/details-business-listing.co...

831 lines
32 KiB
TypeScript

import { ChangeDetectorRef, Component } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { LeafletModule } from '@bluehalo/ngx-leaflet';
import { lastValueFrom } from 'rxjs';
import { BusinessListing, EventTypeEnum, ShareByEMail, User } from '../../../../../../bizmatch-server/src/models/db.model';
import { KeycloakUser, MailInfo } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment';
import { EMailService } from '../../../components/email/email.service';
import { MessageService } from '../../../components/message/message.service';
import { ValidatedInputComponent } from '../../../components/validated-input/validated-input.component';
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
// Note: Leaflet requires browser environment - protected by isBrowser checks in base class
import { circle, Circle, Control, DomEvent, DomUtil, icon, Icon, latLng, LatLngBounds, Marker, polygon, Polygon, tileLayer } from 'leaflet';
import dayjs from 'dayjs';
import { AuthService } from '../../../services/auth.service';
import { BaseDetailsComponent } from '../base-details.component';
import { ShareButton } from 'ngx-sharebuttons/button';
@Component({
selector: 'app-details-business-listing',
standalone: true,
imports: [SharedModule, ValidatedInputComponent, ValidatedTextareaComponent, ValidatedNgSelectComponent, LeafletModule, BreadcrumbsComponent, ShareButton, NgOptimizedImage],
providers: [],
templateUrl: './details-business-listing.component.html',
styleUrl: '../details.scss',
})
export class DetailsBusinessListingComponent extends BaseDetailsComponent {
// listings: Array<BusinessListing>;
responsiveOptions = [
{
breakpoint: '1199px',
numVisible: 1,
numScroll: 1,
},
{
breakpoint: '991px',
numVisible: 2,
numScroll: 1,
},
{
breakpoint: '767px',
numVisible: 1,
numScroll: 1,
},
];
private id: string | undefined = this.activatedRoute.snapshot.params['slug'] as string | undefined;
override listing: BusinessListing;
mailinfo: MailInfo;
environment = environment;
keycloakUser: KeycloakUser;
user: User;
listingUser: User;
description: SafeHtml;
private history: string[] = [];
ts = new Date().getTime();
env = environment;
breadcrumbs: BreadcrumbItem[] = [];
relatedListings: BusinessListing[] = [];
businessFAQs: Array<{ question: string; answer: string }> = [];
constructor(
private activatedRoute: ActivatedRoute,
private listingsService: ListingsService,
private router: Router,
private userService: UserService,
public selectOptions: SelectOptionsService,
private mailService: MailService,
private sanitizer: DomSanitizer,
public historyService: HistoryService,
private validationMessagesService: ValidationMessagesService,
private messageService: MessageService,
private auditService: AuditService,
public emailService: EMailService,
private geoService: GeoService,
public authService: AuthService,
private cdref: ChangeDetectorRef,
private seoService: SeoService,
) {
super();
this.router.events.subscribe(event => {
if (event instanceof NavigationEnd) {
this.history.push(event.urlAfterRedirects);
}
});
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) {
this.user = await this.userService.getByMail(this.keycloakUser.email);
this.mailinfo = createMailInfo(this.user);
}
try {
this.listing = await lastValueFrom(this.listingsService.getListingById(this.id, 'business'));
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.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}` }
]);
// Generate FAQ for AEO (Answer Engine Optimization)
this.businessFAQs = this.generateBusinessFAQ();
const faqSchema = this.seoService.generateFAQPageSchema(this.businessFAQs);
// Inject all schemas including FAQ
this.seoService.injectMultipleSchemas([productSchema, breadcrumbSchema, faqSchema]);
// 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) {
// 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 = [];
}
}
/**
* Generate dynamic FAQ based on business listing data fields
* Provides AEO (Answer Engine Optimization) content
*/
private generateBusinessFAQ(): Array<{ question: string; answer: string }> {
const faqs: Array<{ question: string; answer: string }> = [];
// FAQ 1: When was this business established?
if (this.listing.established) {
faqs.push({
question: 'When was this business established?',
answer: `This business was established ${this.listing.established} years ago${this.listing.established >= 10 ? ', demonstrating a proven track record and market stability' : ''}.`
});
}
// FAQ 2: What is the asking price?
if (this.listing.price) {
faqs.push({
question: 'What is the asking price for this business?',
answer: `The asking price for this business is $${this.listing.price.toLocaleString()}.${this.listing.salesRevenue ? ` With an annual revenue of $${this.listing.salesRevenue.toLocaleString()}, this represents a competitive valuation.` : ''}`
});
} else {
faqs.push({
question: 'What is the asking price for this business?',
answer: 'The asking price is available upon request. Please contact the seller for detailed pricing information.'
});
}
// FAQ 3: What is included in the sale?
const includedItems: string[] = [];
if (this.listing.realEstateIncluded) includedItems.push('real estate property');
if (this.listing.ffe) includedItems.push(`furniture, fixtures, and equipment valued at $${this.listing.ffe.toLocaleString()}`);
if (this.listing.inventory) includedItems.push(`inventory worth $${this.listing.inventory.toLocaleString()}`);
if (includedItems.length > 0) {
faqs.push({
question: 'What is included in the sale?',
answer: `The sale includes: ${includedItems.join(', ')}.${this.listing.leasedLocation ? ' The business operates from a leased location.' : ''}${this.listing.franchiseResale ? ' This is a franchise resale opportunity.' : ''}`
});
}
// FAQ 4: How many employees does the business have?
if (this.listing.employees) {
faqs.push({
question: 'How many employees does this business have?',
answer: `The business currently employs ${this.listing.employees} ${this.listing.employees === 1 ? 'person' : 'people'}.${this.listing.supportAndTraining ? ' The seller offers support and training to ensure smooth transition.' : ''}`
});
}
// FAQ 5: What is the annual revenue and cash flow?
if (this.listing.salesRevenue || this.listing.cashFlow) {
let answer = '';
if (this.listing.salesRevenue) {
answer += `The business generates an annual revenue of $${this.listing.salesRevenue.toLocaleString()}.`;
}
if (this.listing.cashFlow) {
answer += ` The annual cash flow is $${this.listing.cashFlow.toLocaleString()}.`;
}
faqs.push({
question: 'What is the financial performance of this business?',
answer: answer.trim()
});
}
// FAQ 6: Why is the business for sale?
if (this.listing.reasonForSale) {
faqs.push({
question: 'Why is this business for sale?',
answer: this.listing.reasonForSale
});
}
// FAQ 7: Where is the business located?
faqs.push({
question: 'Where is this business located?',
answer: `This ${this.selectOptions.getBusiness(this.listing.type)} business is located in ${this.listing.location.name || this.listing.location.county}, ${this.selectOptions.getState(this.listing.location.state)}.`
});
// FAQ 8: Is broker licensing required?
if (this.listing.brokerLicencing) {
faqs.push({
question: 'Is a broker license required for this business?',
answer: this.listing.brokerLicencing
});
}
// FAQ 9: What type of business is this?
faqs.push({
question: 'What type of business is this?',
answer: `This is a ${this.selectOptions.getBusiness(this.listing.type)} business${this.listing.established ? ` that has been operating for ${this.listing.established} years` : ''}.`
});
return faqs;
}
ngOnDestroy() {
this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten
this.seoService.clearStructuredData(); // Clean up SEO structured data
}
async mail() {
try {
this.mailinfo.email = this.listingUser.email;
this.mailinfo.listing = this.listing;
await this.mailService.mail(this.mailinfo);
this.validationMessagesService.clearMessages();
this.auditService.createEvent(this.listing.id, 'contact', this.user?.email, this.mailinfo.sender);
this.messageService.addMessage({ severity: 'success', text: 'Your message has been sent to the creator of the listing', duration: 3000 });
this.mailinfo = createMailInfo(this.user);
} catch (error) {
this.messageService.addMessage({
severity: 'danger',
text: 'An error occurred while sending the request - Please check your inputs',
duration: 5000,
});
if (error.error && Array.isArray(error.error?.message)) {
this.validationMessagesService.updateMessages(error.error.message);
}
}
}
get listingDetails() {
let typeOfRealEstate = '';
if (this.listing.realEstateIncluded) {
typeOfRealEstate = 'Real Estate Included';
} else if (this.listing.leasedLocation) {
typeOfRealEstate = 'Leased Location';
} else if (this.listing.franchiseResale) {
typeOfRealEstate = 'Franchise Re-Sale';
}
const result = [
{ label: 'Category', value: this.selectOptions.getBusiness(this.listing.type) },
{
label: 'Located in',
value: `${this.listing.location.name ? this.listing.location.name : this.listing.location.county ? this.listing.location.county : ''} ${this.listing.location.name || this.listing.location.county ? ', ' : ''
}${this.selectOptions.getState(this.listing.location.state)}`,
},
{ label: 'Asking Price', value: `${this.listing.price ? `$${this.listing.price.toLocaleString()}` : 'undisclosed '}` },
{ label: 'Sales revenue', value: `${this.listing.salesRevenue ? `$${this.listing.salesRevenue.toLocaleString()}` : 'undisclosed '}` },
{ label: 'Cash flow', value: `${this.listing.cashFlow ? `$${this.listing.cashFlow.toLocaleString()}` : 'undisclosed '}` },
...(this.listing.ffe
? [
{
label: 'Furniture, Fixtures / Equipment Value (FFE)',
value: `$${this.listing.ffe.toLocaleString()}`,
},
]
: []),
...(this.listing.inventory
? [
{
label: 'Inventory at Cost Value',
value: `$${this.listing.inventory.toLocaleString()}`,
},
]
: []),
{ label: 'Type of Real Estate', value: typeOfRealEstate },
{ label: 'Employees', value: this.listing.employees },
{ label: 'Years established', value: this.listing.established },
{ label: 'Support & Training', value: this.listing.supportAndTraining },
{ label: 'Reason for Sale', value: this.listing.reasonForSale },
{ label: 'Broker licensing', value: this.listing.brokerLicencing },
{ label: 'Listed since', value: `${this.dateInserted()} - ${this.getDaysListed()} days` },
{
label: 'Listing by',
value: null, // Wird nicht verwendet
isHtml: true,
isListingBy: true, // Flag für den speziellen Fall
user: this.listingUser, // Übergebe das User-Objekt
imagePath: this.listing.imageName,
imageBaseUrl: this.env.imageBaseUrl,
ts: this.ts,
},
];
if (this.listing.draft) {
result.push({ label: 'Draft', value: this.listing.draft ? 'Yes' : 'No' });
}
return result;
}
async save() {
await this.listingsService.addToFavorites(this.listing.id, 'business');
this.listing.favoritesForUser.push(this.user.email);
this.auditService.createEvent(this.listing.id, 'favorite', this.user?.email);
}
isAlreadyFavorite() {
return this.listing.favoritesForUser.includes(this.user.email);
}
async showShareByEMail() {
const result = await this.emailService.showShareByEMail({
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,
type: 'business',
});
if (result) {
this.auditService.createEvent(this.listing.id, 'email', this.user?.email, <ShareByEMail>result);
this.messageService.addMessage({
severity: 'success',
text: 'Your Email has beend sent',
duration: 5000,
});
}
}
createEvent(eventType: EventTypeEnum) {
this.auditService.createEvent(this.listing.id, eventType, this.user?.email);
}
shareToFacebook() {
const url = encodeURIComponent(window.location.href);
window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`, '_blank', 'width=600,height=400');
this.createEvent('facebook');
}
shareToTwitter() {
const url = encodeURIComponent(window.location.href);
const text = encodeURIComponent(this.listing?.title || 'Check out this business listing');
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank', 'width=600,height=400');
this.createEvent('x');
}
shareToLinkedIn() {
const url = encodeURIComponent(window.location.href);
window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${url}`, '_blank', 'width=600,height=400');
this.createEvent('linkedin');
}
getDaysListed() {
return dayjs().diff(this.listing.created, 'day');
}
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;
// Check if we have valid coordinates (null-safe check)
if (latitude !== null && latitude !== undefined &&
longitude !== null && longitude !== undefined) {
this.mapCenter = latLng(latitude, longitude);
// Case 1: City name available - show city boundary (current behavior)
if (cityName && state) {
this.mapZoom = 11; // Zoom to city level
// 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);
}
});
}
// Case 2: Only state available (NEW) - show state-level circle
else if (state) {
this.mapZoom = 6; // Zoom to state level
// Use state-level fallback with larger radius
this.useStateLevelFallback(latitude, longitude, county, state);
}
// Case 3: No location name at all - minimal marker
else {
this.mapZoom = 8;
this.useMinimalMarker(latitude, longitude);
}
}
}
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,
};
}
/**
* Show state-level boundary polygon
* Used when only state is available without city
*/
private useStateLevelFallback(latitude: number, longitude: number, county: string, state: string) {
this.mapCenter = latLng(latitude, longitude);
// Fetch state boundary from Nominatim API (similar to city boundary)
this.geoService.getStateBoundary(state).subscribe({
next: (data) => {
if (data && data.length > 0 && data[0].geojson) {
// Handle Polygon
if (data[0].geojson.type === 'Polygon') {
const coordinates = data[0].geojson.coordinates[0];
const latlngs = coordinates.map((coord: number[]) => latLng(coord[1], coord[0]));
const statePolygon = polygon(latlngs, {
color: '#ef4444',
fillColor: '#ef4444',
fillOpacity: 0.05, // Very transparent for large area
weight: 2
});
statePolygon.bindPopup(`
<div style="padding: 8px;">
<strong>General Area:</strong><br/>
${county ? county + ', ' : ''}${state}<br/>
<small style="color: #666;">State 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',
}),
statePolygon
];
// Fit map to state bounds
const bounds = statePolygon.getBounds();
this.mapOptions = {
...this.mapOptions,
center: bounds.getCenter(),
zoom: this.mapZoom,
};
}
// Handle MultiPolygon (states with islands, etc.)
else if (data[0].geojson.type === 'MultiPolygon') {
const allPolygons: Polygon[] = [];
data[0].geojson.coordinates.forEach((polygonCoords: number[][][]) => {
const latlngs = polygonCoords[0].map((coord: number[]) => latLng(coord[1], coord[0]));
const statePolygon = polygon(latlngs, {
color: '#ef4444',
fillColor: '#ef4444',
fillOpacity: 0.05,
weight: 2
});
allPolygons.push(statePolygon);
});
if (allPolygons.length > 0) {
allPolygons[0].bindPopup(`
<div style="padding: 8px;">
<strong>General Area:</strong><br/>
${county ? county + ', ' : ''}${state}<br/>
<small style="color: #666;">State 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 if unexpected format
this.useCircleFallbackForState(latitude, longitude, county, state);
}
} else {
// Fallback if no data
this.useCircleFallbackForState(latitude, longitude, county, state);
}
},
error: (err) => {
console.error('Error fetching state boundary:', err);
// Fallback to circle on error
this.useCircleFallbackForState(latitude, longitude, county, state);
}
});
}
/**
* Fallback: Show circle when state boundary cannot be fetched
*/
private useCircleFallbackForState(latitude: number, longitude: number, county: string, state: string) {
this.mapCenter = latLng(latitude, longitude);
const stateCircle = circle([latitude, longitude], {
color: '#ef4444',
fillColor: '#ef4444',
fillOpacity: 0.05,
weight: 2,
radius: 50000 // 50km
});
stateCircle.bindPopup(`
<div style="padding: 8px;">
<strong>General Area:</strong><br/>
${county ? county + ', ' : ''}${state}<br/>
<small style="color: #666;">Approximate state-level location 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',
}),
stateCircle
];
this.mapOptions = {
...this.mapOptions,
center: this.mapCenter,
zoom: this.mapZoom,
};
}
/**
* Show minimal marker when no location name is available
*/
private useMinimalMarker(latitude: number, longitude: number) {
this.mapCenter = latLng(latitude, longitude);
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',
}),
});
marker.bindPopup(`
<div style="padding: 8px;">
<strong>Location</strong><br/>
<small style="color: #666;">Contact seller for exact address</small>
</div>
`);
this.mapLayers = [
tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
}),
marker
];
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');
}
}
}