Compare commits
10 Commits
097f911889
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 18d74cddff | |||
| 537ba783d1 | |||
| 093563a458 | |||
| e63c0f5998 | |||
| 2492720470 | |||
| c4b509c1eb | |||
| 577b63b7e7 | |||
| 84c24b2e92 | |||
| b628bbb83f | |||
| 0e6b54b9e4 |
4
bizmatch-server/src/config/pictures.config.ts
Normal file
4
bizmatch-server/src/config/pictures.config.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Single source of truth for the pictures directory.
|
||||
// MUST match the container-side path of the volume mount in docker-compose.yml:
|
||||
// ./bizmatch-server/pictures:/app/dist/pictures
|
||||
export const PICTURES_DIR = process.env.PICTURES_DIR || '/app/dist/pictures';
|
||||
@@ -3,14 +3,15 @@ import fs from 'fs-extra';
|
||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||
import sharp from 'sharp';
|
||||
import { Logger } from 'winston';
|
||||
import { PICTURES_DIR } from '../config/pictures.config';
|
||||
|
||||
@Injectable()
|
||||
export class FileService {
|
||||
constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) {
|
||||
fs.ensureDirSync(`./pictures`);
|
||||
fs.ensureDirSync(`./pictures/profile`);
|
||||
fs.ensureDirSync(`./pictures/logo`);
|
||||
fs.ensureDirSync(`./pictures/property`);
|
||||
fs.ensureDirSync(`${PICTURES_DIR}`);
|
||||
fs.ensureDirSync(`${PICTURES_DIR}/profile`);
|
||||
fs.ensureDirSync(`${PICTURES_DIR}/logo`);
|
||||
fs.ensureDirSync(`${PICTURES_DIR}/property`);
|
||||
}
|
||||
// ############
|
||||
// Profile
|
||||
@@ -22,10 +23,10 @@ export class FileService {
|
||||
.avif({ quality }) // Verwende AVIF
|
||||
//.webp({ quality }) // Verwende Webp
|
||||
.toBuffer();
|
||||
await sharp(output).toFile(`./pictures/profile/${adjustedEmail}.avif`);
|
||||
await sharp(output).toFile(`${PICTURES_DIR}/profile/${adjustedEmail}.avif`);
|
||||
}
|
||||
hasProfile(adjustedEmail: string) {
|
||||
return fs.existsSync(`./pictures/profile/${adjustedEmail}.avif`);
|
||||
return fs.existsSync(`${PICTURES_DIR}/profile/${adjustedEmail}.avif`);
|
||||
}
|
||||
// ############
|
||||
// Logo
|
||||
@@ -37,18 +38,18 @@ export class FileService {
|
||||
.avif({ quality }) // Verwende AVIF
|
||||
//.webp({ quality }) // Verwende Webp
|
||||
.toBuffer();
|
||||
await sharp(output).toFile(`./pictures/logo/${adjustedEmail}.avif`); // Ersetze Dateierweiterung
|
||||
// await fs.outputFile(`./pictures/logo/${userId}`, file.buffer);
|
||||
await sharp(output).toFile(`${PICTURES_DIR}/logo/${adjustedEmail}.avif`); // Ersetze Dateierweiterung
|
||||
// await fs.outputFile(`${PICTURES_DIR}/logo/${userId}`, file.buffer);
|
||||
}
|
||||
hasCompanyLogo(adjustedEmail: string) {
|
||||
return fs.existsSync(`./pictures/logo/${adjustedEmail}.avif`) ? true : false;
|
||||
return fs.existsSync(`${PICTURES_DIR}/logo/${adjustedEmail}.avif`) ? true : false;
|
||||
}
|
||||
// ############
|
||||
// Property
|
||||
// ############
|
||||
async getPropertyImages(imagePath: string, serial: string): Promise<string[]> {
|
||||
const result: string[] = [];
|
||||
const directory = `./pictures/property/${imagePath}/${serial}`;
|
||||
const directory = `${PICTURES_DIR}/property/${imagePath}/${serial}`;
|
||||
if (fs.existsSync(directory)) {
|
||||
const files = await fs.readdir(directory);
|
||||
files.forEach(f => {
|
||||
@@ -60,7 +61,7 @@ export class FileService {
|
||||
}
|
||||
}
|
||||
async hasPropertyImages(imagePath: string, serial: string): Promise<boolean> {
|
||||
const directory = `./pictures/property/${imagePath}/${serial}`;
|
||||
const directory = `${PICTURES_DIR}/property/${imagePath}/${serial}`;
|
||||
if (fs.existsSync(directory)) {
|
||||
const files = await fs.readdir(directory);
|
||||
return files.length > 0;
|
||||
@@ -69,7 +70,7 @@ export class FileService {
|
||||
}
|
||||
}
|
||||
async storePropertyPicture(file: Express.Multer.File, imagePath: string, serial: string): Promise<string> {
|
||||
const directory = `./pictures/property/${imagePath}/${serial}`;
|
||||
const directory = `${PICTURES_DIR}/property/${imagePath}/${serial}`;
|
||||
fs.ensureDirSync(`${directory}`);
|
||||
const imageName = await this.getNextImageName(directory);
|
||||
//await fs.outputFile(`${directory}/${imageName}`, file.buffer);
|
||||
@@ -111,7 +112,7 @@ export class FileService {
|
||||
}
|
||||
|
||||
deleteDirectoryIfExists(imagePath) {
|
||||
const dirPath = `pictures/property/${imagePath}`;
|
||||
const dirPath = `${PICTURES_DIR}/property/${imagePath}`;
|
||||
try {
|
||||
const exists = fs.pathExistsSync();
|
||||
if (exists) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Logger } from 'winston';
|
||||
import { FileService } from '../file/file.service';
|
||||
import { CommercialPropertyService } from '../listings/commercial-property.service';
|
||||
import { SelectOptionsService } from '../select-options/select-options.service';
|
||||
import { PICTURES_DIR } from '../config/pictures.config';
|
||||
|
||||
@Controller('image')
|
||||
export class ImageController {
|
||||
@@ -28,7 +29,7 @@ export class ImageController {
|
||||
@UseGuards(AuthGuard)
|
||||
@Delete('propertyPicture/:imagePath/:serial/:imagename')
|
||||
async deletePropertyImagesById(@Param('imagePath') imagePath: string, @Param('serial') serial: string, @Param('imagename') imagename: string): Promise<any> {
|
||||
this.fileService.deleteImage(`pictures/property/${imagePath}/${serial}/${imagename}`);
|
||||
this.fileService.deleteImage(`${PICTURES_DIR}/property/${imagePath}/${serial}/${imagename}`);
|
||||
await this.listingService.deleteImage(imagePath, serial, imagename);
|
||||
}
|
||||
// ############
|
||||
@@ -43,7 +44,7 @@ export class ImageController {
|
||||
@UseGuards(AuthGuard)
|
||||
@Delete('profile/:email/')
|
||||
async deleteProfileImagesById(@Param('email') email: string): Promise<any> {
|
||||
this.fileService.deleteImage(`pictures/profile/${email}.avif`);
|
||||
this.fileService.deleteImage(`${PICTURES_DIR}/profile/${email}.avif`);
|
||||
}
|
||||
// ############
|
||||
// Logo
|
||||
@@ -57,6 +58,6 @@ export class ImageController {
|
||||
@UseGuards(AuthGuard)
|
||||
@Delete('logo/:email/')
|
||||
async deleteLogoImagesById(@Param('email') adjustedEmail: string): Promise<any> {
|
||||
this.fileService.deleteImage(`pictures/logo/${adjustedEmail}.avif`);
|
||||
this.fileService.deleteImage(`${PICTURES_DIR}/logo/${adjustedEmail}.avif`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { GeoService } from '../geo/geo.service';
|
||||
import { BusinessListing, BusinessListingSchema } from '../models/db.model';
|
||||
import { BusinessListingCriteria, JwtUser } from '../models/main.model';
|
||||
import { getDistanceQuery, splitName } from '../utils';
|
||||
import { generateSlug, extractShortIdFromSlug, isSlug } from '../utils/slug.utils';
|
||||
import { generateSlug, extractShortIdFromSlug, isSlug, isUUID } from '../utils/slug.utils';
|
||||
|
||||
@Injectable()
|
||||
export class BusinessListingService {
|
||||
@@ -271,6 +271,9 @@ export class BusinessListingService {
|
||||
}
|
||||
} else {
|
||||
this.logger.debug(`Detected as UUID: ${slugOrId}`);
|
||||
if (!isUUID(slugOrId)) {
|
||||
throw new BadRequestException(`Invalid identifier format: ${slugOrId}`);
|
||||
}
|
||||
}
|
||||
|
||||
return this.findBusinessesById(id, user);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { GeoService } from '../geo/geo.service';
|
||||
import { CommercialPropertyListing, CommercialPropertyListingSchema } from '../models/db.model';
|
||||
import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model';
|
||||
import { getDistanceQuery, splitName } from '../utils';
|
||||
import { generateSlug, extractShortIdFromSlug, isSlug } from '../utils/slug.utils';
|
||||
import { generateSlug, extractShortIdFromSlug, isSlug, isUUID } from '../utils/slug.utils';
|
||||
|
||||
@Injectable()
|
||||
export class CommercialPropertyService {
|
||||
@@ -166,6 +166,9 @@ export class CommercialPropertyService {
|
||||
}
|
||||
} else {
|
||||
this.logger.debug(`Detected as UUID: ${slugOrId}`);
|
||||
if (!isUUID(slugOrId)) {
|
||||
throw new BadRequestException(`Invalid identifier format: ${slugOrId}`);
|
||||
}
|
||||
}
|
||||
|
||||
return this.findCommercialPropertiesById(id, user);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Controller, Get, Inject, Param, Request, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, Inject, Param, Request, UseGuards, BadRequestException } from '@nestjs/common';
|
||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||
import { OptionalAuthGuard } from 'src/jwt-auth/optional-auth.guard';
|
||||
import { Logger } from 'winston';
|
||||
import { BusinessListingService } from './business-listing.service';
|
||||
import { CommercialPropertyService } from './commercial-property.service';
|
||||
import { isUUID } from '../utils/slug.utils';
|
||||
|
||||
@Controller('listings/undefined')
|
||||
export class UnknownListingsController {
|
||||
@@ -16,6 +17,9 @@ export class UnknownListingsController {
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Get(':id')
|
||||
async findById(@Request() req, @Param('id') id: string): Promise<any> {
|
||||
if (!isUUID(id)) {
|
||||
throw new BadRequestException(`Invalid identifier format: ${id}`);
|
||||
}
|
||||
try {
|
||||
return await this.businessListingsService.findBusinessesById(id, req.user);
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import express from 'express';
|
||||
import helmet from 'helmet';
|
||||
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
|
||||
import { AppModule } from './app.module';
|
||||
import { PICTURES_DIR } from './config/pictures.config';
|
||||
|
||||
async function bootstrap() {
|
||||
const server = express();
|
||||
@@ -14,7 +15,12 @@ async function bootstrap() {
|
||||
app.useLogger(logger);
|
||||
//app.use('/bizmatch/payment/webhook', bodyParser.raw({ type: 'application/json' }));
|
||||
// Serve static files from pictures directory
|
||||
app.use('/pictures', express.static('pictures'));
|
||||
app.use('/pictures', express.static(PICTURES_DIR));
|
||||
// Prevent browsers from caching 404s on /pictures/*
|
||||
app.use('/pictures', (_req, res) => {
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.status(404).end();
|
||||
});
|
||||
|
||||
app.setGlobalPrefix('bizmatch');
|
||||
|
||||
@@ -54,6 +60,17 @@ async function bootstrap() {
|
||||
}),
|
||||
);
|
||||
|
||||
// Reject CSS/JS sourcemap requests before they reach any API controller.
|
||||
// Sourcemap URLs resolve relative to the current page URL and can match
|
||||
// wildcard route params (e.g. /bizmatch/user/default.css.map → @Get(':id')).
|
||||
app.use((req, res, next) => {
|
||||
if (req.path.endsWith('.css.map') || req.path.endsWith('.js.map')) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
await app.listen(process.env.PORT || 3001);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -10,6 +10,7 @@ import { OptionalAuthGuard } from 'src/jwt-auth/optional-auth.guard';
|
||||
import { User } from '../models/db.model';
|
||||
import { JwtUser, Subscription, UserListingCriteria } from '../models/main.model';
|
||||
import { UserService } from './user.service';
|
||||
import { isUUID } from '../utils/slug.utils';
|
||||
|
||||
@Controller('user')
|
||||
export class UserController {
|
||||
@@ -29,6 +30,9 @@ export class UserController {
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Get(':id')
|
||||
async findById(@Param('id') id: string): Promise<User> {
|
||||
if (!isUUID(id)) {
|
||||
throw new BadRequestException(`Invalid identifier format: ${id}`);
|
||||
}
|
||||
const user = await this.userService.getUserById(id);
|
||||
return user;
|
||||
}
|
||||
@@ -81,6 +85,9 @@ export class UserController {
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('subscriptions/:id')
|
||||
async findSubscriptionsById(@Param('id') id: string): Promise<Subscription[]> {
|
||||
if (!isUUID(id)) {
|
||||
throw new BadRequestException(`Invalid identifier format: ${id}`);
|
||||
}
|
||||
const subscriptions = [];
|
||||
const user = await this.userService.getUserById(id);
|
||||
subscriptions.forEach(s => {
|
||||
|
||||
@@ -107,6 +107,13 @@ export function isValidSlug(slug: string): boolean {
|
||||
return lastPart.length === 8 && /^[a-z0-9]{8}$/.test(lastPart);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is a valid UUID v4
|
||||
*/
|
||||
export function isUUID(param: string): boolean {
|
||||
return /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(param);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a parameter is a slug (vs a UUID)
|
||||
*
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
],
|
||||
"optimization": true,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true,
|
||||
"sourceMap": false,
|
||||
"outputHashing": "all"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -29,9 +29,10 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="relative">
|
||||
<button type="button"
|
||||
class="flex text-sm bg-neutral-400 rounded-full md:me-0 focus:ring-4 focus:ring-neutral-300 dark:focus:ring-neutral-600"
|
||||
id="user-menu-button" aria-expanded="false" [attr.data-dropdown-toggle]="user ? 'user-login' : 'user-unknown'">
|
||||
id="user-menu-button" aria-expanded="false" (click)="toggleUserMenu()">
|
||||
<span class="sr-only">Open user menu</span>
|
||||
@if(isProfessional || (authService.isAdmin() | async) && user?.hasProfile){
|
||||
<img class="w-8 h-8 rounded-full object-cover" src="{{ profileUrl }}"
|
||||
@@ -43,8 +44,8 @@
|
||||
<!-- Dropdown menu -->
|
||||
@if(user){
|
||||
<div
|
||||
class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-neutral-100 rounded-lg shadow dark:bg-neutral-700 dark:divide-neutral-600"
|
||||
id="user-login">
|
||||
class="absolute right-0 z-50 mt-2 text-base list-none bg-white divide-y divide-neutral-100 rounded-lg shadow dark:bg-neutral-700 dark:divide-neutral-600"
|
||||
id="user-login" [class.hidden]="!userMenuVisible">
|
||||
<div class="px-4 py-3">
|
||||
<span class="block text-sm text-neutral-900 dark:text-white">Welcome, {{ user.firstname }} </span>
|
||||
<span class="block text-sm text-neutral-500 truncate dark:text-neutral-400">{{ user.email }}</span>
|
||||
@@ -122,8 +123,8 @@
|
||||
</div>
|
||||
} @else {
|
||||
<div
|
||||
class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-neutral-100 rounded-lg shadow dark:bg-neutral-700 dark:divide-neutral-600"
|
||||
id="user-unknown">
|
||||
class="absolute right-0 z-50 mt-2 text-base list-none bg-white divide-y divide-neutral-100 rounded-lg shadow dark:bg-neutral-700 dark:divide-neutral-600"
|
||||
id="user-unknown" [class.hidden]="!userMenuVisible">
|
||||
<ul class="py-2" aria-labelledby="user-menu-button">
|
||||
<li>
|
||||
<a routerLink="/login" [queryParams]="{ mode: 'login' }"
|
||||
@@ -161,6 +162,7 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="items-center justify-between hidden w-full md:flex md:w-auto md:order-1" id="navbar-user">
|
||||
<ul
|
||||
class="flex flex-col font-medium p-4 md:p-0 mt-4 border border-neutral-100 rounded-lg bg-neutral-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-neutral-800 md:dark:bg-neutral-900 dark:border-neutral-700">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms';
|
||||
import { NavigationEnd, Router, RouterModule } from '@angular/router';
|
||||
import { APP_ICONS } from '../../utils/fontawesome-icons';
|
||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||
import { Collapse, Dropdown, initFlowbite } from 'flowbite';
|
||||
import { Collapse } from 'flowbite';
|
||||
import { filter, Observable, Subject, takeUntil } from 'rxjs';
|
||||
|
||||
import { SortByOptions, User } from '../../../../../bizmatch-server/src/models/db.model';
|
||||
@@ -38,8 +38,8 @@ export class HeaderComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
faUserGear = APP_ICONS.faUserGear;
|
||||
profileUrl: string;
|
||||
env = environment;
|
||||
private filterDropdown: Dropdown | null = null;
|
||||
isMobile: boolean = false;
|
||||
userMenuVisible = false;
|
||||
private destroy$ = new Subject<void>();
|
||||
prompt: string;
|
||||
private platformId = inject(PLATFORM_ID);
|
||||
@@ -109,15 +109,9 @@ export class HeaderComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
this.profileUrl = photoUrl;
|
||||
});
|
||||
|
||||
// User Updates - re-initialize Flowbite when user state changes
|
||||
// This ensures the dropdown bindings are updated when the dropdown target changes
|
||||
// User Updates
|
||||
this.userService.currentUser.pipe(untilDestroyed(this)).subscribe(u => {
|
||||
const previousUser = this.user;
|
||||
this.user = u;
|
||||
// Re-initialize Flowbite if user logged in/out state changed
|
||||
if ((previousUser === null) !== (u === null) && this.isBrowser) {
|
||||
setTimeout(() => initFlowbite(), 50);
|
||||
}
|
||||
});
|
||||
|
||||
// Router Events
|
||||
@@ -235,15 +229,7 @@ export class HeaderComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
}
|
||||
|
||||
closeDropdown() {
|
||||
if (!this.isBrowser) return;
|
||||
|
||||
const dropdownButton = document.getElementById('user-menu-button');
|
||||
const dropdownMenu = this.user ? document.getElementById('user-login') : document.getElementById('user-unknown');
|
||||
|
||||
if (dropdownButton && dropdownMenu) {
|
||||
const dropdown = new Dropdown(dropdownMenu, dropdownButton);
|
||||
dropdown.hide();
|
||||
}
|
||||
this.userMenuVisible = false;
|
||||
}
|
||||
|
||||
closeMobileMenu() {
|
||||
@@ -286,6 +272,10 @@ export class HeaderComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
this.sortDropdownVisible = !this.sortDropdownVisible;
|
||||
}
|
||||
|
||||
toggleUserMenu() {
|
||||
this.userMenuVisible = !this.userMenuVisible;
|
||||
}
|
||||
|
||||
get isProfessional() {
|
||||
return this.user?.customerType === 'professional';
|
||||
}
|
||||
|
||||
@@ -99,21 +99,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- Karte hinzufügen, wenn Straße vorhanden ist -->
|
||||
<div *ngIf="listing.location.latitude && listing.location.longitude" class="mt-6">
|
||||
<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>
|
||||
</div>
|
||||
<!-- Disclaimer: immer sichtbar, unabhängig von der Map -->
|
||||
<p class="mt-4 text-sm text-neutral-500 leading-relaxed w-full">
|
||||
The information on this listing has been provided by either the seller or a business broker
|
||||
representing the seller. BizMatch, Inc. has no interest or stake in the sale of this business
|
||||
and has not verified any of the information and assumes no responsibility for its accuracy,
|
||||
veracity, or completeness. See our full
|
||||
<a class="text-primary-600 hover:underline hover:cursor-pointer" routerLink="/terms-of-use">Terms of Use</a>.
|
||||
</p>
|
||||
|
||||
<!-- Right column -->
|
||||
<div class="w-full lg:w-1/2 mt-6 lg:mt-0 print:hidden">
|
||||
<h2 class="md:mt-8 mb-4 text-xl font-bold">Contact the Author of this Listing</h2>
|
||||
@@ -142,6 +135,16 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Disclaimer: volle Breite, unterhalb beider Spalten -->
|
||||
<div class="px-6 pb-6">
|
||||
<p class="disclaimer text-sm text-neutral-500 leading-relaxed border-t border-neutral-200 pt-4">
|
||||
The information on this listing has been provided by either the seller or a business broker representing the
|
||||
seller. BizMatch, Inc. has no interest or stake in the sale of this business and has not verified any of the
|
||||
information and assumes no responsibility for its accuracy, veracity, or completeness. See our full
|
||||
<a class="text-primary-600 hover:underline hover:cursor-pointer" routerLink="/terms-of-use">Terms of Use</a>.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -210,8 +213,8 @@
|
||||
}
|
||||
<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 ? (related.location.name || related.location.county) : '—' }}, {{
|
||||
selectOptions.getState(related.location?.state) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
|
||||
@@ -100,21 +100,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- Karte hinzufügen, wenn Straße vorhanden ist -->
|
||||
<div *ngIf="listing.location.latitude && listing.location.longitude" class="mt-6">
|
||||
<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>
|
||||
</div>
|
||||
<!-- Disclaimer: immer sichtbar, unabhängig von der Map -->
|
||||
<p class="mt-4 text-sm text-neutral-500 leading-relaxed w-full">
|
||||
The information on this listing has been provided by either the seller or a business broker
|
||||
representing the seller. BizMatch, Inc. has no interest or stake in the sale of this business
|
||||
and has not verified any of the information and assumes no responsibility for its accuracy,
|
||||
veracity, or completeness. See our full
|
||||
<a class="text-primary-600 hover:underline hover:cursor-pointer" routerLink="/terms-of-use">Terms of Use</a>.
|
||||
</p>
|
||||
|
||||
<div class="w-full lg:w-1/2 mt-6 lg:mt-0">
|
||||
@if(this.images.length>0){
|
||||
<div class="block print:hidden">
|
||||
@@ -155,6 +148,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Disclaimer: volle Breite, unterhalb beider Spalten -->
|
||||
<div class="pt-6">
|
||||
<p class="disclaimer text-sm text-neutral-500 leading-relaxed border-t border-neutral-200 pt-4">
|
||||
The information on this listing has been provided by either the seller or a business broker representing the
|
||||
seller. BizMatch, Inc. has no interest or stake in the sale of this business and has not verified any of the
|
||||
information and assumes no responsibility for its accuracy, veracity, or completeness. See our full
|
||||
<a class="text-primary-600 hover:underline hover:cursor-pointer" routerLink="/terms-of-use">Terms of Use</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -218,8 +221,8 @@
|
||||
</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 ? (related.location.name || related.location.county) : '—' }}, {{
|
||||
selectOptions.getState(related.location?.state) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
|
||||
@@ -161,7 +161,7 @@ export class DetailsCommercialPropertyListingComponent extends BaseDetailsCompon
|
||||
}
|
||||
if (this.listing.imageOrder && Array.isArray(this.listing.imageOrder)) {
|
||||
this.listing.imageOrder.forEach(image => {
|
||||
const imageURL = `${this.env.imageBaseUrl}/pictures/property/${this.listing.imagePath}/${this.listing.serialId}/${image}`;
|
||||
const imageURL = `${this.env.imageBaseUrl}/pictures/property/${this.listing.imagePath}/${this.listing.serialId}/${image}?_ts=${new Date(this.listing.updated).getTime()}`;
|
||||
this.images.push(new ImageItem({ src: imageURL, thumb: imageURL }));
|
||||
});
|
||||
}
|
||||
@@ -185,7 +185,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}?_ts=${new Date(this.listing.updated).getTime()}`)
|
||||
: []
|
||||
};
|
||||
this.seoService.updateCommercialPropertyMeta(propertyData);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
:host ::ng-deep p {
|
||||
:host ::ng-deep p:not(.disclaimer) {
|
||||
display: block;
|
||||
//margin-top: 1em;
|
||||
//margin-bottom: 1em;
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
<div class="flex justify-between">
|
||||
<span
|
||||
class="w-fit inline-flex items-center justify-center px-2 py-1 mb-4 text-xs font-bold leading-none bg-neutral-200 text-neutral-700 rounded-full">
|
||||
{{ selectOptions.getState(listing.location.state) }}
|
||||
{{ selectOptions.getState(listing.location?.state) }}
|
||||
</span>
|
||||
|
||||
@if (getListingBadge(listing); as badge) {
|
||||
@@ -127,8 +127,8 @@
|
||||
}}
|
||||
</p>
|
||||
<p class="text-sm text-neutral-600 mb-2">
|
||||
<strong>Location:</strong> {{ listing.location.name ? listing.location.name : listing.location.county ?
|
||||
listing.location.county : this.selectOptions.getState(listing.location.state) }}
|
||||
<strong>Location:</strong> {{ listing.location ? (listing.location.name ? listing.location.name : listing.location.county ?
|
||||
listing.location.county : selectOptions.getState(listing.location.state)) : '—' }}
|
||||
</p>
|
||||
<p class="text-sm text-neutral-600 mb-4"><strong>Years established:</strong> {{ listing.established }}</p>
|
||||
@if(listing.imageName) {
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
</button>
|
||||
</div>
|
||||
@if (listing.imageOrder?.length>0){
|
||||
<img [appLazyLoad]="env.imageBaseUrl + '/pictures/property/' + listing.imagePath + '/' + listing.serialId + '/' + listing.imageOrder[0]"
|
||||
<img [appLazyLoad]="env.imageBaseUrl + '/pictures/property/' + listing.imagePath + '/' + listing.serialId + '/' + listing.imageOrder[0] + '?_ts=' + listing.updated"
|
||||
[alt]="altText.generatePropertyListingAlt(listing)"
|
||||
class="w-full h-48 object-cover"
|
||||
width="400"
|
||||
@@ -61,7 +61,7 @@
|
||||
><i [class]="selectOptions.getIconAndTextColorTypeOfCommercials(listing.type)" class="mr-1"></i> {{ selectOptions.getCommercialProperty(listing.type) }}</span
|
||||
>
|
||||
<div class="flex items-center justify-between my-2">
|
||||
<span class="bg-neutral-200 text-neutral-700 text-xs font-semibold px-2 py-1 rounded">{{ selectOptions.getState(listing.location.state) }}</span>
|
||||
<span class="bg-neutral-200 text-neutral-700 text-xs font-semibold px-2 py-1 rounded">{{ selectOptions.getState(listing.location?.state) }}</span>
|
||||
<p class="text-sm text-neutral-600 mb-4">
|
||||
<strong>{{ getDaysListed(listing) }} days listed</strong>
|
||||
</p>
|
||||
@@ -72,7 +72,7 @@
|
||||
<span class="bg-red-100 text-red-800 text-sm font-medium me-2 ml-2 px-2.5 py-0.5 rounded dark:bg-red-900 dark:text-red-300">Draft</span>
|
||||
}
|
||||
</h3>
|
||||
<p class="text-neutral-600 mb-2">{{ listing.location.name ? listing.location.name : listing.location.county }}</p>
|
||||
<p class="text-neutral-600 mb-2">{{ listing.location ? (listing.location.name ? listing.location.name : listing.location.county) : '—' }}</p>
|
||||
<p class="text-xl font-bold mb-4">{{ listing.price | currency : 'USD' : 'symbol' : '1.0-0' }}</p>
|
||||
<div class="flex-grow"></div>
|
||||
<button [routerLink]="['/commercial-property', listing.slug || listing.id]" class="bg-success-500 text-white px-4 py-2 rounded-full w-full hover:bg-success-600 transition duration-300 mt-auto">
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
<td class="py-2 px-4">{{ $any(listing).title }}</td>
|
||||
<td class="py-2 px-4">{{ $any(listing).listingsCategory === 'commercialProperty' ? 'Commercial Property' :
|
||||
'Business' }}</td>
|
||||
<td class="py-2 px-4">{{ listing.location.name ? listing.location.name : listing.location.county }}, {{
|
||||
listing.location.state }}</td>
|
||||
<td class="py-2 px-4">${{ $any(listing).price.toLocaleString() }}</td>
|
||||
<td class="py-2 px-4">{{ listing.location ? (listing.location.name ? listing.location.name : listing.location.county) : '—' }}, {{
|
||||
listing.location?.state || '' }}</td>
|
||||
<td class="py-2 px-4">${{ $any(listing).price ? $any(listing).price.toLocaleString() : 'Price on Request' }}</td>
|
||||
<td class="py-2 px-4 flex">
|
||||
@if($any(listing).listingsCategory==='business'){
|
||||
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
|
||||
@@ -74,9 +74,9 @@
|
||||
<h2 class="text-xl font-semibold mb-2">{{ $any(listing).title }}</h2>
|
||||
<p class="text-gray-600 mb-2">Category: {{ $any(listing).listingsCategory === 'commercialProperty' ? 'Commercial
|
||||
Property' : 'Business' }}</p>
|
||||
<p class="text-gray-600 mb-2">Located in: {{ listing.location.name ? listing.location.name :
|
||||
listing.location.county }}, {{ listing.location.state }}</p>
|
||||
<p class="text-gray-600 mb-2">Price: ${{ $any(listing).price.toLocaleString() }}</p>
|
||||
<p class="text-gray-600 mb-2">Located in: {{ listing.location ? (listing.location.name ? listing.location.name :
|
||||
listing.location.county) : '—' }}, {{ listing.location?.state || '' }}</p>
|
||||
<p class="text-gray-600 mb-2">Price: ${{ $any(listing).price ? $any(listing).price.toLocaleString() : 'Price on Request' }}</p>
|
||||
<div class="flex justify-start">
|
||||
@if($any(listing).listingsCategory==='business'){
|
||||
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
<tr *ngFor="let listing of myListings" class="border-b">
|
||||
<td class="py-2 px-4">{{ listing.title }}</td>
|
||||
<td class="py-2 px-4">{{ listing.listingsCategory === 'commercialProperty' ? 'Commercial Property' : 'Business' }}</td>
|
||||
<td class="py-2 px-4">{{ listing.location.name ? listing.location.name : listing.location.county ? listing.location.county : this.selectOptions.getState(listing.location.state) }}</td>
|
||||
<td class="py-2 px-4">{{ listing.location ? (listing.location.name ? listing.location.name : listing.location.county ? listing.location.county : selectOptions.getState(listing.location.state)) : '—' }}</td>
|
||||
<td class="py-2 px-4">${{ listing.price ? listing.price.toLocaleString() : '' }}</td>
|
||||
<td class="py-2 px-4 flex justify-center">
|
||||
{{ listing.internalListingNumber ?? '—' }}
|
||||
@@ -132,7 +132,7 @@
|
||||
<h2 class="text-xl font-semibold mb-2">{{ listing.title }}</h2>
|
||||
<p class="text-gray-600 mb-2">Category: {{ listing.listingsCategory === 'commercialProperty' ? 'Commercial Property' : 'Business' }}</p>
|
||||
<p class="text-gray-600 mb-2">Located in: {{ listing.location?.name ? listing.location.name : listing.location?.county }} - {{ listing.location?.state }}</p>
|
||||
<p class="text-gray-600 mb-2">Price: ${{ listing.price.toLocaleString() }}</p>
|
||||
<p class="text-gray-600 mb-2">Price: ${{ listing.price ? listing.price.toLocaleString() : 'Price on Request' }}</p>
|
||||
<p class="text-gray-600 mb-2">Internal #: {{ listing.internalListingNumber ?? '—' }}</p>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-gray-600">Publication Status:</span>
|
||||
|
||||
@@ -25,6 +25,9 @@ services:
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./bizmatch-server/.env
|
||||
environment:
|
||||
# MUST match the container-side path of the volume mount below
|
||||
PICTURES_DIR: /app/dist/pictures
|
||||
depends_on:
|
||||
- postgres
|
||||
networks:
|
||||
|
||||
Reference in New Issue
Block a user