feat: Initialize Angular SSR application with core pages, components, and server setup.

This commit is contained in:
Timo
2026-01-03 12:53:37 +01:00
parent 0ac17ef155
commit b52e47b653
28 changed files with 1115 additions and 461 deletions

View File

@@ -1,6 +1,8 @@
import { Component } from '@angular/core';
import { Component, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { Control, DomEvent, DomUtil, icon, Icon, latLng, Layer, Map, MapOptions, Marker, tileLayer } from 'leaflet';
import { BusinessListing, CommercialPropertyListing } from '../../../../../bizmatch-server/src/models/db.model';
@Component({
selector: 'app-base-details',
template: ``,
@@ -12,28 +14,39 @@ export abstract class BaseDetailsComponent {
mapOptions: MapOptions;
mapLayers: Layer[] = [];
mapCenter: any;
mapZoom: number = 13; // Standardzoomlevel
mapZoom: number = 13;
protected listing: BusinessListing | CommercialPropertyListing;
protected isBrowser: boolean;
private platformId = inject(PLATFORM_ID);
constructor() {
this.mapOptions = {
layers: [
tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
}),
],
zoom: this.mapZoom,
center: latLng(0, 0), // Platzhalter, wird später gesetzt
};
this.isBrowser = isPlatformBrowser(this.platformId);
// Only initialize mapOptions in browser context
if (this.isBrowser) {
this.mapOptions = {
layers: [
tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
}),
],
zoom: this.mapZoom,
center: latLng(0, 0),
};
}
}
protected configureMap() {
if (!this.isBrowser) {
return; // Skip on server
}
const latitude = this.listing.location.latitude;
const longitude = this.listing.location.longitude;
if (latitude !== null && latitude !== undefined &&
longitude !== null && longitude !== undefined) {
longitude !== null && longitude !== undefined) {
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);
@@ -53,7 +66,6 @@ export abstract class BaseDetailsComponent {
}),
});
// Add popup to marker with address
if (fullAddress) {
marker.bindPopup(`
<div style="padding: 8px;">
@@ -76,8 +88,12 @@ export abstract class BaseDetailsComponent {
};
}
}
onMapReady(map: Map) {
// Build comprehensive address for the control
if (!this.isBrowser) {
return;
}
const addressParts = [];
if (this.listing.location.housenumber) addressParts.push(this.listing.location.housenumber);
if (this.listing.location.street) addressParts.push(this.listing.location.street);
@@ -99,10 +115,8 @@ export abstract class BaseDetailsComponent {
</div>
`;
// Verhindere, dass die Karte durch das Klicken des Links bewegt wird
DomEvent.disableClickPropagation(container);
// Füge einen Event Listener für den Link hinzu
const link = container.querySelector('#view-full-map') as HTMLElement;
if (link) {
DomEvent.on(link, 'click', (e: Event) => {
@@ -117,12 +131,20 @@ export abstract class BaseDetailsComponent {
addressControl.addTo(map);
}
}
openFullMap() {
if (!this.isBrowser) {
return;
}
const latitude = this.listing.location.latitude;
const longitude = this.listing.location.longitude;
const address = `${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 url = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=15/${latitude}/${longitude}`;
window.open(url, '_blank');
}
}

View File

@@ -5,10 +5,8 @@
}
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg overflow-hidden relative">
<button
(click)="historyService.goBack()"
class="absolute top-4 right-4 bg-red-500 text-white rounded-full w-8 h-8 flex items-center justify-center hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 print:hidden"
>
<button (click)="historyService.goBack()"
class="absolute top-4 right-4 bg-red-500 text-white rounded-full w-8 h-8 flex items-center justify-center hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 print:hidden">
<i class="fas fa-times"></i>
</button>
@if(listing){
@@ -19,30 +17,38 @@
<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-neutral-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" [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 && 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" />
<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>
<div class="py-4 print:hidden">
@if(listing && listingUser && (listingUser?.email===user?.email || (authService.isAdmin() | async))){
<div class="inline">
<button class="share share-edit text-white font-bold text-xs py-1.5 px-2 inline-flex items-center" [routerLink]="['/editBusinessListing', listing.id]">
<button class="share share-edit text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
[routerLink]="['/editBusinessListing', listing.id]">
<i class="fa-regular fa-pen-to-square"></i>
<span class="ml-2">Edit</span>
</button>
</div>
} @if(user){
<div class="inline">
<button class="share share-save text-white font-bold text-xs py-1.5 px-2 inline-flex items-center" (click)="save()" [disabled]="listing.favoritesForUser.includes(user.email)">
<button class="share share-save text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="save()" [disabled]="listing.favoritesForUser.includes(user.email)">
<i class="fa-regular fa-heart"></i>
@if(listing.favoritesForUser.includes(user.email)){
<span class="ml-2">Saved ...</span>
@@ -55,21 +61,46 @@
<share-button button="print" showText="true" (click)="createEvent('print')"></share-button>
<!-- <share-button button="email" showText="true"></share-button> -->
<div class="inline">
<button class="share share-email text-white font-bold text-xs py-1.5 px-2 inline-flex items-center" (click)="showShareByEMail()">
<button class="share share-email text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="showShareByEMail()">
<i class="fa-solid fa-envelope"></i>
<span class="ml-2">Email</span>
</button>
</div>
<share-button button="facebook" showText="true" (click)="createEvent('facebook')"></share-button>
<share-button button="x" showText="true" (click)="createEvent('x')"></share-button>
<share-button button="linkedin" showText="true" (click)="createEvent('linkedin')"></share-button>
<div class="inline">
<button type="button"
class="share share-facebook text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToFacebook()">
<i class="fab fa-facebook"></i>
<span class="ml-2">Facebook</span>
</button>
</div>
<div class="inline">
<button type="button"
class="share share-twitter text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToTwitter()">
<i class="fab fa-x-twitter"></i>
<span class="ml-2">X</span>
</button>
</div>
<div class="inline">
<button type="button"
class="share share-linkedin text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToLinkedIn()">
<i class="fab fa-linkedin"></i>
<span class="ml-2">LinkedIn</span>
</button>
</div>
</div>
<!-- Karte hinzufügen, wenn Straße vorhanden ist -->
<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 style="height: 400px" leaflet [leafletOptions]="mapOptions" [leafletLayers]="mapLayers"
[leafletCenter]="mapCenter" [leafletZoom]="mapZoom" (leafletMapReady)="onMapReady($event)"></div>
</div>
</div>
@@ -80,19 +111,24 @@
<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>
<app-validated-input label="Your Email" name="email" [(ngModel)]="mailinfo.sender.email" kind="email"></app-validated-input>
<app-validated-input label="Your Email" name="email" [(ngModel)]="mailinfo.sender.email"
kind="email"></app-validated-input>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<app-validated-input label="Phone Number" name="phoneNumber" [(ngModel)]="mailinfo.sender.phoneNumber" mask="(000) 000-0000"></app-validated-input>
<app-validated-input label="Phone Number" name="phoneNumber" [(ngModel)]="mailinfo.sender.phoneNumber"
mask="(000) 000-0000"></app-validated-input>
<!-- <app-validated-input label="Country/State" name="state" [(ngModel)]="mailinfo.sender.state"></app-validated-input> -->
<app-validated-ng-select label="State" name="state" [(ngModel)]="mailinfo.sender.state" [items]="selectOptions?.states"></app-validated-ng-select>
<app-validated-ng-select label="State" name="state" [(ngModel)]="mailinfo.sender.state"
[items]="selectOptions?.states"></app-validated-ng-select>
</div>
<div>
<app-validated-textarea label="Questions/Comments" name="comments" [(ngModel)]="mailinfo.sender.comments"></app-validated-textarea>
<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-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>
<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>
@@ -107,13 +143,17 @@
<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="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>
<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>
<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>
@@ -127,11 +167,14 @@
}
<div class="flex justify-between">
<span class="font-medium">Location:</span>
<span>{{ related.location.name || related.location.county }}, {{ selectOptions.getState(related.location.state) }}</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>
<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>
@@ -141,4 +184,4 @@
</div>
</div>
}
</div>
</div>

View File

@@ -2,7 +2,6 @@ import { ChangeDetectorRef, Component } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { LeafletModule } from '@bluehalo/ngx-leaflet';
import { ShareButton } from 'ngx-sharebuttons/button';
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';
@@ -25,15 +24,16 @@ 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
// 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, ShareButton, ValidatedNgSelectComponent, LeafletModule, BreadcrumbsComponent],
imports: [SharedModule, ValidatedInputComponent, ValidatedTextareaComponent, ValidatedNgSelectComponent, LeafletModule, BreadcrumbsComponent, ShareButton],
providers: [],
templateUrl: './details-business-listing.component.html',
styleUrl: '../details.scss',
@@ -231,28 +231,27 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent {
{ 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)}`,
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()}`,
},
]
{
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: 'Inventory at Cost Value',
value: `$${this.listing.inventory.toLocaleString()}`,
},
]
: []),
{ label: 'Type of Real Estate', value: typeOfRealEstate },
{ label: 'Employees', value: this.listing.employees },
@@ -308,6 +307,26 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent {
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');
}
@@ -330,7 +349,7 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent {
// Check if we have valid coordinates (null-safe check)
if (latitude !== null && latitude !== undefined &&
longitude !== null && longitude !== undefined) {
longitude !== null && longitude !== undefined) {
this.mapCenter = latLng(latitude, longitude);
@@ -340,23 +359,23 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent {
// 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
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]));
// 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
});
// 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(`
// Add popup to polygon
cityPolygon.bindPopup(`
<div style="padding: 8px;">
<strong>General Area:</strong><br/>
${cityName}, ${county ? county + ', ' : ''}${state}<br/>
@@ -364,74 +383,74 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent {
</div>
`);
this.mapLayers = [
tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
}),
cityPolygon
];
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[] = [];
// 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
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);
});
allPolygons.push(cityPolygon);
});
// Add popup to first polygon
if (allPolygons.length > 0) {
allPolygons[0].bindPopup(`
// 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
];
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,
};
// 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);
}
} else {
// Fallback: Use circle if no polygon data available
},
error: (err) => {
console.error('Error fetching city boundary:', err);
// Fallback: Use circle on error
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) {

View File

@@ -6,10 +6,8 @@
@if(listing){
<div class="p-6 relative">
<h1 class="text-3xl font-bold mb-4">{{ listing?.title }}</h1>
<button
(click)="historyService.goBack()"
class="print:hidden absolute top-4 right-4 bg-red-500 text-white rounded-full w-8 h-8 flex items-center justify-center hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50"
>
<button (click)="historyService.goBack()"
class="print:hidden absolute top-4 right-4 bg-red-500 text-white rounded-full w-8 h-8 flex items-center justify-center hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50">
<i class="fas fa-times"></i>
</button>
<div class="flex flex-col lg:flex-row">
@@ -17,33 +15,41 @@
<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-neutral-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 -->
<div class="w-full sm:w-2/3 p-2" *ngIf="!detail.isHtml && !detail.isListingBy">{{ detail.value }}</div>
<!-- HTML Content (nicht für RouterLink) -->
<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" [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 && 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" />
<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>
<div class="py-4 print:hidden">
@if(listing && listingUser && (listingUser?.email===user?.email || (authService.isAdmin() | async))){
<div class="inline">
<button class="share share-edit text-white font-bold text-xs py-1.5 px-2 inline-flex items-center" [routerLink]="['/editCommercialPropertyListing', listing.id]">
<button class="share share-edit text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
[routerLink]="['/editCommercialPropertyListing', listing.id]">
<i class="fa-regular fa-pen-to-square"></i>
<span class="ml-2">Edit</span>
</button>
</div>
} @if(user){
<div class="inline">
<button class="share share-save text-white font-bold text-xs py-1.5 px-2 inline-flex items-center" (click)="save()" [disabled]="listing.favoritesForUser.includes(user.email)">
<button class="share share-save text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="save()" [disabled]="listing.favoritesForUser.includes(user.email)">
<i class="fa-regular fa-heart"></i>
@if(listing.favoritesForUser.includes(user.email)){
<span class="ml-2">Saved ...</span>
@@ -56,21 +62,46 @@
<share-button button="print" showText="true" (click)="createEvent('print')"></share-button>
<!-- <share-button button="email" showText="true"></share-button> -->
<div class="inline">
<button class="share share-email text-white font-bold text-xs py-1.5 px-2 inline-flex items-center" (click)="showShareByEMail()">
<button class="share share-email text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="showShareByEMail()">
<i class="fa-solid fa-envelope"></i>
<span class="ml-2">Email</span>
</button>
</div>
<share-button button="facebook" showText="true" (click)="createEvent('facebook')"></share-button>
<share-button button="x" showText="true" (click)="createEvent('x')"></share-button>
<share-button button="linkedin" showText="true" (click)="createEvent('linkedin')"></share-button>
<div class="inline">
<button type="button"
class="share share-facebook text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToFacebook()">
<i class="fab fa-facebook"></i>
<span class="ml-2">Facebook</span>
</button>
</div>
<div class="inline">
<button type="button"
class="share share-twitter text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToTwitter()">
<i class="fab fa-x-twitter"></i>
<span class="ml-2">X</span>
</button>
</div>
<div class="inline">
<button type="button"
class="share share-linkedin text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
(click)="shareToLinkedIn()">
<i class="fab fa-linkedin"></i>
<span class="ml-2">LinkedIn</span>
</button>
</div>
</div>
<!-- Karte hinzufügen, wenn Straße vorhanden ist -->
<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>
<div style="height: 400px" leaflet [leafletOptions]="mapOptions" [leafletLayers]="mapLayers"
[leafletCenter]="mapCenter" [leafletZoom]="mapZoom" (leafletMapReady)="onMapReady($event)"></div>
</div>
</div>
@@ -89,20 +120,26 @@
<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>
<app-validated-input label="Your Email" name="email" [(ngModel)]="mailinfo.sender.email" kind="email"></app-validated-input>
<app-validated-input label="Your Name" name="name"
[(ngModel)]="mailinfo.sender.name"></app-validated-input>
<app-validated-input label="Your Email" name="email" [(ngModel)]="mailinfo.sender.email"
kind="email"></app-validated-input>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<app-validated-input label="Phone Number" name="phoneNumber" [(ngModel)]="mailinfo.sender.phoneNumber" mask="(000) 000-0000"></app-validated-input>
<app-validated-input label="Phone Number" name="phoneNumber" [(ngModel)]="mailinfo.sender.phoneNumber"
mask="(000) 000-0000"></app-validated-input>
<!-- <app-validated-input label="Country/State" name="state" [(ngModel)]="mailinfo.sender.state"></app-validated-input> -->
<app-validated-ng-select label="State" name="state" [(ngModel)]="mailinfo.sender.state" [items]="selectOptions?.states"></app-validated-ng-select>
<app-validated-ng-select label="State" name="state" [(ngModel)]="mailinfo.sender.state"
[items]="selectOptions?.states"></app-validated-ng-select>
</div>
<div>
<app-validated-textarea label="Questions/Comments" name="comments" [(ngModel)]="mailinfo.sender.comments"></app-validated-textarea>
<app-validated-textarea label="Questions/Comments" name="comments"
[(ngModel)]="mailinfo.sender.comments"></app-validated-textarea>
</div>
<div class="flex items-center justify-between">
<button (click)="mail()" class="bg-primary-500 text-white px-4 py-2 rounded hover:bg-primary-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>
@@ -120,13 +157,17 @@
<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="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>
<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>
<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>
@@ -134,11 +175,14 @@
</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>
<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>
<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>
@@ -148,4 +192,4 @@
</div>
</div>
}
</div>
</div>

View File

@@ -5,7 +5,6 @@ import { LeafletModule } from '@bluehalo/ngx-leaflet';
import { faTimes } from '@fortawesome/free-solid-svg-icons';
import dayjs from 'dayjs';
import { GalleryModule, ImageItem } from 'ng-gallery';
import { ShareButton } from 'ngx-sharebuttons/button';
import { lastValueFrom } from 'rxjs';
import { CommercialPropertyListing, EventTypeEnum, ShareByEMail, User } from '../../../../../../bizmatch-server/src/models/db.model';
import { CommercialPropertyListingCriteria, ErrorResponse, KeycloakUser, MailInfo } from '../../../../../../bizmatch-server/src/models/main.model';
@@ -29,11 +28,12 @@ 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';
import { ShareButton } from 'ngx-sharebuttons/button';
@Component({
selector: 'app-details-commercial-property-listing',
standalone: true,
imports: [SharedModule, ValidatedInputComponent, ValidatedTextareaComponent, ShareButton, ValidatedNgSelectComponent, GalleryModule, LeafletModule, BreadcrumbsComponent],
imports: [SharedModule, ValidatedInputComponent, ValidatedTextareaComponent, ValidatedNgSelectComponent, GalleryModule, LeafletModule, BreadcrumbsComponent, ShareButton],
providers: [],
templateUrl: './details-commercial-property-listing.component.html',
styleUrl: '../details.scss',
@@ -162,7 +162,7 @@ export class DetailsCommercialPropertyListingComponent extends BaseDetailsCompon
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.env.imageBaseUrl}/pictures/property/${this.listing.imagePath}/${this.listing.serialId}/${img}`)
: []
};
this.seoService.updateCommercialPropertyMeta(propertyData);
@@ -282,6 +282,26 @@ export class DetailsCommercialPropertyListingComponent extends BaseDetailsCompon
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 commercial property');
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');
}

View File

@@ -58,6 +58,7 @@ button.share {
margin-right: 4px;
margin-left: 2px;
border-radius: 4px;
cursor: pointer;
i {
font-size: 15px;
}
@@ -71,6 +72,15 @@ button.share {
.share-email {
background-color: #ff961c;
}
.share-facebook {
background-color: #1877f2;
}
.share-twitter {
background-color: #000000;
}
.share-linkedin {
background-color: #0a66c2;
}
:host ::ng-deep .ng-select-container {
height: 42px !important;
border-radius: 0.5rem;