Compare commits
2 Commits
a6a37f8f1a
...
1874d5f4ed
| Author | SHA1 | Date |
|---|---|---|
|
|
1874d5f4ed | |
|
|
adeefb199c |
|
|
@ -13,7 +13,20 @@
|
||||||
"Bash(sudo chown:*)",
|
"Bash(sudo chown:*)",
|
||||||
"Bash(chmod:*)",
|
"Bash(chmod:*)",
|
||||||
"Bash(npm audit:*)",
|
"Bash(npm audit:*)",
|
||||||
"Bash(npm view:*)"
|
"Bash(npm view:*)",
|
||||||
|
"Bash(npm run build:ssr:*)",
|
||||||
|
"Bash(pkill:*)",
|
||||||
|
"WebSearch",
|
||||||
|
"Bash(lsof:*)",
|
||||||
|
"Bash(xargs:*)",
|
||||||
|
"Bash(curl:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(cat:*)",
|
||||||
|
"Bash(NODE_ENV=development npm run build:ssr:*)",
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"WebFetch(domain:angular.dev)",
|
||||||
|
"Bash(killall:*)",
|
||||||
|
"Bash(echo:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Docker Code Sync Guide
|
||||||
|
|
||||||
|
If you have made changes to the backend code and they don't seem to take effect (even though the files on disk are updated), it's because the Docker container is running from a pre-compiled `dist/` directory.
|
||||||
|
|
||||||
|
### The Problem
|
||||||
|
The `bizmatch-app` container compiles the TypeScript code *only once* when the container starts. It does not automatically watch for changes and recompile while running.
|
||||||
|
|
||||||
|
### The Solution
|
||||||
|
You must restart or recreate the container to trigger a new build.
|
||||||
|
|
||||||
|
**Option 1: Quick Restart (Recommended)**
|
||||||
|
Run this in the `bizmatch-server` directory:
|
||||||
|
```bash
|
||||||
|
docker-compose restart app
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 2: Force Rebuild (If changes aren't picked up)**
|
||||||
|
If a simple restart doesn't work, use this to force a fresh build:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d --build app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Summary for Other Laptops
|
||||||
|
1. **Pull** the latest changes from Git.
|
||||||
|
2. **Execute** `docker-compose restart app`.
|
||||||
|
3. **Verify** the logs for the new `WARN` debug messages.
|
||||||
|
.
|
||||||
|
|
@ -5,7 +5,7 @@ import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||||
import { Logger } from 'winston';
|
import { Logger } from 'winston';
|
||||||
import { ZodError } from 'zod';
|
import { ZodError } from 'zod';
|
||||||
import * as schema from '../drizzle/schema';
|
import * as schema from '../drizzle/schema';
|
||||||
import { businesses_json, PG_CONNECTION, users_json } from '../drizzle/schema';
|
import { businesses_json, PG_CONNECTION } from '../drizzle/schema';
|
||||||
import { GeoService } from '../geo/geo.service';
|
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';
|
||||||
|
|
@ -22,23 +22,26 @@ export class BusinessListingService {
|
||||||
|
|
||||||
private getWhereConditions(criteria: BusinessListingCriteria, user: JwtUser): SQL[] {
|
private getWhereConditions(criteria: BusinessListingCriteria, user: JwtUser): SQL[] {
|
||||||
const whereConditions: SQL[] = [];
|
const whereConditions: SQL[] = [];
|
||||||
|
this.logger.info('getWhereConditions start', { criteria: JSON.stringify(criteria) });
|
||||||
|
|
||||||
if (criteria.city && criteria.searchType === 'exact') {
|
if (criteria.city && criteria.searchType === 'exact') {
|
||||||
whereConditions.push(sql`(${businesses_json.data}->'location'->>'name') ILIKE ${criteria.city.name}`);
|
whereConditions.push(sql`(${businesses_json.data}->'location'->>'name') ILIKE ${criteria.city.name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) {
|
if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) {
|
||||||
|
this.logger.debug('Adding radius search filter', { city: criteria.city.name, radius: criteria.radius });
|
||||||
const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name);
|
const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name);
|
||||||
whereConditions.push(sql`${getDistanceQuery(businesses_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`);
|
whereConditions.push(sql`(${getDistanceQuery(businesses_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius})`);
|
||||||
}
|
|
||||||
if (criteria.types && Array.isArray(criteria.types) && criteria.types.length > 0) {
|
|
||||||
const validTypes = criteria.types.filter(t => t !== null && t !== undefined && t !== '');
|
|
||||||
if (validTypes.length > 0) {
|
|
||||||
whereConditions.push(inArray(sql`${businesses_json.data}->>'type'`, validTypes));
|
|
||||||
}
|
}
|
||||||
|
if (criteria.types && criteria.types.length > 0) {
|
||||||
|
this.logger.warn('Adding business category filter', { types: criteria.types });
|
||||||
|
// Use explicit SQL with IN for robust JSONB comparison
|
||||||
|
const typeValues = criteria.types.map(t => sql`${t}`);
|
||||||
|
whereConditions.push(sql`((${businesses_json.data}->>'type') IN (${sql.join(typeValues, sql`, `)}))`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.state) {
|
if (criteria.state) {
|
||||||
|
this.logger.debug('Adding state filter', { state: criteria.state });
|
||||||
whereConditions.push(sql`(${businesses_json.data}->'location'->>'state') = ${criteria.state}`);
|
whereConditions.push(sql`(${businesses_json.data}->'location'->>'state') = ${criteria.state}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,27 +108,30 @@ export class BusinessListingService {
|
||||||
if (criteria.title && criteria.title.trim() !== '') {
|
if (criteria.title && criteria.title.trim() !== '') {
|
||||||
const searchTerm = `%${criteria.title.trim()}%`;
|
const searchTerm = `%${criteria.title.trim()}%`;
|
||||||
whereConditions.push(
|
whereConditions.push(
|
||||||
or(
|
sql`((${businesses_json.data}->>'title') ILIKE ${searchTerm} OR (${businesses_json.data}->>'description') ILIKE ${searchTerm})`
|
||||||
sql`(${businesses_json.data}->>'title') ILIKE ${searchTerm}`,
|
|
||||||
sql`(${businesses_json.data}->>'description') ILIKE ${searchTerm}`
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (criteria.brokerName) {
|
if (criteria.brokerName) {
|
||||||
const { firstname, lastname } = splitName(criteria.brokerName);
|
const { firstname, lastname } = splitName(criteria.brokerName);
|
||||||
if (firstname === lastname) {
|
if (firstname === lastname) {
|
||||||
whereConditions.push(sql`(${users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${users_json.data}->>'lastname') ILIKE ${`%${lastname}%`}`);
|
whereConditions.push(
|
||||||
|
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
whereConditions.push(sql`(${users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} AND (${users_json.data}->>'lastname') ILIKE ${`%${lastname}%`}`);
|
whereConditions.push(
|
||||||
|
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} AND (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (criteria.email) {
|
if (criteria.email) {
|
||||||
whereConditions.push(eq(users_json.email, criteria.email));
|
whereConditions.push(eq(schema.users_json.email, criteria.email));
|
||||||
}
|
}
|
||||||
if (user?.role !== 'admin') {
|
if (user?.role !== 'admin') {
|
||||||
whereConditions.push(or(eq(businesses_json.email, user?.email), sql`(${businesses_json.data}->>'draft')::boolean IS NOT TRUE`));
|
whereConditions.push(
|
||||||
|
sql`((${businesses_json.email} = ${user?.email || null}) OR (${businesses_json.data}->>'draft')::boolean IS NOT TRUE)`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
whereConditions.push(and(sql`(${users_json.data}->>'customerType') = 'professional'`, sql`(${users_json.data}->>'customerSubType') = 'broker'`));
|
this.logger.warn('whereConditions count', { count: whereConditions.length });
|
||||||
return whereConditions;
|
return whereConditions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,24 +141,21 @@ export class BusinessListingService {
|
||||||
const query = this.conn
|
const query = this.conn
|
||||||
.select({
|
.select({
|
||||||
business: businesses_json,
|
business: businesses_json,
|
||||||
brokerFirstName: sql`${users_json.data}->>'firstname'`.as('brokerFirstName'),
|
brokerFirstName: sql`${schema.users_json.data}->>'firstname'`.as('brokerFirstName'),
|
||||||
brokerLastName: sql`${users_json.data}->>'lastname'`.as('brokerLastName'),
|
brokerLastName: sql`${schema.users_json.data}->>'lastname'`.as('brokerLastName'),
|
||||||
})
|
})
|
||||||
.from(businesses_json)
|
.from(businesses_json)
|
||||||
.leftJoin(users_json, eq(businesses_json.email, users_json.email));
|
.leftJoin(schema.users_json, eq(businesses_json.email, schema.users_json.email));
|
||||||
|
|
||||||
const whereConditions = this.getWhereConditions(criteria, user);
|
const whereConditions = this.getWhereConditions(criteria, user);
|
||||||
|
|
||||||
// Uncomment for debugging filter issues:
|
this.logger.warn('Filter Criteria:', { criteria: JSON.stringify(criteria) });
|
||||||
// this.logger.info('Filter Criteria:', { criteria });
|
|
||||||
// this.logger.info('Where Conditions Count:', { count: whereConditions.length });
|
|
||||||
|
|
||||||
if (whereConditions.length > 0) {
|
if (whereConditions.length > 0) {
|
||||||
const whereClause = and(...whereConditions);
|
const whereClause = sql.join(whereConditions, sql` AND `);
|
||||||
query.where(whereClause);
|
query.where(sql`(${whereClause})`);
|
||||||
|
|
||||||
// Uncomment for debugging SQL queries:
|
this.logger.warn('Generated SQL:', { sql: query.toSQL().sql, params: query.toSQL().params });
|
||||||
// this.logger.info('Generated SQL:', { sql: query.toSQL() });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sortierung
|
// Sortierung
|
||||||
|
|
@ -228,13 +231,13 @@ export class BusinessListingService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBusinessListingsCount(criteria: BusinessListingCriteria, user: JwtUser): Promise<number> {
|
async getBusinessListingsCount(criteria: BusinessListingCriteria, user: JwtUser): Promise<number> {
|
||||||
const countQuery = this.conn.select({ value: count() }).from(businesses_json).leftJoin(users_json, eq(businesses_json.email, users_json.email));
|
const countQuery = this.conn.select({ value: count() }).from(businesses_json).leftJoin(schema.users_json, eq(businesses_json.email, schema.users_json.email));
|
||||||
|
|
||||||
const whereConditions = this.getWhereConditions(criteria, user);
|
const whereConditions = this.getWhereConditions(criteria, user);
|
||||||
|
|
||||||
if (whereConditions.length > 0) {
|
if (whereConditions.length > 0) {
|
||||||
const whereClause = and(...whereConditions);
|
const whereClause = sql.join(whereConditions, sql` AND `);
|
||||||
countQuery.where(whereClause);
|
countQuery.where(sql`(${whereClause})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [{ value: totalCount }] = await countQuery;
|
const [{ value: totalCount }] = await countQuery;
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { FileService } from '../file/file.service';
|
||||||
import { GeoService } from '../geo/geo.service';
|
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 } from '../utils';
|
import { getDistanceQuery, splitName } from '../utils';
|
||||||
import { generateSlug, extractShortIdFromSlug, isSlug } from '../utils/slug.utils';
|
import { generateSlug, extractShortIdFromSlug, isSlug } from '../utils/slug.utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|
@ -32,7 +32,10 @@ export class CommercialPropertyService {
|
||||||
whereConditions.push(sql`${getDistanceQuery(commercials_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`);
|
whereConditions.push(sql`${getDistanceQuery(commercials_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`);
|
||||||
}
|
}
|
||||||
if (criteria.types && criteria.types.length > 0) {
|
if (criteria.types && criteria.types.length > 0) {
|
||||||
whereConditions.push(inArray(sql`${commercials_json.data}->>'type'`, criteria.types));
|
this.logger.warn('Adding commercial property type filter', { types: criteria.types });
|
||||||
|
// Use explicit SQL with IN for robust JSONB comparison
|
||||||
|
const typeValues = criteria.types.map(t => sql`${t}`);
|
||||||
|
whereConditions.push(sql`((${commercials_json.data}->>'type') IN (${sql.join(typeValues, sql`, `)}))`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.state) {
|
if (criteria.state) {
|
||||||
|
|
@ -48,12 +51,32 @@ export class CommercialPropertyService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (criteria.title) {
|
if (criteria.title) {
|
||||||
whereConditions.push(sql`(${commercials_json.data}->>'title') ILIKE ${`%${criteria.title}%`} OR (${commercials_json.data}->>'description') ILIKE ${`%${criteria.title}%`}`);
|
whereConditions.push(
|
||||||
|
sql`((${commercials_json.data}->>'title') ILIKE ${`%${criteria.title}%`} OR (${commercials_json.data}->>'description') ILIKE ${`%${criteria.title}%`})`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (criteria.brokerName) {
|
||||||
|
const { firstname, lastname } = splitName(criteria.brokerName);
|
||||||
|
if (firstname === lastname) {
|
||||||
|
// Single word: search either first OR last name
|
||||||
|
whereConditions.push(
|
||||||
|
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Multiple words: search both first AND last name
|
||||||
|
whereConditions.push(
|
||||||
|
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} AND (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (user?.role !== 'admin') {
|
if (user?.role !== 'admin') {
|
||||||
whereConditions.push(or(eq(commercials_json.email, user?.email), sql`(${commercials_json.data}->>'draft')::boolean IS NOT TRUE`));
|
whereConditions.push(
|
||||||
|
sql`((${commercials_json.email} = ${user?.email || null}) OR (${commercials_json.data}->>'draft')::boolean IS NOT TRUE)`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// whereConditions.push(and(eq(schema.users.customerType, 'professional')));
|
this.logger.warn('whereConditions count', { count: whereConditions.length });
|
||||||
return whereConditions;
|
return whereConditions;
|
||||||
}
|
}
|
||||||
// #### Find by criteria ########################################
|
// #### Find by criteria ########################################
|
||||||
|
|
@ -63,9 +86,13 @@ export class CommercialPropertyService {
|
||||||
const query = this.conn.select({ commercial: commercials_json }).from(commercials_json).leftJoin(schema.users_json, eq(commercials_json.email, schema.users_json.email));
|
const query = this.conn.select({ commercial: commercials_json }).from(commercials_json).leftJoin(schema.users_json, eq(commercials_json.email, schema.users_json.email));
|
||||||
const whereConditions = this.getWhereConditions(criteria, user);
|
const whereConditions = this.getWhereConditions(criteria, user);
|
||||||
|
|
||||||
|
this.logger.warn('Filter Criteria:', { criteria: JSON.stringify(criteria) });
|
||||||
|
|
||||||
if (whereConditions.length > 0) {
|
if (whereConditions.length > 0) {
|
||||||
const whereClause = and(...whereConditions);
|
const whereClause = sql.join(whereConditions, sql` AND `);
|
||||||
query.where(whereClause);
|
query.where(sql`(${whereClause})`);
|
||||||
|
|
||||||
|
this.logger.warn('Generated SQL:', { sql: query.toSQL().sql, params: query.toSQL().params });
|
||||||
}
|
}
|
||||||
// Sortierung
|
// Sortierung
|
||||||
switch (criteria.sortBy) {
|
switch (criteria.sortBy) {
|
||||||
|
|
@ -103,8 +130,8 @@ export class CommercialPropertyService {
|
||||||
const whereConditions = this.getWhereConditions(criteria, user);
|
const whereConditions = this.getWhereConditions(criteria, user);
|
||||||
|
|
||||||
if (whereConditions.length > 0) {
|
if (whereConditions.length > 0) {
|
||||||
const whereClause = and(...whereConditions);
|
const whereClause = sql.join(whereConditions, sql` AND `);
|
||||||
countQuery.where(whereClause);
|
countQuery.where(sql`(${whereClause})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [{ value: totalCount }] = await countQuery;
|
const [{ value: totalCount }] = await countQuery;
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ export const CustomerTypeEnum = z.enum(['buyer', 'seller', 'professional']);
|
||||||
export const SubscriptionTypeEnum = z.enum(['free', 'professional', 'broker']);
|
export const SubscriptionTypeEnum = z.enum(['free', 'professional', 'broker']);
|
||||||
export const CustomerSubTypeEnum = z.enum(['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']);
|
export const CustomerSubTypeEnum = z.enum(['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']);
|
||||||
export const ListingsCategoryEnum = z.enum(['commercialProperty', 'business']);
|
export const ListingsCategoryEnum = z.enum(['commercialProperty', 'business']);
|
||||||
|
export const ShareCategoryEnum = z.enum(['commercialProperty', 'business', 'user']);
|
||||||
export const ZodEventTypeEnum = z.enum(['view', 'print', 'email', 'facebook', 'x', 'linkedin', 'contact', 'favorite', 'emailus', 'pricing']);
|
export const ZodEventTypeEnum = z.enum(['view', 'print', 'email', 'facebook', 'x', 'linkedin', 'contact', 'favorite', 'emailus', 'pricing']);
|
||||||
export type EventTypeEnum = z.infer<typeof ZodEventTypeEnum>;
|
export type EventTypeEnum = z.infer<typeof ZodEventTypeEnum>;
|
||||||
const PropertyTypeEnum = z.enum(['retail', 'land', 'industrial', 'office', 'mixedUse', 'multifamily', 'uncategorized']);
|
const PropertyTypeEnum = z.enum(['retail', 'land', 'industrial', 'office', 'mixedUse', 'multifamily', 'uncategorized']);
|
||||||
|
|
@ -186,6 +187,7 @@ export const UserSchema = z
|
||||||
updated: z.date().optional().nullable(),
|
updated: z.date().optional().nullable(),
|
||||||
subscriptionId: z.string().optional().nullable(),
|
subscriptionId: z.string().optional().nullable(),
|
||||||
subscriptionPlan: SubscriptionTypeEnum.optional().nullable(),
|
subscriptionPlan: SubscriptionTypeEnum.optional().nullable(),
|
||||||
|
favoritesForUser: z.array(z.string()),
|
||||||
showInDirectory: z.boolean(),
|
showInDirectory: z.boolean(),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
|
|
@ -369,7 +371,7 @@ export const ShareByEMailSchema = z.object({
|
||||||
listingTitle: z.string().optional().nullable(),
|
listingTitle: z.string().optional().nullable(),
|
||||||
url: z.string().url({ message: 'Invalid URL format' }).optional().nullable(),
|
url: z.string().url({ message: 'Invalid URL format' }).optional().nullable(),
|
||||||
id: z.string().optional().nullable(),
|
id: z.string().optional().nullable(),
|
||||||
type: ListingsCategoryEnum,
|
type: ShareCategoryEnum,
|
||||||
});
|
});
|
||||||
export type ShareByEMail = z.infer<typeof ShareByEMailSchema>;
|
export type ShareByEMail = z.infer<typeof ShareByEMailSchema>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ export interface CommercialPropertyListingCriteria extends ListCriteria {
|
||||||
minPrice: number;
|
minPrice: number;
|
||||||
maxPrice: number;
|
maxPrice: number;
|
||||||
title: string;
|
title: string;
|
||||||
|
brokerName: string;
|
||||||
criteriaType: 'commercialPropertyListings';
|
criteriaType: 'commercialPropertyListings';
|
||||||
}
|
}
|
||||||
export interface UserListingCriteria extends ListCriteria {
|
export interface UserListingCriteria extends ListCriteria {
|
||||||
|
|
@ -358,6 +359,7 @@ export function createDefaultUser(email: string, firstname: string, lastname: st
|
||||||
updated: new Date(),
|
updated: new Date(),
|
||||||
subscriptionId: null,
|
subscriptionId: null,
|
||||||
subscriptionPlan: subscriptionPlan,
|
subscriptionPlan: subscriptionPlan,
|
||||||
|
favoritesForUser: [],
|
||||||
showInDirectory: false,
|
showInDirectory: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@
|
||||||
<span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
<span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||||
Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</span>
|
||||||
|
<span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||||
|
Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@if(criteria.criteriaType==='commercialPropertyListings') {
|
@if(criteria.criteriaType==='commercialPropertyListings') {
|
||||||
<div class="grid grid-cols-1 gap-6">
|
<div class="grid grid-cols-1 gap-6">
|
||||||
|
|
@ -113,6 +116,17 @@
|
||||||
placeholder="Select categories"
|
placeholder="Select categories"
|
||||||
></ng-select>
|
></ng-select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="brokername" class="block mb-2 text-sm font-medium text-neutral-900">Broker Name / Company Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="brokername"
|
||||||
|
[ngModel]="criteria.brokerName"
|
||||||
|
(ngModelChange)="updateCriteria({ brokerName: $event })"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
|
||||||
|
placeholder="e.g. Brokers Invest"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
@ -146,6 +160,9 @@
|
||||||
<span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
<span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||||
Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
</span>
|
</span>
|
||||||
|
<span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||||
|
Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@if(criteria.criteriaType==='commercialPropertyListings') {
|
@if(criteria.criteriaType==='commercialPropertyListings') {
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
|
@ -217,6 +234,17 @@
|
||||||
placeholder="e.g. Office Space"
|
placeholder="e.g. Office Space"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="brokername-embedded" class="block mb-2 text-sm font-medium text-neutral-900">Broker Name / Company Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="brokername-embedded"
|
||||||
|
[ngModel]="criteria.brokerName"
|
||||||
|
(ngModelChange)="updateCriteria({ brokerName: $event })"
|
||||||
|
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
|
||||||
|
placeholder="e.g. Brokers Invest"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,9 @@ export class SearchModalCommercialComponent implements OnInit, OnDestroy {
|
||||||
case 'title':
|
case 'title':
|
||||||
updates.title = null;
|
updates.title = null;
|
||||||
break;
|
break;
|
||||||
|
case 'brokerName':
|
||||||
|
updates.brokerName = null;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateCriteria(updates);
|
this.updateCriteria(updates);
|
||||||
|
|
@ -280,6 +283,7 @@ export class SearchModalCommercialComponent implements OnInit, OnDestroy {
|
||||||
minPrice: null,
|
minPrice: null,
|
||||||
maxPrice: null,
|
maxPrice: null,
|
||||||
title: null,
|
title: null,
|
||||||
|
brokerName: null,
|
||||||
prompt: null,
|
prompt: null,
|
||||||
page: 1,
|
page: 1,
|
||||||
start: 0,
|
start: 0,
|
||||||
|
|
@ -290,7 +294,15 @@ export class SearchModalCommercialComponent implements OnInit, OnDestroy {
|
||||||
hasActiveFilters(): boolean {
|
hasActiveFilters(): boolean {
|
||||||
if (!this.criteria) return false;
|
if (!this.criteria) return false;
|
||||||
|
|
||||||
return !!(this.criteria.state || this.criteria.city || this.criteria.minPrice || this.criteria.maxPrice || this.criteria.types?.length || this.criteria.title);
|
return !!(
|
||||||
|
this.criteria.state ||
|
||||||
|
this.criteria.city ||
|
||||||
|
this.criteria.minPrice ||
|
||||||
|
this.criteria.maxPrice ||
|
||||||
|
this.criteria.types?.length ||
|
||||||
|
this.criteria.title ||
|
||||||
|
this.criteria.brokerName
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
trackByFn(item: GeoResult): any {
|
trackByFn(item: GeoResult): any {
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,68 @@
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<p class="p-4 text-neutral-700">{{ user.description }}</p>
|
<p class="p-4 text-neutral-700">{{ user.description }}</p>
|
||||||
|
|
||||||
|
<!-- Like and Share Action Buttons -->
|
||||||
|
<div class="py-4 px-4 print:hidden">
|
||||||
|
@if(user && keycloakUser && (user?.email===keycloakUser?.email || (authService.isAdmin() | async))){
|
||||||
|
<div class="inline">
|
||||||
|
<button class="share share-edit text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||||
|
[routerLink]="['/account', user.id]">
|
||||||
|
<i class="fa-regular fa-pen-to-square"></i>
|
||||||
|
<span class="ml-2">Edit</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if(keycloakUser && keycloakUser.email !== user.email){
|
||||||
|
<div class="inline">
|
||||||
|
<button class="share share-save text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||||
|
(click)="save()" [disabled]="isAlreadyFavorite()">
|
||||||
|
<i class="fa-regular fa-heart"></i>
|
||||||
|
@if(isAlreadyFavorite()){
|
||||||
|
<span class="ml-2">Saved ...</span>
|
||||||
|
}@else {
|
||||||
|
<span class="ml-2">Save</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<share-button button="print" showText="true" (click)="createEvent('print')"></share-button>
|
||||||
|
|
||||||
|
<div class="inline">
|
||||||
|
<button class="share share-email text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||||
|
(click)="showShareByEMail()">
|
||||||
|
<i class="fa-solid fa-envelope"></i>
|
||||||
|
<span class="ml-2">Email</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline">
|
||||||
|
<button type="button"
|
||||||
|
class="share share-facebook text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||||
|
(click)="shareToFacebook()">
|
||||||
|
<i class="fab fa-facebook"></i>
|
||||||
|
<span class="ml-2">Facebook</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline">
|
||||||
|
<button type="button"
|
||||||
|
class="share share-twitter text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||||
|
(click)="shareToTwitter()">
|
||||||
|
<i class="fab fa-x-twitter"></i>
|
||||||
|
<span class="ml-2">X</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="inline">
|
||||||
|
<button type="button"
|
||||||
|
class="share share-linkedin text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||||
|
(click)="shareToLinkedIn()">
|
||||||
|
<i class="fab fa-linkedin"></i>
|
||||||
|
<span class="ml-2">LinkedIn</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Company Profile -->
|
<!-- Company Profile -->
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<h2 class="text-xl font-semibold mb-4">Company Profile</h2>
|
<h2 class="text-xl font-semibold mb-4">Company Profile</h2>
|
||||||
|
|
@ -142,8 +204,6 @@
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
} @if( user?.email===keycloakUser?.email || (authService.isAdmin() | async)){
|
|
||||||
<button class="mt-4 bg-primary-500 text-white px-4 py-2 rounded hover:bg-primary-600" [routerLink]="['/account', user.id]">Edit</button>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,14 @@ 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 { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { BusinessListing, CommercialPropertyListing, User } from '../../../../../../bizmatch-server/src/models/db.model';
|
import { BusinessListing, CommercialPropertyListing, User, ShareByEMail, EventTypeEnum } from '../../../../../../bizmatch-server/src/models/db.model';
|
||||||
import { KeycloakUser, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
|
import { KeycloakUser, emailToDirName } 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 { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
|
||||||
import { AuthService } from '../../../services/auth.service';
|
import { AuthService } from '../../../services/auth.service';
|
||||||
|
import { AuditService } from '../../../services/audit.service';
|
||||||
|
import { EMailService } from '../../../components/email/email.service';
|
||||||
|
import { MessageService } from '../../../components/message/message.service';
|
||||||
import { HistoryService } from '../../../services/history.service';
|
import { HistoryService } from '../../../services/history.service';
|
||||||
import { ImageService } from '../../../services/image.service';
|
import { ImageService } from '../../../services/image.service';
|
||||||
import { ListingsService } from '../../../services/listings.service';
|
import { ListingsService } from '../../../services/listings.service';
|
||||||
|
|
@ -15,11 +18,12 @@ import { SelectOptionsService } from '../../../services/select-options.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 { formatPhoneNumber, map2User } from '../../../utils/utils';
|
import { formatPhoneNumber, map2User } from '../../../utils/utils';
|
||||||
|
import { ShareButton } from 'ngx-sharebuttons/button';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-details-user',
|
selector: 'app-details-user',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [SharedModule, BreadcrumbsComponent, NgOptimizedImage],
|
imports: [SharedModule, BreadcrumbsComponent, NgOptimizedImage, ShareButton],
|
||||||
templateUrl: './details-user.component.html',
|
templateUrl: './details-user.component.html',
|
||||||
styleUrl: '../details.scss',
|
styleUrl: '../details.scss',
|
||||||
})
|
})
|
||||||
|
|
@ -47,12 +51,14 @@ export class DetailsUserComponent {
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
private listingsService: ListingsService,
|
private listingsService: ListingsService,
|
||||||
|
|
||||||
public selectOptions: SelectOptionsService,
|
public selectOptions: SelectOptionsService,
|
||||||
private sanitizer: DomSanitizer,
|
private sanitizer: DomSanitizer,
|
||||||
private imageService: ImageService,
|
private imageService: ImageService,
|
||||||
public historyService: HistoryService,
|
public historyService: HistoryService,
|
||||||
public authService: AuthService,
|
public authService: AuthService,
|
||||||
|
private auditService: AuditService,
|
||||||
|
private emailService: EMailService,
|
||||||
|
private messageService: MessageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
|
|
@ -66,4 +72,82 @@ export class DetailsUserComponent {
|
||||||
this.companyOverview = this.sanitizer.bypassSecurityTrustHtml(this.user.companyOverview ? this.user.companyOverview : '');
|
this.companyOverview = this.sanitizer.bypassSecurityTrustHtml(this.user.companyOverview ? this.user.companyOverview : '');
|
||||||
this.offeredServices = this.sanitizer.bypassSecurityTrustHtml(this.user.offeredServices ? this.user.offeredServices : '');
|
this.offeredServices = this.sanitizer.bypassSecurityTrustHtml(this.user.offeredServices ? this.user.offeredServices : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add professional to favorites
|
||||||
|
*/
|
||||||
|
async save() {
|
||||||
|
await this.listingsService.addToFavorites(this.user.id, 'user');
|
||||||
|
if (!this.user.favoritesForUser) {
|
||||||
|
this.user.favoritesForUser = [];
|
||||||
|
}
|
||||||
|
this.user.favoritesForUser.push(this.keycloakUser.email);
|
||||||
|
this.auditService.createEvent(this.user.id, 'favorite', this.keycloakUser?.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if already in favorites
|
||||||
|
*/
|
||||||
|
isAlreadyFavorite(): boolean {
|
||||||
|
if (!this.keycloakUser?.email || !this.user?.favoritesForUser) return false;
|
||||||
|
return this.user.favoritesForUser.includes(this.keycloakUser.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show email sharing modal
|
||||||
|
*/
|
||||||
|
async showShareByEMail() {
|
||||||
|
const result = await this.emailService.showShareByEMail({
|
||||||
|
yourEmail: this.keycloakUser ? this.keycloakUser.email : '',
|
||||||
|
yourName: this.keycloakUser ? `${this.keycloakUser.firstName} ${this.keycloakUser.lastName}` : '',
|
||||||
|
recipientEmail: '',
|
||||||
|
url: environment.mailinfoUrl,
|
||||||
|
listingTitle: `${this.user.firstname} ${this.user.lastname} - ${this.user.companyName}`,
|
||||||
|
id: this.user.id,
|
||||||
|
type: 'user',
|
||||||
|
});
|
||||||
|
if (result) {
|
||||||
|
this.auditService.createEvent(this.user.id, 'email', this.keycloakUser?.email, <ShareByEMail>result);
|
||||||
|
this.messageService.addMessage({
|
||||||
|
severity: 'success',
|
||||||
|
text: 'Your Email has been sent',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create audit event
|
||||||
|
*/
|
||||||
|
createEvent(eventType: EventTypeEnum) {
|
||||||
|
this.auditService.createEvent(this.user.id, eventType, this.keycloakUser?.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share to Facebook
|
||||||
|
*/
|
||||||
|
shareToFacebook() {
|
||||||
|
const url = encodeURIComponent(window.location.href);
|
||||||
|
window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`, '_blank', 'width=600,height=400');
|
||||||
|
this.createEvent('facebook');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share to Twitter/X
|
||||||
|
*/
|
||||||
|
shareToTwitter() {
|
||||||
|
const url = encodeURIComponent(window.location.href);
|
||||||
|
const text = encodeURIComponent(`Check out ${this.user.firstname} ${this.user.lastname} - ${this.user.companyName}`);
|
||||||
|
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank', 'width=600,height=400');
|
||||||
|
this.createEvent('x');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share to LinkedIn
|
||||||
|
*/
|
||||||
|
shareToLinkedIn() {
|
||||||
|
const url = encodeURIComponent(window.location.href);
|
||||||
|
window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${url}`, '_blank', 'width=600,height=400');
|
||||||
|
this.createEvent('linkedin');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,22 @@
|
||||||
<!-- Professional Cards -->
|
<!-- Professional Cards -->
|
||||||
@for (user of users; track user) {
|
@for (user of users; track user) {
|
||||||
<div
|
<div
|
||||||
class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6 flex flex-col justify-between hover:shadow-2xl transition-all duration-300 hover:scale-[1.02]">
|
class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6 flex flex-col justify-between hover:shadow-2xl transition-all duration-300 hover:scale-[1.02] group relative">
|
||||||
|
<!-- Quick Actions Overlay -->
|
||||||
|
<div class="absolute top-4 right-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-20">
|
||||||
|
@if(currentUser) {
|
||||||
|
<button class="bg-white rounded-full p-2 shadow-lg transition-colors"
|
||||||
|
[class.bg-red-50]="isFavorite(user)"
|
||||||
|
[title]="isFavorite(user) ? 'Remove from favorites' : 'Save to favorites'"
|
||||||
|
(click)="toggleFavorite($event, user)">
|
||||||
|
<i [class]="isFavorite(user) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button type="button" class="bg-white rounded-full p-2 shadow-lg hover:bg-blue-50 transition-colors"
|
||||||
|
title="Share professional" (click)="shareProfessional($event, user)">
|
||||||
|
<i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="flex items-start space-x-4">
|
<div class="flex items-start space-x-4">
|
||||||
@if(user.hasProfile){
|
@if(user.hasProfile){
|
||||||
<img src="{{ env.imageBaseUrl }}/pictures/profile/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}"
|
<img src="{{ env.imageBaseUrl }}/pictures/profile/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}"
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
import { UntilDestroy } from '@ngneat/until-destroy';
|
import { UntilDestroy } from '@ngneat/until-destroy';
|
||||||
import { Subject, takeUntil } from 'rxjs';
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
import { BusinessListing, SortByOptions, User } from '../../../../../../bizmatch-server/src/models/db.model';
|
import { BusinessListing, SortByOptions, User } from '../../../../../../bizmatch-server/src/models/db.model';
|
||||||
import { LISTINGS_PER_PAGE, ListingType, UserListingCriteria, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
|
import { LISTINGS_PER_PAGE, ListingType, UserListingCriteria, emailToDirName, KeycloakUser } 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 { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
|
||||||
import { CustomerSubTypeComponent } from '../../../components/customer-sub-type/customer-sub-type.component';
|
import { CustomerSubTypeComponent } from '../../../components/customer-sub-type/customer-sub-type.component';
|
||||||
|
|
@ -20,7 +20,8 @@ import { ListingsService } from '../../../services/listings.service';
|
||||||
import { SearchService } from '../../../services/search.service';
|
import { SearchService } from '../../../services/search.service';
|
||||||
import { SelectOptionsService } from '../../../services/select-options.service';
|
import { SelectOptionsService } from '../../../services/select-options.service';
|
||||||
import { UserService } from '../../../services/user.service';
|
import { UserService } from '../../../services/user.service';
|
||||||
import { assignProperties, resetUserListingCriteria } from '../../../utils/utils';
|
import { AuthService } from '../../../services/auth.service';
|
||||||
|
import { assignProperties, resetUserListingCriteria, map2User } from '../../../utils/utils';
|
||||||
@UntilDestroy()
|
@UntilDestroy()
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-broker-listings',
|
selector: 'app-broker-listings',
|
||||||
|
|
@ -56,6 +57,7 @@ export class BrokerListingsComponent implements OnInit, OnDestroy {
|
||||||
page = 1;
|
page = 1;
|
||||||
pageCount = 1;
|
pageCount = 1;
|
||||||
sortBy: SortByOptions = null; // Neu: Separate Property
|
sortBy: SortByOptions = null; // Neu: Separate Property
|
||||||
|
currentUser: KeycloakUser | null = null; // Current logged-in user
|
||||||
constructor(
|
constructor(
|
||||||
public altText: AltTextService,
|
public altText: AltTextService,
|
||||||
public selectOptions: SelectOptionsService,
|
public selectOptions: SelectOptionsService,
|
||||||
|
|
@ -70,6 +72,7 @@ export class BrokerListingsComponent implements OnInit, OnDestroy {
|
||||||
private modalService: ModalService,
|
private modalService: ModalService,
|
||||||
private criteriaChangeService: CriteriaChangeService,
|
private criteriaChangeService: CriteriaChangeService,
|
||||||
private filterStateService: FilterStateService,
|
private filterStateService: FilterStateService,
|
||||||
|
private authService: AuthService,
|
||||||
) {
|
) {
|
||||||
this.loadSortBy();
|
this.loadSortBy();
|
||||||
}
|
}
|
||||||
|
|
@ -77,7 +80,11 @@ export class BrokerListingsComponent implements OnInit, OnDestroy {
|
||||||
const storedSortBy = sessionStorage.getItem('professionalsSortBy');
|
const storedSortBy = sessionStorage.getItem('professionalsSortBy');
|
||||||
this.sortBy = storedSortBy && storedSortBy !== 'null' ? (storedSortBy as SortByOptions) : null;
|
this.sortBy = storedSortBy && storedSortBy !== 'null' ? (storedSortBy as SortByOptions) : null;
|
||||||
}
|
}
|
||||||
ngOnInit(): void {
|
async ngOnInit(): Promise<void> {
|
||||||
|
// Get current logged-in user
|
||||||
|
const token = await this.authService.getToken();
|
||||||
|
this.currentUser = map2User(token);
|
||||||
|
|
||||||
// Subscribe to FilterStateService for criteria changes
|
// Subscribe to FilterStateService for criteria changes
|
||||||
this.filterStateService
|
this.filterStateService
|
||||||
.getState$('brokerListings')
|
.getState$('brokerListings')
|
||||||
|
|
@ -144,4 +151,86 @@ export class BrokerListingsComponent implements OnInit, OnDestroy {
|
||||||
this.criteria = assignProperties(this.criteria, modalResult.criteria);
|
this.criteria = assignProperties(this.criteria, modalResult.criteria);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if professional/user is already in current user's favorites
|
||||||
|
*/
|
||||||
|
isFavorite(professional: User): boolean {
|
||||||
|
if (!this.currentUser?.email || !professional.favoritesForUser) return false;
|
||||||
|
return professional.favoritesForUser.includes(this.currentUser.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle favorite status for a professional
|
||||||
|
*/
|
||||||
|
async toggleFavorite(event: Event, professional: User): Promise<void> {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!this.currentUser?.email) {
|
||||||
|
// User not logged in - redirect to login
|
||||||
|
this.router.navigate(['/login']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.isFavorite(professional)) {
|
||||||
|
// Remove from favorites
|
||||||
|
await this.listingsService.removeFavorite(professional.id, 'user');
|
||||||
|
professional.favoritesForUser = professional.favoritesForUser.filter(
|
||||||
|
email => email !== this.currentUser!.email
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Add to favorites
|
||||||
|
await this.listingsService.addToFavorites(professional.id, 'user');
|
||||||
|
if (!professional.favoritesForUser) {
|
||||||
|
professional.favoritesForUser = [];
|
||||||
|
}
|
||||||
|
professional.favoritesForUser.push(this.currentUser.email);
|
||||||
|
}
|
||||||
|
this.cdRef.detectChanges();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling favorite:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share professional profile
|
||||||
|
*/
|
||||||
|
async shareProfessional(event: Event, user: User): Promise<void> {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const url = `${window.location.origin}/details-user/${user.id}`;
|
||||||
|
const title = `${user.firstname} ${user.lastname} - ${user.companyName}`;
|
||||||
|
|
||||||
|
// Try native share API first (works on mobile and some desktop browsers)
|
||||||
|
if (navigator.share) {
|
||||||
|
try {
|
||||||
|
await navigator.share({
|
||||||
|
title: title,
|
||||||
|
text: `Check out this professional: ${title}`,
|
||||||
|
url: url,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// User cancelled or share failed - fall back to clipboard
|
||||||
|
this.copyToClipboard(url);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: open Facebook share dialog
|
||||||
|
const encodedUrl = encodeURIComponent(url);
|
||||||
|
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, '_blank', 'width=600,height=400');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy URL to clipboard and show feedback
|
||||||
|
*/
|
||||||
|
private copyToClipboard(url: string): void {
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
console.log('Link copied to clipboard!');
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to copy link:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,11 @@
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
@for (listing of listings; track listing.id) {
|
@for (listing of listings; track listing.id) {
|
||||||
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg overflow-hidden flex flex-col h-full group relative">
|
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg overflow-hidden flex flex-col h-full group relative">
|
||||||
<!-- Favorites Button -->
|
<!-- Quick Actions Overlay -->
|
||||||
|
<div class="absolute top-4 right-4 z-10 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||||
@if(user) {
|
@if(user) {
|
||||||
<button
|
<button
|
||||||
class="absolute top-4 right-4 z-10 bg-white rounded-full p-2 shadow-lg transition-colors opacity-0 group-hover:opacity-100"
|
class="bg-white rounded-full p-2 shadow-lg transition-colors"
|
||||||
[class.bg-red-50]="isFavorite(listing)"
|
[class.bg-red-50]="isFavorite(listing)"
|
||||||
[class.opacity-100]="isFavorite(listing)"
|
[class.opacity-100]="isFavorite(listing)"
|
||||||
[title]="isFavorite(listing) ? 'Remove from favorites' : 'Save to favorites'"
|
[title]="isFavorite(listing) ? 'Remove from favorites' : 'Save to favorites'"
|
||||||
|
|
@ -34,6 +35,11 @@
|
||||||
<i [class]="isFavorite(listing) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i>
|
<i [class]="isFavorite(listing) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
<button type="button" class="bg-white rounded-full p-2 shadow-lg hover:bg-blue-50 transition-colors"
|
||||||
|
title="Share property" (click)="shareProperty($event, listing)">
|
||||||
|
<i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
@if (listing.imageOrder?.length>0){
|
@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]"
|
||||||
[alt]="altText.generatePropertyListingAlt(listing)"
|
[alt]="altText.generatePropertyListingAlt(listing)"
|
||||||
|
|
|
||||||
|
|
@ -258,4 +258,44 @@ export class CommercialPropertyListingsComponent implements OnInit, OnDestroy {
|
||||||
});
|
});
|
||||||
this.seoService.injectStructuredData(collectionSchema);
|
this.seoService.injectStructuredData(collectionSchema);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share property listing
|
||||||
|
*/
|
||||||
|
async shareProperty(event: Event, listing: CommercialPropertyListing): Promise<void> {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const url = `${window.location.origin}/commercial-property/${listing.slug || listing.id}`;
|
||||||
|
const title = listing.title || 'Commercial Property Listing';
|
||||||
|
|
||||||
|
// Try native share API first (works on mobile and some desktop browsers)
|
||||||
|
if (navigator.share) {
|
||||||
|
try {
|
||||||
|
await navigator.share({
|
||||||
|
title: title,
|
||||||
|
text: `Check out this property: ${title}`,
|
||||||
|
url: url,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// User cancelled or share failed - fall back to clipboard
|
||||||
|
this.copyToClipboard(url);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: open Facebook share dialog
|
||||||
|
const encodedUrl = encodeURIComponent(url);
|
||||||
|
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, '_blank', 'width=600,height=400');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy URL to clipboard and show feedback
|
||||||
|
*/
|
||||||
|
private copyToClipboard(url: string): void {
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
console.log('Link copied to clipboard!');
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to copy link:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -233,7 +233,6 @@
|
||||||
<span class="text-sm text-gray-500 ml-2">[Add more licenses or remove existing ones.]</span>
|
<span class="text-sm text-gray-500 ml-2">[Add more licenses or remove existing ones.]</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
<div class="flex items-center !my-8">
|
<div class="flex items-center !my-8">
|
||||||
<label class="flex items-center cursor-pointer">
|
<label class="flex items-center cursor-pointer">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
|
|
@ -243,6 +242,7 @@
|
||||||
<div class="ml-3 text-gray-700 font-medium">Show your profile in Professional Directory</div>
|
<div class="ml-3 text-gray-700 font-medium">Show your profile in Professional Directory</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="flex justify-start">
|
<div class="flex justify-start">
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,7 @@ export class FilterStateService {
|
||||||
minPrice: null,
|
minPrice: null,
|
||||||
maxPrice: null,
|
maxPrice: null,
|
||||||
title: null,
|
title: null,
|
||||||
|
brokerName: null,
|
||||||
prompt: null,
|
prompt: null,
|
||||||
page: 1,
|
page: 1,
|
||||||
start: 0,
|
start: 0,
|
||||||
|
|
|
||||||
|
|
@ -51,10 +51,10 @@ export class ListingsService {
|
||||||
async deleteCommercialPropertyListing(id: string, imagePath: string) {
|
async deleteCommercialPropertyListing(id: string, imagePath: string) {
|
||||||
await lastValueFrom(this.http.delete<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/commercialProperty/listing/${id}/${imagePath}`));
|
await lastValueFrom(this.http.delete<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/commercialProperty/listing/${id}/${imagePath}`));
|
||||||
}
|
}
|
||||||
async addToFavorites(id: string, listingsCategory?: 'business' | 'commercialProperty') {
|
async addToFavorites(id: string, listingsCategory?: 'business' | 'commercialProperty' | 'user') {
|
||||||
await lastValueFrom(this.http.post<void>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorite/${id}`, {}));
|
await lastValueFrom(this.http.post<void>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorite/${id}`, {}));
|
||||||
}
|
}
|
||||||
async removeFavorite(id: string, listingsCategory?: 'business' | 'commercialProperty') {
|
async removeFavorite(id: string, listingsCategory?: 'business' | 'commercialProperty' | 'user') {
|
||||||
await lastValueFrom(this.http.delete<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorite/${id}`));
|
await lastValueFrom(this.http.delete<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorite/${id}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export function createEmptyCommercialPropertyListingCriteria(): CommercialProper
|
||||||
minPrice: null,
|
minPrice: null,
|
||||||
maxPrice: null,
|
maxPrice: null,
|
||||||
title: '',
|
title: '',
|
||||||
|
brokerName: '',
|
||||||
searchType: 'exact',
|
searchType: 'exact',
|
||||||
radius: null,
|
radius: null,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
|
||||||
|
import { and, inArray, sql, SQL } from 'drizzle-orm';
|
||||||
|
import { businesses_json, users_json } from './bizmatch-server/src/drizzle/schema';
|
||||||
|
|
||||||
|
// Mock criteria similar to what the user used
|
||||||
|
const criteria: any = {
|
||||||
|
types: ['retail'],
|
||||||
|
brokerName: 'page',
|
||||||
|
criteriaType: 'businessListings'
|
||||||
|
};
|
||||||
|
|
||||||
|
const user = { role: 'guest', email: 'timo@example.com' };
|
||||||
|
|
||||||
|
function getWhereConditions(criteria: any, user: any): SQL[] {
|
||||||
|
const whereConditions: SQL[] = [];
|
||||||
|
|
||||||
|
// Category filter
|
||||||
|
if (criteria.types && criteria.types.length > 0) {
|
||||||
|
// Suspected problematic line:
|
||||||
|
whereConditions.push(inArray(sql`${businesses_json.data}->>'type'`, criteria.types));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broker filter
|
||||||
|
if (criteria.brokerName) {
|
||||||
|
const firstname = criteria.brokerName;
|
||||||
|
const lastname = criteria.brokerName;
|
||||||
|
whereConditions.push(
|
||||||
|
sql`((${users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${users_json.data}->>'lastname') ILIKE ${`%${lastname}%` bubble})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draft check
|
||||||
|
if (user?.role !== 'admin') {
|
||||||
|
whereConditions.push(
|
||||||
|
sql`((${ businesses_json.email } = ${ user?.email || null}) OR(${ businesses_json.data } ->> 'draft')::boolean IS NOT TRUE)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return whereConditions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditions = getWhereConditions(criteria, user);
|
||||||
|
const combined = and(...conditions);
|
||||||
|
|
||||||
|
console.log('--- Conditions Count ---');
|
||||||
|
console.log(conditions.length);
|
||||||
|
|
||||||
|
console.log('--- Generated SQL Fragment ---');
|
||||||
|
// We need a dummy query to see the full SQL
|
||||||
|
// Since we don't have a real DB connection here, we just inspect the SQL parts
|
||||||
|
// Drizzle conditions can be serialized to SQL strings
|
||||||
|
// This is a simplified test
|
||||||
|
|
||||||
|
try {
|
||||||
|
// In a real environment we would use a dummy pg adapter
|
||||||
|
console.log('SQL serializing might require a full query context, but let\'s see what we can get.');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue