Compare commits
1 Commits
master
...
timo-clean
| Author | SHA1 | Date | |
|---|---|---|---|
| fca746cef6 |
@@ -1,4 +0,0 @@
|
|||||||
// 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,15 +3,14 @@ import fs from 'fs-extra';
|
|||||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { Logger } from 'winston';
|
import { Logger } from 'winston';
|
||||||
import { PICTURES_DIR } from '../config/pictures.config';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FileService {
|
export class FileService {
|
||||||
constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) {
|
constructor(@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger) {
|
||||||
fs.ensureDirSync(`${PICTURES_DIR}`);
|
fs.ensureDirSync(`./pictures`);
|
||||||
fs.ensureDirSync(`${PICTURES_DIR}/profile`);
|
fs.ensureDirSync(`./pictures/profile`);
|
||||||
fs.ensureDirSync(`${PICTURES_DIR}/logo`);
|
fs.ensureDirSync(`./pictures/logo`);
|
||||||
fs.ensureDirSync(`${PICTURES_DIR}/property`);
|
fs.ensureDirSync(`./pictures/property`);
|
||||||
}
|
}
|
||||||
// ############
|
// ############
|
||||||
// Profile
|
// Profile
|
||||||
@@ -23,10 +22,10 @@ export class FileService {
|
|||||||
.avif({ quality }) // Verwende AVIF
|
.avif({ quality }) // Verwende AVIF
|
||||||
//.webp({ quality }) // Verwende Webp
|
//.webp({ quality }) // Verwende Webp
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
await sharp(output).toFile(`${PICTURES_DIR}/profile/${adjustedEmail}.avif`);
|
await sharp(output).toFile(`./pictures/profile/${adjustedEmail}.avif`);
|
||||||
}
|
}
|
||||||
hasProfile(adjustedEmail: string) {
|
hasProfile(adjustedEmail: string) {
|
||||||
return fs.existsSync(`${PICTURES_DIR}/profile/${adjustedEmail}.avif`);
|
return fs.existsSync(`./pictures/profile/${adjustedEmail}.avif`);
|
||||||
}
|
}
|
||||||
// ############
|
// ############
|
||||||
// Logo
|
// Logo
|
||||||
@@ -38,18 +37,18 @@ export class FileService {
|
|||||||
.avif({ quality }) // Verwende AVIF
|
.avif({ quality }) // Verwende AVIF
|
||||||
//.webp({ quality }) // Verwende Webp
|
//.webp({ quality }) // Verwende Webp
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
await sharp(output).toFile(`${PICTURES_DIR}/logo/${adjustedEmail}.avif`); // Ersetze Dateierweiterung
|
await sharp(output).toFile(`./pictures/logo/${adjustedEmail}.avif`); // Ersetze Dateierweiterung
|
||||||
// await fs.outputFile(`${PICTURES_DIR}/logo/${userId}`, file.buffer);
|
// await fs.outputFile(`./pictures/logo/${userId}`, file.buffer);
|
||||||
}
|
}
|
||||||
hasCompanyLogo(adjustedEmail: string) {
|
hasCompanyLogo(adjustedEmail: string) {
|
||||||
return fs.existsSync(`${PICTURES_DIR}/logo/${adjustedEmail}.avif`) ? true : false;
|
return fs.existsSync(`./pictures/logo/${adjustedEmail}.avif`) ? true : false;
|
||||||
}
|
}
|
||||||
// ############
|
// ############
|
||||||
// Property
|
// Property
|
||||||
// ############
|
// ############
|
||||||
async getPropertyImages(imagePath: string, serial: string): Promise<string[]> {
|
async getPropertyImages(imagePath: string, serial: string): Promise<string[]> {
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
const directory = `${PICTURES_DIR}/property/${imagePath}/${serial}`;
|
const directory = `./pictures/property/${imagePath}/${serial}`;
|
||||||
if (fs.existsSync(directory)) {
|
if (fs.existsSync(directory)) {
|
||||||
const files = await fs.readdir(directory);
|
const files = await fs.readdir(directory);
|
||||||
files.forEach(f => {
|
files.forEach(f => {
|
||||||
@@ -61,7 +60,7 @@ export class FileService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async hasPropertyImages(imagePath: string, serial: string): Promise<boolean> {
|
async hasPropertyImages(imagePath: string, serial: string): Promise<boolean> {
|
||||||
const directory = `${PICTURES_DIR}/property/${imagePath}/${serial}`;
|
const directory = `./pictures/property/${imagePath}/${serial}`;
|
||||||
if (fs.existsSync(directory)) {
|
if (fs.existsSync(directory)) {
|
||||||
const files = await fs.readdir(directory);
|
const files = await fs.readdir(directory);
|
||||||
return files.length > 0;
|
return files.length > 0;
|
||||||
@@ -70,7 +69,7 @@ export class FileService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async storePropertyPicture(file: Express.Multer.File, imagePath: string, serial: string): Promise<string> {
|
async storePropertyPicture(file: Express.Multer.File, imagePath: string, serial: string): Promise<string> {
|
||||||
const directory = `${PICTURES_DIR}/property/${imagePath}/${serial}`;
|
const directory = `./pictures/property/${imagePath}/${serial}`;
|
||||||
fs.ensureDirSync(`${directory}`);
|
fs.ensureDirSync(`${directory}`);
|
||||||
const imageName = await this.getNextImageName(directory);
|
const imageName = await this.getNextImageName(directory);
|
||||||
//await fs.outputFile(`${directory}/${imageName}`, file.buffer);
|
//await fs.outputFile(`${directory}/${imageName}`, file.buffer);
|
||||||
@@ -112,7 +111,7 @@ export class FileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deleteDirectoryIfExists(imagePath) {
|
deleteDirectoryIfExists(imagePath) {
|
||||||
const dirPath = `${PICTURES_DIR}/property/${imagePath}`;
|
const dirPath = `pictures/property/${imagePath}`;
|
||||||
try {
|
try {
|
||||||
const exists = fs.pathExistsSync();
|
const exists = fs.pathExistsSync();
|
||||||
if (exists) {
|
if (exists) {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Logger } from 'winston';
|
|||||||
import { FileService } from '../file/file.service';
|
import { FileService } from '../file/file.service';
|
||||||
import { CommercialPropertyService } from '../listings/commercial-property.service';
|
import { CommercialPropertyService } from '../listings/commercial-property.service';
|
||||||
import { SelectOptionsService } from '../select-options/select-options.service';
|
import { SelectOptionsService } from '../select-options/select-options.service';
|
||||||
import { PICTURES_DIR } from '../config/pictures.config';
|
|
||||||
|
|
||||||
@Controller('image')
|
@Controller('image')
|
||||||
export class ImageController {
|
export class ImageController {
|
||||||
@@ -29,7 +28,7 @@ export class ImageController {
|
|||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@Delete('propertyPicture/:imagePath/:serial/:imagename')
|
@Delete('propertyPicture/:imagePath/:serial/:imagename')
|
||||||
async deletePropertyImagesById(@Param('imagePath') imagePath: string, @Param('serial') serial: string, @Param('imagename') imagename: string): Promise<any> {
|
async deletePropertyImagesById(@Param('imagePath') imagePath: string, @Param('serial') serial: string, @Param('imagename') imagename: string): Promise<any> {
|
||||||
this.fileService.deleteImage(`${PICTURES_DIR}/property/${imagePath}/${serial}/${imagename}`);
|
this.fileService.deleteImage(`pictures/property/${imagePath}/${serial}/${imagename}`);
|
||||||
await this.listingService.deleteImage(imagePath, serial, imagename);
|
await this.listingService.deleteImage(imagePath, serial, imagename);
|
||||||
}
|
}
|
||||||
// ############
|
// ############
|
||||||
@@ -44,7 +43,7 @@ export class ImageController {
|
|||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@Delete('profile/:email/')
|
@Delete('profile/:email/')
|
||||||
async deleteProfileImagesById(@Param('email') email: string): Promise<any> {
|
async deleteProfileImagesById(@Param('email') email: string): Promise<any> {
|
||||||
this.fileService.deleteImage(`${PICTURES_DIR}/profile/${email}.avif`);
|
this.fileService.deleteImage(`pictures/profile/${email}.avif`);
|
||||||
}
|
}
|
||||||
// ############
|
// ############
|
||||||
// Logo
|
// Logo
|
||||||
@@ -58,6 +57,6 @@ export class ImageController {
|
|||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@Delete('logo/:email/')
|
@Delete('logo/:email/')
|
||||||
async deleteLogoImagesById(@Param('email') adjustedEmail: string): Promise<any> {
|
async deleteLogoImagesById(@Param('email') adjustedEmail: string): Promise<any> {
|
||||||
this.fileService.deleteImage(`${PICTURES_DIR}/logo/${adjustedEmail}.avif`);
|
this.fileService.deleteImage(`pictures/logo/${adjustedEmail}.avif`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { GeoService } from '../geo/geo.service';
|
|||||||
import { BusinessListing, BusinessListingSchema } from '../models/db.model';
|
import { BusinessListing, BusinessListingSchema } from '../models/db.model';
|
||||||
import { BusinessListingCriteria, JwtUser } from '../models/main.model';
|
import { BusinessListingCriteria, JwtUser } from '../models/main.model';
|
||||||
import { getDistanceQuery, splitName } from '../utils';
|
import { getDistanceQuery, splitName } from '../utils';
|
||||||
import { generateSlug, extractShortIdFromSlug, isSlug, isUUID } from '../utils/slug.utils';
|
import { generateSlug, extractShortIdFromSlug, isSlug } from '../utils/slug.utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BusinessListingService {
|
export class BusinessListingService {
|
||||||
@@ -271,9 +271,6 @@ export class BusinessListingService {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.logger.debug(`Detected as UUID: ${slugOrId}`);
|
this.logger.debug(`Detected as UUID: ${slugOrId}`);
|
||||||
if (!isUUID(slugOrId)) {
|
|
||||||
throw new BadRequestException(`Invalid identifier format: ${slugOrId}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.findBusinessesById(id, user);
|
return this.findBusinessesById(id, user);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { GeoService } from '../geo/geo.service';
|
|||||||
import { CommercialPropertyListing, CommercialPropertyListingSchema } from '../models/db.model';
|
import { CommercialPropertyListing, CommercialPropertyListingSchema } from '../models/db.model';
|
||||||
import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model';
|
import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model';
|
||||||
import { getDistanceQuery, splitName } from '../utils';
|
import { getDistanceQuery, splitName } from '../utils';
|
||||||
import { generateSlug, extractShortIdFromSlug, isSlug, isUUID } from '../utils/slug.utils';
|
import { generateSlug, extractShortIdFromSlug, isSlug } from '../utils/slug.utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommercialPropertyService {
|
export class CommercialPropertyService {
|
||||||
@@ -166,9 +166,6 @@ export class CommercialPropertyService {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.logger.debug(`Detected as UUID: ${slugOrId}`);
|
this.logger.debug(`Detected as UUID: ${slugOrId}`);
|
||||||
if (!isUUID(slugOrId)) {
|
|
||||||
throw new BadRequestException(`Invalid identifier format: ${slugOrId}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.findCommercialPropertiesById(id, user);
|
return this.findCommercialPropertiesById(id, user);
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Controller, Get, Inject, Param, Request, UseGuards, BadRequestException } from '@nestjs/common';
|
import { Controller, Get, Inject, Param, Request, UseGuards } from '@nestjs/common';
|
||||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||||
import { OptionalAuthGuard } from 'src/jwt-auth/optional-auth.guard';
|
import { OptionalAuthGuard } from 'src/jwt-auth/optional-auth.guard';
|
||||||
import { Logger } from 'winston';
|
import { Logger } from 'winston';
|
||||||
import { BusinessListingService } from './business-listing.service';
|
import { BusinessListingService } from './business-listing.service';
|
||||||
import { CommercialPropertyService } from './commercial-property.service';
|
import { CommercialPropertyService } from './commercial-property.service';
|
||||||
import { isUUID } from '../utils/slug.utils';
|
|
||||||
|
|
||||||
@Controller('listings/undefined')
|
@Controller('listings/undefined')
|
||||||
export class UnknownListingsController {
|
export class UnknownListingsController {
|
||||||
@@ -17,9 +16,6 @@ export class UnknownListingsController {
|
|||||||
@UseGuards(OptionalAuthGuard)
|
@UseGuards(OptionalAuthGuard)
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async findById(@Request() req, @Param('id') id: string): Promise<any> {
|
async findById(@Request() req, @Param('id') id: string): Promise<any> {
|
||||||
if (!isUUID(id)) {
|
|
||||||
throw new BadRequestException(`Invalid identifier format: ${id}`);
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
return await this.businessListingsService.findBusinessesById(id, req.user);
|
return await this.businessListingsService.findBusinessesById(id, req.user);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import express from 'express';
|
|||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
|
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { PICTURES_DIR } from './config/pictures.config';
|
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const server = express();
|
const server = express();
|
||||||
@@ -15,12 +14,7 @@ async function bootstrap() {
|
|||||||
app.useLogger(logger);
|
app.useLogger(logger);
|
||||||
//app.use('/bizmatch/payment/webhook', bodyParser.raw({ type: 'application/json' }));
|
//app.use('/bizmatch/payment/webhook', bodyParser.raw({ type: 'application/json' }));
|
||||||
// Serve static files from pictures directory
|
// Serve static files from pictures directory
|
||||||
app.use('/pictures', express.static(PICTURES_DIR));
|
app.use('/pictures', express.static('pictures'));
|
||||||
// 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');
|
app.setGlobalPrefix('bizmatch');
|
||||||
|
|
||||||
@@ -60,17 +54,6 @@ 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);
|
await app.listen(process.env.PORT || 3001);
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { OptionalAuthGuard } from 'src/jwt-auth/optional-auth.guard';
|
|||||||
import { User } from '../models/db.model';
|
import { User } from '../models/db.model';
|
||||||
import { JwtUser, Subscription, UserListingCriteria } from '../models/main.model';
|
import { JwtUser, Subscription, UserListingCriteria } from '../models/main.model';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
import { isUUID } from '../utils/slug.utils';
|
|
||||||
|
|
||||||
@Controller('user')
|
@Controller('user')
|
||||||
export class UserController {
|
export class UserController {
|
||||||
@@ -30,9 +29,6 @@ export class UserController {
|
|||||||
@UseGuards(OptionalAuthGuard)
|
@UseGuards(OptionalAuthGuard)
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async findById(@Param('id') id: string): Promise<User> {
|
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);
|
const user = await this.userService.getUserById(id);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
@@ -85,9 +81,6 @@ export class UserController {
|
|||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@Get('subscriptions/:id')
|
@Get('subscriptions/:id')
|
||||||
async findSubscriptionsById(@Param('id') id: string): Promise<Subscription[]> {
|
async findSubscriptionsById(@Param('id') id: string): Promise<Subscription[]> {
|
||||||
if (!isUUID(id)) {
|
|
||||||
throw new BadRequestException(`Invalid identifier format: ${id}`);
|
|
||||||
}
|
|
||||||
const subscriptions = [];
|
const subscriptions = [];
|
||||||
const user = await this.userService.getUserById(id);
|
const user = await this.userService.getUserById(id);
|
||||||
subscriptions.forEach(s => {
|
subscriptions.forEach(s => {
|
||||||
|
|||||||
@@ -107,13 +107,6 @@ export function isValidSlug(slug: string): boolean {
|
|||||||
return lastPart.length === 8 && /^[a-z0-9]{8}$/.test(lastPart);
|
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)
|
* Check if a parameter is a slug (vs a UUID)
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
],
|
],
|
||||||
"optimization": true,
|
"optimization": true,
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
"sourceMap": false,
|
"sourceMap": true,
|
||||||
"outputHashing": "all"
|
"outputHashing": "all"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,10 +29,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="relative">
|
|
||||||
<button type="button"
|
<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"
|
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" (click)="toggleUserMenu()">
|
id="user-menu-button" aria-expanded="false" [attr.data-dropdown-toggle]="user ? 'user-login' : 'user-unknown'">
|
||||||
<span class="sr-only">Open user menu</span>
|
<span class="sr-only">Open user menu</span>
|
||||||
@if(isProfessional || (authService.isAdmin() | async) && user?.hasProfile){
|
@if(isProfessional || (authService.isAdmin() | async) && user?.hasProfile){
|
||||||
<img class="w-8 h-8 rounded-full object-cover" src="{{ profileUrl }}"
|
<img class="w-8 h-8 rounded-full object-cover" src="{{ profileUrl }}"
|
||||||
@@ -44,8 +43,8 @@
|
|||||||
<!-- Dropdown menu -->
|
<!-- Dropdown menu -->
|
||||||
@if(user){
|
@if(user){
|
||||||
<div
|
<div
|
||||||
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"
|
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.hidden]="!userMenuVisible">
|
id="user-login">
|
||||||
<div class="px-4 py-3">
|
<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-900 dark:text-white">Welcome, {{ user.firstname }} </span>
|
||||||
<span class="block text-sm text-neutral-500 truncate dark:text-neutral-400">{{ user.email }}</span>
|
<span class="block text-sm text-neutral-500 truncate dark:text-neutral-400">{{ user.email }}</span>
|
||||||
@@ -123,8 +122,8 @@
|
|||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div
|
<div
|
||||||
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"
|
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.hidden]="!userMenuVisible">
|
id="user-unknown">
|
||||||
<ul class="py-2" aria-labelledby="user-menu-button">
|
<ul class="py-2" aria-labelledby="user-menu-button">
|
||||||
<li>
|
<li>
|
||||||
<a routerLink="/login" [queryParams]="{ mode: 'login' }"
|
<a routerLink="/login" [queryParams]="{ mode: 'login' }"
|
||||||
@@ -162,7 +161,6 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="items-center justify-between hidden w-full md:flex md:w-auto md:order-1" id="navbar-user">
|
<div class="items-center justify-between hidden w-full md:flex md:w-auto md:order-1" id="navbar-user">
|
||||||
<ul
|
<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">
|
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 { NavigationEnd, Router, RouterModule } from '@angular/router';
|
||||||
import { APP_ICONS } from '../../utils/fontawesome-icons';
|
import { APP_ICONS } from '../../utils/fontawesome-icons';
|
||||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||||
import { Collapse } from 'flowbite';
|
import { Collapse, Dropdown, initFlowbite } from 'flowbite';
|
||||||
import { filter, Observable, Subject, takeUntil } from 'rxjs';
|
import { filter, Observable, Subject, takeUntil } from 'rxjs';
|
||||||
|
|
||||||
import { SortByOptions, User } from '../../../../../bizmatch-server/src/models/db.model';
|
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;
|
faUserGear = APP_ICONS.faUserGear;
|
||||||
profileUrl: string;
|
profileUrl: string;
|
||||||
env = environment;
|
env = environment;
|
||||||
|
private filterDropdown: Dropdown | null = null;
|
||||||
isMobile: boolean = false;
|
isMobile: boolean = false;
|
||||||
userMenuVisible = false;
|
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
prompt: string;
|
prompt: string;
|
||||||
private platformId = inject(PLATFORM_ID);
|
private platformId = inject(PLATFORM_ID);
|
||||||
@@ -109,9 +109,15 @@ export class HeaderComponent implements OnInit, OnDestroy, AfterViewInit {
|
|||||||
this.profileUrl = photoUrl;
|
this.profileUrl = photoUrl;
|
||||||
});
|
});
|
||||||
|
|
||||||
// User Updates
|
// User Updates - re-initialize Flowbite when user state changes
|
||||||
|
// This ensures the dropdown bindings are updated when the dropdown target changes
|
||||||
this.userService.currentUser.pipe(untilDestroyed(this)).subscribe(u => {
|
this.userService.currentUser.pipe(untilDestroyed(this)).subscribe(u => {
|
||||||
|
const previousUser = this.user;
|
||||||
this.user = u;
|
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
|
// Router Events
|
||||||
@@ -229,7 +235,15 @@ export class HeaderComponent implements OnInit, OnDestroy, AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
closeDropdown() {
|
closeDropdown() {
|
||||||
this.userMenuVisible = false;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
closeMobileMenu() {
|
closeMobileMenu() {
|
||||||
@@ -272,10 +286,6 @@ export class HeaderComponent implements OnInit, OnDestroy, AfterViewInit {
|
|||||||
this.sortDropdownVisible = !this.sortDropdownVisible;
|
this.sortDropdownVisible = !this.sortDropdownVisible;
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleUserMenu() {
|
|
||||||
this.userMenuVisible = !this.userMenuVisible;
|
|
||||||
}
|
|
||||||
|
|
||||||
get isProfessional() {
|
get isProfessional() {
|
||||||
return this.user?.customerType === 'professional';
|
return this.user?.customerType === 'professional';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Karte hinzufügen, wenn Straße vorhanden ist -->
|
<!-- 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>
|
<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: 300px" leaflet [leafletOptions]="mapOptions" [leafletLayers]="mapLayers" [leafletCenter]="mapCenter" [leafletZoom]="mapZoom"></div> -->
|
||||||
<div style="height: 400px" leaflet [leafletOptions]="mapOptions" [leafletLayers]="mapLayers"
|
<div style="height: 400px" leaflet [leafletOptions]="mapOptions" [leafletLayers]="mapLayers"
|
||||||
@@ -135,16 +135,6 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
@@ -213,8 +203,8 @@
|
|||||||
}
|
}
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="font-medium">Location:</span>
|
<span class="font-medium">Location:</span>
|
||||||
<span>{{ related.location ? (related.location.name || related.location.county) : '—' }}, {{
|
<span>{{ related.location.name || related.location.county }}, {{
|
||||||
selectOptions.getState(related.location?.state) }}</span>
|
selectOptions.getState(related.location.state) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NgOptimizedImage } from '@angular/common';
|
|
||||||
import { ChangeDetectorRef, Component } from '@angular/core';
|
import { ChangeDetectorRef, Component } from '@angular/core';
|
||||||
|
import { NgOptimizedImage } from '@angular/common';
|
||||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||||
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
|
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
|
||||||
import { LeafletModule } from '@bluehalo/ngx-leaflet';
|
import { LeafletModule } from '@bluehalo/ngx-leaflet';
|
||||||
@@ -7,13 +7,13 @@ import { lastValueFrom } from 'rxjs';
|
|||||||
import { BusinessListing, EventTypeEnum, ShareByEMail, User } from '../../../../../../bizmatch-server/src/models/db.model';
|
import { BusinessListing, EventTypeEnum, ShareByEMail, User } from '../../../../../../bizmatch-server/src/models/db.model';
|
||||||
import { KeycloakUser, MailInfo } from '../../../../../../bizmatch-server/src/models/main.model';
|
import { KeycloakUser, MailInfo } from '../../../../../../bizmatch-server/src/models/main.model';
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
|
|
||||||
import { EMailService } from '../../../components/email/email.service';
|
import { EMailService } from '../../../components/email/email.service';
|
||||||
import { MessageService } from '../../../components/message/message.service';
|
import { MessageService } from '../../../components/message/message.service';
|
||||||
import { ValidatedInputComponent } from '../../../components/validated-input/validated-input.component';
|
import { ValidatedInputComponent } from '../../../components/validated-input/validated-input.component';
|
||||||
import { ValidatedNgSelectComponent } from '../../../components/validated-ng-select/validated-ng-select.component';
|
import { ValidatedNgSelectComponent } from '../../../components/validated-ng-select/validated-ng-select.component';
|
||||||
import { ValidatedTextareaComponent } from '../../../components/validated-textarea/validated-textarea.component';
|
import { ValidatedTextareaComponent } from '../../../components/validated-textarea/validated-textarea.component';
|
||||||
import { ValidationMessagesService } from '../../../components/validation-messages.service';
|
import { ValidationMessagesService } from '../../../components/validation-messages.service';
|
||||||
|
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
|
||||||
import { AuditService } from '../../../services/audit.service';
|
import { AuditService } from '../../../services/audit.service';
|
||||||
import { GeoService } from '../../../services/geo.service';
|
import { GeoService } from '../../../services/geo.service';
|
||||||
import { HistoryService } from '../../../services/history.service';
|
import { HistoryService } from '../../../services/history.service';
|
||||||
@@ -26,13 +26,13 @@ import { SharedModule } from '../../../shared/shared/shared.module';
|
|||||||
import { createMailInfo, map2User } from '../../../utils/utils';
|
import { createMailInfo, map2User } from '../../../utils/utils';
|
||||||
// Import für Leaflet
|
// Import für Leaflet
|
||||||
// Note: Leaflet requires browser environment - protected by isBrowser checks in base class
|
// 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 dayjs from 'dayjs';
|
||||||
import { circle, Control, DomEvent, DomUtil, icon, Icon, latLng, LatLngBounds, Marker, polygon, Polygon, tileLayer } from 'leaflet';
|
|
||||||
import { provideShareButtonsOptions, SharerMethods, withConfig } from 'ngx-sharebuttons';
|
|
||||||
import { ShareButton } from 'ngx-sharebuttons/button';
|
|
||||||
import { shareIcons } from 'ngx-sharebuttons/icons';
|
|
||||||
import { AuthService } from '../../../services/auth.service';
|
import { AuthService } from '../../../services/auth.service';
|
||||||
import { BaseDetailsComponent } from '../base-details.component';
|
import { BaseDetailsComponent } from '../base-details.component';
|
||||||
|
import { ShareButton } from 'ngx-sharebuttons/button';
|
||||||
|
import { provideShareButtonsOptions, SharerMethods, withConfig } from 'ngx-sharebuttons';
|
||||||
|
import { shareIcons } from 'ngx-sharebuttons/icons';
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-details-business-listing',
|
selector: 'app-details-business-listing',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -376,6 +376,7 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent {
|
|||||||
{ label: 'Support & Training', value: this.listing.supportAndTraining },
|
{ label: 'Support & Training', value: this.listing.supportAndTraining },
|
||||||
{ label: 'Reason for Sale', value: this.listing.reasonForSale },
|
{ label: 'Reason for Sale', value: this.listing.reasonForSale },
|
||||||
{ label: 'Broker licensing', value: this.listing.brokerLicencing },
|
{ label: 'Broker licensing', value: this.listing.brokerLicencing },
|
||||||
|
{ label: 'Listed since', value: `${this.dateInserted()} - ${this.getDaysListed()} days` },
|
||||||
{
|
{
|
||||||
label: 'Listing by',
|
label: 'Listing by',
|
||||||
value: null, // Wird nicht verwendet
|
value: null, // Wird nicht verwendet
|
||||||
|
|||||||
@@ -100,7 +100,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Karte hinzufügen, wenn Straße vorhanden ist -->
|
<!-- 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>
|
<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: 300px" leaflet [leafletOptions]="mapOptions" [leafletLayers]="mapLayers" [leafletCenter]="mapCenter" [leafletZoom]="mapZoom"></div> -->
|
||||||
<div style="height: 400px" leaflet [leafletOptions]="mapOptions" [leafletLayers]="mapLayers"
|
<div style="height: 400px" leaflet [leafletOptions]="mapOptions" [leafletLayers]="mapLayers"
|
||||||
@@ -148,16 +148,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -221,8 +211,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="font-medium">Location:</span>
|
<span class="font-medium">Location:</span>
|
||||||
<span>{{ related.location ? (related.location.name || related.location.county) : '—' }}, {{
|
<span>{{ related.location.name || related.location.county }}, {{
|
||||||
selectOptions.getState(related.location?.state) }}</span>
|
selectOptions.getState(related.location.state) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import { NgOptimizedImage } from '@angular/common';
|
|
||||||
import { ChangeDetectorRef, Component, NgZone } from '@angular/core';
|
import { ChangeDetectorRef, Component, NgZone } from '@angular/core';
|
||||||
|
import { NgOptimizedImage } from '@angular/common';
|
||||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { LeafletModule } from '@bluehalo/ngx-leaflet';
|
import { LeafletModule } from '@bluehalo/ngx-leaflet';
|
||||||
|
import { APP_ICONS } from '../../../utils/fontawesome-icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { GALLERY_CONFIG, GalleryConfig, GalleryModule, ImageItem } from 'ng-gallery';
|
import { GALLERY_CONFIG, GalleryConfig, GalleryModule, ImageItem } from 'ng-gallery';
|
||||||
import { provideShareButtonsOptions, SharerMethods, withConfig } from 'ngx-sharebuttons';
|
|
||||||
import { ShareButton } from 'ngx-sharebuttons/button';
|
|
||||||
import { shareIcons } from 'ngx-sharebuttons/icons';
|
|
||||||
import { lastValueFrom } from 'rxjs';
|
import { lastValueFrom } from 'rxjs';
|
||||||
import { CommercialPropertyListing, EventTypeEnum, ShareByEMail, User } from '../../../../../../bizmatch-server/src/models/db.model';
|
import { CommercialPropertyListing, EventTypeEnum, ShareByEMail, User } from '../../../../../../bizmatch-server/src/models/db.model';
|
||||||
import { CommercialPropertyListingCriteria, ErrorResponse, KeycloakUser, MailInfo } from '../../../../../../bizmatch-server/src/models/main.model';
|
import { CommercialPropertyListingCriteria, ErrorResponse, KeycloakUser, MailInfo } from '../../../../../../bizmatch-server/src/models/main.model';
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
|
|
||||||
import { EMailService } from '../../../components/email/email.service';
|
import { EMailService } from '../../../components/email/email.service';
|
||||||
import { MessageService } from '../../../components/message/message.service';
|
import { MessageService } from '../../../components/message/message.service';
|
||||||
import { ValidatedInputComponent } from '../../../components/validated-input/validated-input.component';
|
import { ValidatedInputComponent } from '../../../components/validated-input/validated-input.component';
|
||||||
@@ -29,9 +26,12 @@ import { SelectOptionsService } from '../../../services/select-options.service';
|
|||||||
import { SeoService } from '../../../services/seo.service';
|
import { SeoService } from '../../../services/seo.service';
|
||||||
import { UserService } from '../../../services/user.service';
|
import { UserService } from '../../../services/user.service';
|
||||||
import { SharedModule } from '../../../shared/shared/shared.module';
|
import { SharedModule } from '../../../shared/shared/shared.module';
|
||||||
import { APP_ICONS } from '../../../utils/fontawesome-icons';
|
|
||||||
import { createMailInfo, map2User } from '../../../utils/utils';
|
import { createMailInfo, map2User } from '../../../utils/utils';
|
||||||
import { BaseDetailsComponent } from '../base-details.component';
|
import { BaseDetailsComponent } from '../base-details.component';
|
||||||
|
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
|
||||||
|
import { ShareButton } from 'ngx-sharebuttons/button';
|
||||||
|
import { provideShareButtonsOptions, SharerMethods, withConfig } from 'ngx-sharebuttons';
|
||||||
|
import { shareIcons } from 'ngx-sharebuttons/icons';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-details-commercial-property-listing',
|
selector: 'app-details-commercial-property-listing',
|
||||||
@@ -145,6 +145,7 @@ export class DetailsCommercialPropertyListingComponent extends BaseDetailsCompon
|
|||||||
{ label: 'Located in', value: this.selectOptions.getState(this.listing.location.state) },
|
{ label: 'Located in', value: this.selectOptions.getState(this.listing.location.state) },
|
||||||
{ label: this.listing.location.name ? 'City' : 'County', value: this.listing.location.name ? this.listing.location.name : this.listing.location.county },
|
{ label: this.listing.location.name ? 'City' : 'County', value: this.listing.location.name ? this.listing.location.name : this.listing.location.county },
|
||||||
{ label: 'Asking Price:', value: `$${this.listing.price?.toLocaleString()}` },
|
{ label: 'Asking Price:', value: `$${this.listing.price?.toLocaleString()}` },
|
||||||
|
{ label: 'Listed since', value: `${this.dateInserted()} - ${this.getDaysListed()} days` },
|
||||||
{
|
{
|
||||||
label: 'Listing by',
|
label: 'Listing by',
|
||||||
value: null, // Wird nicht verwendet
|
value: null, // Wird nicht verwendet
|
||||||
@@ -161,7 +162,7 @@ export class DetailsCommercialPropertyListingComponent extends BaseDetailsCompon
|
|||||||
}
|
}
|
||||||
if (this.listing.imageOrder && Array.isArray(this.listing.imageOrder)) {
|
if (this.listing.imageOrder && Array.isArray(this.listing.imageOrder)) {
|
||||||
this.listing.imageOrder.forEach(image => {
|
this.listing.imageOrder.forEach(image => {
|
||||||
const imageURL = `${this.env.imageBaseUrl}/pictures/property/${this.listing.imagePath}/${this.listing.serialId}/${image}?_ts=${new Date(this.listing.updated).getTime()}`;
|
const imageURL = `${this.env.imageBaseUrl}/pictures/property/${this.listing.imagePath}/${this.listing.serialId}/${image}`;
|
||||||
this.images.push(new ImageItem({ src: imageURL, thumb: imageURL }));
|
this.images.push(new ImageItem({ src: imageURL, thumb: imageURL }));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -185,7 +186,7 @@ export class DetailsCommercialPropertyListingComponent extends BaseDetailsCompon
|
|||||||
yearBuilt: (this.listing as any).yearBuilt,
|
yearBuilt: (this.listing as any).yearBuilt,
|
||||||
images: this.listing.imageOrder?.length > 0
|
images: this.listing.imageOrder?.length > 0
|
||||||
? this.listing.imageOrder.map(img =>
|
? this.listing.imageOrder.map(img =>
|
||||||
`${this.env.imageBaseUrl}/pictures/property/${this.listing.imagePath}/${this.listing.serialId}/${img}?_ts=${new Date(this.listing.updated).getTime()}`)
|
`${this.env.imageBaseUrl}/pictures/property/${this.listing.imagePath}/${this.listing.serialId}/${img}`)
|
||||||
: []
|
: []
|
||||||
};
|
};
|
||||||
this.seoService.updateCommercialPropertyMeta(propertyData);
|
this.seoService.updateCommercialPropertyMeta(propertyData);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
:host ::ng-deep p:not(.disclaimer) {
|
:host ::ng-deep p {
|
||||||
display: block;
|
display: block;
|
||||||
//margin-top: 1em;
|
//margin-top: 1em;
|
||||||
//margin-bottom: 1em;
|
//margin-bottom: 1em;
|
||||||
|
|||||||
@@ -136,44 +136,13 @@ export class HomeComponent {
|
|||||||
// FAQ content is preserved in component for future use when FAQ section is made visible
|
// FAQ content is preserved in component for future use when FAQ section is made visible
|
||||||
const organizationSchema = this.seoService.generateOrganizationSchema();
|
const organizationSchema = this.seoService.generateOrganizationSchema();
|
||||||
|
|
||||||
// Add HowTo schema for buying a business
|
|
||||||
const howToSchema = this.seoService.generateHowToSchema({
|
|
||||||
name: 'How to Buy a Business on BizMatch',
|
|
||||||
description: 'Step-by-step guide to finding and purchasing your ideal business through BizMatch marketplace',
|
|
||||||
totalTime: 'PT45M',
|
|
||||||
steps: [
|
|
||||||
{
|
|
||||||
name: 'Browse Business Listings',
|
|
||||||
text: 'Search through thousands of verified business listings using our advanced filters. Filter by industry, location, price range, revenue, and more to find businesses that match your criteria.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Review Business Details',
|
|
||||||
text: 'Examine the business financials, including annual revenue, cash flow, asking price, and years established. Read the detailed business description and view photos of the operation.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Contact the Seller',
|
|
||||||
text: 'Use our secure messaging system to contact the seller or business broker directly. Request additional information, financial documents, or schedule a site visit to see the business in person.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Conduct Due Diligence',
|
|
||||||
text: 'Review all financial statements, tax returns, lease agreements, and legal documents. Verify the business information, inspect the physical location, and consult with legal and financial advisors.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Make an Offer',
|
|
||||||
text: 'Submit a formal offer based on your valuation and due diligence findings. Negotiate terms including purchase price, payment structure, transition period, and any contingencies.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Close the Transaction',
|
|
||||||
text: 'Work with attorneys and escrow services to finalize all legal documents, transfer ownership, and complete the purchase. The seller will transfer assets, train you on operations, and help with the transition.'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add SearchBox schema for Sitelinks Search
|
// Add SearchBox schema for Sitelinks Search
|
||||||
const searchBoxSchema = this.seoService.generateSearchBoxSchema();
|
const searchBoxSchema = this.seoService.generateSearchBoxSchema();
|
||||||
|
|
||||||
// Inject schemas (FAQ schema excluded - content not visible to users)
|
// Inject schemas (FAQ schema excluded - content not visible to users)
|
||||||
this.seoService.injectMultipleSchemas([organizationSchema, howToSchema, searchBoxSchema]);
|
this.seoService.injectMultipleSchemas([organizationSchema, searchBoxSchema]);
|
||||||
|
|
||||||
// Clear all filters and sort options on initial load
|
// Clear all filters and sort options on initial load
|
||||||
this.filterStateService.resetCriteria('businessListings');
|
this.filterStateService.resetCriteria('businessListings');
|
||||||
|
|||||||
@@ -95,7 +95,7 @@
|
|||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span
|
<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">
|
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>
|
</span>
|
||||||
|
|
||||||
@if (getListingBadge(listing); as badge) {
|
@if (getListingBadge(listing); as badge) {
|
||||||
@@ -127,8 +127,8 @@
|
|||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-neutral-600 mb-2">
|
<p class="text-sm text-neutral-600 mb-2">
|
||||||
<strong>Location:</strong> {{ listing.location ? (listing.location.name ? listing.location.name : listing.location.county ?
|
<strong>Location:</strong> {{ listing.location.name ? listing.location.name : listing.location.county ?
|
||||||
listing.location.county : selectOptions.getState(listing.location.state)) : '—' }}
|
listing.location.county : this.selectOptions.getState(listing.location.state) }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-neutral-600 mb-4"><strong>Years established:</strong> {{ listing.established }}</p>
|
<p class="text-sm text-neutral-600 mb-4"><strong>Years established:</strong> {{ listing.established }}</p>
|
||||||
@if(listing.imageName) {
|
@if(listing.imageName) {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@if (listing.imageOrder?.length>0){
|
@if (listing.imageOrder?.length>0){
|
||||||
<img [appLazyLoad]="env.imageBaseUrl + '/pictures/property/' + listing.imagePath + '/' + listing.serialId + '/' + listing.imageOrder[0] + '?_ts=' + listing.updated"
|
<img [appLazyLoad]="env.imageBaseUrl + '/pictures/property/' + listing.imagePath + '/' + listing.serialId + '/' + listing.imageOrder[0]"
|
||||||
[alt]="altText.generatePropertyListingAlt(listing)"
|
[alt]="altText.generatePropertyListingAlt(listing)"
|
||||||
class="w-full h-48 object-cover"
|
class="w-full h-48 object-cover"
|
||||||
width="400"
|
width="400"
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
><i [class]="selectOptions.getIconAndTextColorTypeOfCommercials(listing.type)" class="mr-1"></i> {{ selectOptions.getCommercialProperty(listing.type) }}</span
|
><i [class]="selectOptions.getIconAndTextColorTypeOfCommercials(listing.type)" class="mr-1"></i> {{ selectOptions.getCommercialProperty(listing.type) }}</span
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between my-2">
|
<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">
|
<p class="text-sm text-neutral-600 mb-4">
|
||||||
<strong>{{ getDaysListed(listing) }} days listed</strong>
|
<strong>{{ getDaysListed(listing) }} days listed</strong>
|
||||||
</p>
|
</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>
|
<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>
|
</h3>
|
||||||
<p class="text-neutral-600 mb-2">{{ listing.location ? (listing.location.name ? listing.location.name : listing.location.county) : '—' }}</p>
|
<p class="text-neutral-600 mb-2">{{ 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>
|
<p class="text-xl font-bold mb-4">{{ listing.price | currency : 'USD' : 'symbol' : '1.0-0' }}</p>
|
||||||
<div class="flex-grow"></div>
|
<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">
|
<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).title }}</td>
|
||||||
<td class="py-2 px-4">{{ $any(listing).listingsCategory === 'commercialProperty' ? 'Commercial Property' :
|
<td class="py-2 px-4">{{ $any(listing).listingsCategory === 'commercialProperty' ? 'Commercial Property' :
|
||||||
'Business' }}</td>
|
'Business' }}</td>
|
||||||
<td class="py-2 px-4">{{ listing.location ? (listing.location.name ? listing.location.name : listing.location.county) : '—' }}, {{
|
<td class="py-2 px-4">{{ listing.location.name ? listing.location.name : listing.location.county }}, {{
|
||||||
listing.location?.state || '' }}</td>
|
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">${{ $any(listing).price.toLocaleString() }}</td>
|
||||||
<td class="py-2 px-4 flex">
|
<td class="py-2 px-4 flex">
|
||||||
@if($any(listing).listingsCategory==='business'){
|
@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"
|
<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>
|
<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
|
<p class="text-gray-600 mb-2">Category: {{ $any(listing).listingsCategory === 'commercialProperty' ? 'Commercial
|
||||||
Property' : 'Business' }}</p>
|
Property' : 'Business' }}</p>
|
||||||
<p class="text-gray-600 mb-2">Located in: {{ listing.location ? (listing.location.name ? listing.location.name :
|
<p class="text-gray-600 mb-2">Located in: {{ listing.location.name ? listing.location.name :
|
||||||
listing.location.county) : '—' }}, {{ listing.location?.state || '' }}</p>
|
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>
|
<p class="text-gray-600 mb-2">Price: ${{ $any(listing).price.toLocaleString() }}</p>
|
||||||
<div class="flex justify-start">
|
<div class="flex justify-start">
|
||||||
@if($any(listing).listingsCategory==='business'){
|
@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"
|
<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">
|
<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.title }}</td>
|
||||||
<td class="py-2 px-4">{{ listing.listingsCategory === 'commercialProperty' ? 'Commercial Property' : 'Business' }}</td>
|
<td class="py-2 px-4">{{ listing.listingsCategory === 'commercialProperty' ? 'Commercial Property' : 'Business' }}</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.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.price ? listing.price.toLocaleString() : '' }}</td>
|
<td class="py-2 px-4">${{ listing.price ? listing.price.toLocaleString() : '' }}</td>
|
||||||
<td class="py-2 px-4 flex justify-center">
|
<td class="py-2 px-4 flex justify-center">
|
||||||
{{ listing.internalListingNumber ?? '—' }}
|
{{ listing.internalListingNumber ?? '—' }}
|
||||||
@@ -132,7 +132,7 @@
|
|||||||
<h2 class="text-xl font-semibold mb-2">{{ listing.title }}</h2>
|
<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">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">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 ? listing.price.toLocaleString() : 'Price on Request' }}</p>
|
<p class="text-gray-600 mb-2">Price: ${{ listing.price.toLocaleString() }}</p>
|
||||||
<p class="text-gray-600 mb-2">Internal #: {{ listing.internalListingNumber ?? '—' }}</p>
|
<p class="text-gray-600 mb-2">Internal #: {{ listing.internalListingNumber ?? '—' }}</p>
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<span class="text-gray-600">Publication Status:</span>
|
<span class="text-gray-600">Publication Status:</span>
|
||||||
|
|||||||
@@ -66,39 +66,7 @@
|
|||||||
<!-- Note: Organization and WebSite schemas are now injected dynamically by SeoService -->
|
<!-- Note: Organization and WebSite schemas are now injected dynamically by SeoService -->
|
||||||
<!-- with more complete data (telephone, foundingDate, knowsAbout, dual search actions) -->
|
<!-- with more complete data (telephone, foundingDate, knowsAbout, dual search actions) -->
|
||||||
|
|
||||||
<!-- LocalBusiness Schema for local visibility -->
|
|
||||||
<script type="application/ld+json">
|
|
||||||
{
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "LocalBusiness",
|
|
||||||
"@id": "https://www.bizmatch.net/#localbusiness",
|
|
||||||
"name": "BizMatch",
|
|
||||||
"description": "Business brokerage and commercial real estate marketplace connecting buyers and sellers across the United States.",
|
|
||||||
"url": "https://www.bizmatch.net",
|
|
||||||
"logo": "https://www.bizmatch.net/assets/images/bizmatch-logo.png",
|
|
||||||
"image": "https://www.bizmatch.net/assets/images/bizmatch-logo.png",
|
|
||||||
"priceRange": "$$",
|
|
||||||
"address": {
|
|
||||||
"@type": "PostalAddress",
|
|
||||||
"streetAddress": "1001 Blucher Street",
|
|
||||||
"addressLocality": "Corpus Christi",
|
|
||||||
"addressRegion": "TX",
|
|
||||||
"postalCode": "78401",
|
|
||||||
"addressCountry": "US"
|
|
||||||
},
|
|
||||||
"geo": {
|
|
||||||
"@type": "GeoCoordinates",
|
|
||||||
"latitude": "27.7876",
|
|
||||||
"longitude": "-97.3940"
|
|
||||||
},
|
|
||||||
"areaServed": {
|
|
||||||
"@type": "Country",
|
|
||||||
"name": "United States"
|
|
||||||
},
|
|
||||||
"serviceType": ["Business Brokerage", "Commercial Real Estate", "Business For Sale Listings"],
|
|
||||||
"knowsAbout": ["Business Sales", "Commercial Properties", "Franchise Opportunities", "Business Valuation"]
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="flex flex-col min-h-screen">
|
<body class="flex flex-col min-h-screen">
|
||||||
|
|||||||
@@ -1,40 +1,39 @@
|
|||||||
services:
|
services:
|
||||||
# --- FRONTEND (SSR) ---
|
# --- FRONTEND ---
|
||||||
bizmatch-ssr:
|
bizmatch-ssr:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: . # Pfad zum Angular Ordner
|
||||||
dockerfile: bizmatch/Dockerfile
|
dockerfile: bizmatch/Dockerfile
|
||||||
|
image: bizmatch-ssr
|
||||||
container_name: bizmatch-ssr
|
container_name: bizmatch-ssr
|
||||||
|
extra_hosts:
|
||||||
|
- "localhost:host-gateway"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# In der Produktion brauchen wir keine Ports nach außen (Caddy regelt das intern)
|
ports:
|
||||||
networks:
|
- '4200:4000' # Extern 4200 -> Intern 4000 (SSR)
|
||||||
- bizmatch
|
|
||||||
volumes:
|
|
||||||
- ./bizmatch-server/pictures:/app/pictures
|
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
# WICHTIG: Die URL, unter der das SSR-Frontend die API intern erreicht
|
volumes:
|
||||||
API_INTERNAL_URL: http://bizmatch-app:3001
|
- ./bizmatch-server/pictures:/app/pictures
|
||||||
|
|
||||||
# --- BACKEND ---
|
# --- BACKEND ---
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
context: ./bizmatch-server
|
context: ./bizmatch-server # Pfad zum NestJS Ordner
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
image: bizmatch-server:latest
|
||||||
container_name: bizmatch-app
|
container_name: bizmatch-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '3001:3001'
|
||||||
env_file:
|
env_file:
|
||||||
- ./bizmatch-server/.env
|
- ./bizmatch-server/.env # Pfad zur .env Datei
|
||||||
environment:
|
|
||||||
# MUST match the container-side path of the volume mount below
|
|
||||||
PICTURES_DIR: /app/dist/pictures
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
networks:
|
networks:
|
||||||
- bizmatch
|
- bizmatch
|
||||||
volumes:
|
# WICHTIG: Kein Volume Mapping für node_modules im Prod-Modus!
|
||||||
# Das Backend braucht ebenfalls Zugriff auf den Bilder-Ordner zum Speichern!
|
# Das Image bringt alles fertig mit.
|
||||||
- ./bizmatch-server/pictures:/app/dist/pictures
|
|
||||||
|
|
||||||
# --- DATABASE ---
|
# --- DATABASE ---
|
||||||
postgres:
|
postgres:
|
||||||
@@ -45,12 +44,19 @@ services:
|
|||||||
- bizmatch-db-data:/var/lib/postgresql/data
|
- bizmatch-db-data:/var/lib/postgresql/data
|
||||||
env_file:
|
env_file:
|
||||||
- ./bizmatch-server/.env
|
- ./bizmatch-server/.env
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
ports:
|
||||||
|
- '5434:5432'
|
||||||
networks:
|
networks:
|
||||||
- bizmatch
|
- bizmatch
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
bizmatch-db-data:
|
bizmatch-db-data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
bizmatch:
|
bizmatch:
|
||||||
external: true # Wir nutzen das gleiche Netzwerk wie für Caddy
|
external: false # Oder true, falls du es manuell erstellt hast
|
||||||
Reference in New Issue
Block a user