831 lines
32 KiB
TypeScript
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: '© 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);
|
|
}
|
|
});
|
|
}
|
|
// 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: '© 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: '© 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: '© 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: '© 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: '© 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');
|
|
}
|
|
}
|
|
}
|