10 Commits

Author SHA1 Message Date
18d74cddff fix 2026-06-12 17:06:21 -05:00
537ba783d1 np sourcemaps in prod 2026-06-12 16:39:43 -05:00
093563a458 fix 2026-06-12 16:25:27 -05:00
e63c0f5998 bugfixes 2026-06-12 16:13:39 -05:00
2492720470 css fix 2026-06-12 15:54:40 -05:00
c4b509c1eb menu fix 2026-06-12 15:45:34 -05:00
577b63b7e7 image caching optimized 2026-06-12 15:06:55 -05:00
84c24b2e92 fix for image dir 2026-06-12 14:42:14 -05:00
b628bbb83f override 2026-05-28 11:23:48 -05:00
0e6b54b9e4 fix 2026-05-28 11:14:45 -05:00
21 changed files with 131 additions and 83 deletions

View 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';

View File

@@ -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) {

View File

@@ -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`);
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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 => {

View File

@@ -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)
*

View File

@@ -101,7 +101,7 @@
],
"optimization": true,
"extractLicenses": false,
"sourceMap": true,
"sourceMap": false,
"outputHashing": "all"
}
},

View File

@@ -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">

View File

@@ -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';
}

View File

@@ -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">

View File

@@ -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">

View File

@@ -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);

View File

@@ -1,4 +1,4 @@
:host ::ng-deep p {
:host ::ng-deep p:not(.disclaimer) {
display: block;
//margin-top: 1em;
//margin-bottom: 1em;

View File

@@ -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) {

View File

@@ -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">

View File

@@ -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"

View File

@@ -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>

View File

@@ -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: