Compare commits
43 Commits
a6a37f8f1a
...
timo-clean
| Author | SHA1 | Date | |
|---|---|---|---|
| fca746cef6 | |||
| 79098f59c6 | |||
| 345761da87 | |||
| 7e00b4d71b | |||
| 715220f6d5 | |||
| dc79ac3df7 | |||
| a545b84f6c | |||
| 2d293d8b12 | |||
| d008b50892 | |||
| 1a1eaa46ae | |||
| a9dcb66e5b | |||
| 33ea71dc12 | |||
| 91bcf3c2ed | |||
| 36ef7eb4bf | |||
| ae12eb87f0 | |||
| 0b4e4207d1 | |||
| 53537226cd | |||
| 00597a796a | |||
| 8b3c79b5ff | |||
| a7d3d2d958 | |||
| 49528a5c37 | |||
| 047c723364 | |||
| 39c93e7178 | |||
| 6f1109d593 | |||
| 70a50e0ff6 | |||
| 23f7caedeb | |||
| 737329794c | |||
|
|
ff7ef0f423 | ||
|
|
e25722d806 | ||
|
|
bf735ed60f | ||
| 0bbfc3f4fb | |||
| 3b47540985 | |||
| 21d7f16289 | |||
| c632cd90b5 | |||
| 152304aa71 | |||
| e8f493558f | |||
| 31a507ad58 | |||
| 447027db2b | |||
| 09e7ce59a9 | |||
| 897ab1ff77 | |||
|
|
1874d5f4ed | ||
| adeefb199c | |||
| c2d7a53039 |
@@ -13,7 +13,22 @@
|
||||
"Bash(sudo chown:*)",
|
||||
"Bash(chmod:*)",
|
||||
"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:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(npx tsc:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.git
|
||||
.idea
|
||||
.vscode
|
||||
dist
|
||||
coverage
|
||||
4
.gitattributes
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
27
DOCKER_SYNC_GUIDE.md
Normal file
@@ -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.
|
||||
.
|
||||
@@ -1,19 +1,25 @@
|
||||
# Build Stage
|
||||
FROM node:18-alpine AS build
|
||||
|
||||
# --- STAGE 1: Build ---
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
# HIER KEIN NODE_ENV=production setzen! Wir brauchen devDependencies zum Bauen.
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Runtime Stage
|
||||
FROM node:18-alpine
|
||||
|
||||
# --- STAGE 2: Runtime ---
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/dist /app/dist
|
||||
COPY --from=build /app/package*.json /app/
|
||||
|
||||
RUN npm install --production
|
||||
# HIER ist es richtig!
|
||||
ENV NODE_ENV=production
|
||||
|
||||
CMD ["node", "dist/main.js"]
|
||||
COPY --from=builder /app/dist /app/dist
|
||||
COPY --from=builder /app/package*.json /app/
|
||||
|
||||
# Installiert nur "dependencies" (Nest core, TypeORM, Helmet, Sharp etc.)
|
||||
# "devDependencies" (TypeScript, Jest, ESLint) werden weggelassen.
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# WICHTIG: Pfad prüfen (siehe Punkt 2 unten)
|
||||
CMD ["node", "dist/src/main.js"]
|
||||
@@ -1,48 +0,0 @@
|
||||
services:
|
||||
app:
|
||||
image: node:22-alpine
|
||||
container_name: bizmatch-app
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./:/app
|
||||
- node_modules:/app/node_modules
|
||||
ports:
|
||||
- '3001:3001'
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- DATABASE_URL
|
||||
command: sh -c "if [ ! -f node_modules/.installed ]; then npm ci && touch node_modules/.installed; fi && npm run build && node dist/src/main.js"
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- postgres
|
||||
networks:
|
||||
- bizmatch
|
||||
|
||||
postgres:
|
||||
container_name: bizmatchdb
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- bizmatch-db-data:/var/lib/postgresql/data
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
ports:
|
||||
- '5434:5432'
|
||||
networks:
|
||||
- bizmatch
|
||||
|
||||
volumes:
|
||||
bizmatch-db-data:
|
||||
driver: local
|
||||
node_modules:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
bizmatch:
|
||||
external: true
|
||||
@@ -47,6 +47,7 @@
|
||||
"fs-extra": "^11.2.0",
|
||||
"groq-sdk": "^0.5.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^8.1.0",
|
||||
"nest-winston": "^1.9.4",
|
||||
"nestjs-cls": "^5.4.0",
|
||||
"nodemailer": "^7.0.12",
|
||||
|
||||
119
bizmatch-server/scripts/reproduce-favorites.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { drizzle, NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { sql, eq, and } from 'drizzle-orm';
|
||||
import * as schema from '../src/drizzle/schema';
|
||||
import { users_json } from '../src/drizzle/schema';
|
||||
|
||||
// Mock JwtUser
|
||||
interface JwtUser {
|
||||
email: string;
|
||||
}
|
||||
|
||||
// Logic from UserService.addFavorite
|
||||
async function addFavorite(db: NodePgDatabase<typeof schema>, id: string, user: JwtUser) {
|
||||
console.log(`[Action] Adding favorite. Target ID: ${id}, Favoriter Email: ${user.email}`);
|
||||
await db
|
||||
.update(schema.users_json)
|
||||
.set({
|
||||
data: sql`jsonb_set(${schema.users_json.data}, '{favoritesForUser}',
|
||||
coalesce((${schema.users_json.data}->'favoritesForUser')::jsonb, '[]'::jsonb) || to_jsonb(${user.email}::text))`,
|
||||
} as any)
|
||||
.where(eq(schema.users_json.id, id));
|
||||
}
|
||||
|
||||
// Logic from UserService.getFavoriteUsers
|
||||
async function getFavoriteUsers(db: NodePgDatabase<typeof schema>, user: JwtUser) {
|
||||
console.log(`[Action] Fetching favorites for ${user.email}`);
|
||||
|
||||
// Corrected query using `?` operator (matches array element check)
|
||||
const data = await db
|
||||
.select()
|
||||
.from(schema.users_json)
|
||||
.where(sql`${schema.users_json.data}->'favoritesForUser' ? ${user.email}`);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// Logic from UserService.deleteFavorite
|
||||
async function deleteFavorite(db: NodePgDatabase<typeof schema>, id: string, user: JwtUser) {
|
||||
console.log(`[Action] Removing favorite. Target ID: ${id}, Favoriter Email: ${user.email}`);
|
||||
await db
|
||||
.update(schema.users_json)
|
||||
.set({
|
||||
data: sql`jsonb_set(${schema.users_json.data}, '{favoritesForUser}',
|
||||
(SELECT coalesce(jsonb_agg(elem), '[]'::jsonb)
|
||||
FROM jsonb_array_elements(coalesce(${schema.users_json.data}->'favoritesForUser', '[]'::jsonb)) AS elem
|
||||
WHERE elem::text != to_jsonb(${user.email}::text)::text))`,
|
||||
} as any)
|
||||
.where(eq(schema.users_json.id, id));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('═══════════════════════════════════════════════════════');
|
||||
console.log(' FAVORITES REPRODUCTION SCRIPT');
|
||||
console.log('═══════════════════════════════════════════════════════\n');
|
||||
|
||||
const connectionString = process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/bizmatch';
|
||||
const pool = new Pool({ connectionString });
|
||||
const db = drizzle(pool, { schema });
|
||||
|
||||
try {
|
||||
// 1. Find a "professional" user to be the TARGET listing
|
||||
// filtering by customerType = 'professional' inside the jsonb data
|
||||
const targets = await db.select().from(users_json).limit(1);
|
||||
|
||||
if (targets.length === 0) {
|
||||
console.error("No users found in DB to test with.");
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUser = targets[0];
|
||||
console.log(`Found target user: ID=${targetUser.id}, Email=${targetUser.email}`);
|
||||
|
||||
// 2. Define a "favoriter" user (doesn't need to exist in DB for the logic to work, but better if it's realistic)
|
||||
// We'll just use a dummy email or one from DB if available.
|
||||
const favoriterEmail = 'test-repro-favoriter@example.com';
|
||||
const favoriter: JwtUser = { email: favoriterEmail };
|
||||
|
||||
// 3. Clear any existing favorite for this pair first
|
||||
await deleteFavorite(db, targetUser.id, favoriter);
|
||||
|
||||
// 4. Add Favorite
|
||||
await addFavorite(db, targetUser.id, favoriter);
|
||||
|
||||
// 5. Verify it was added by checking the raw data
|
||||
const updatedTarget = await db.select().from(users_json).where(eq(users_json.id, targetUser.id));
|
||||
const favoritesData = (updatedTarget[0].data as any).favoritesForUser;
|
||||
console.log(`\n[Check] Raw favoritesForUser data on target:`, favoritesData);
|
||||
|
||||
if (!favoritesData || !favoritesData.includes(favoriterEmail)) {
|
||||
console.error("❌ Add Favorite FAILED. Email not found in favoritesForUser array.");
|
||||
} else {
|
||||
console.log("✅ Add Favorite SUCCESS. Email found in JSON.");
|
||||
}
|
||||
|
||||
// 6. Test retrieval using the getFavoriteUsers query
|
||||
const retrievedFavorites = await getFavoriteUsers(db, favoriter);
|
||||
console.log(`\n[Check] retrievedFavorites count: ${retrievedFavorites.length}`);
|
||||
|
||||
const found = retrievedFavorites.find(u => u.id === targetUser.id);
|
||||
if (found) {
|
||||
console.log("✅ Get Favorites SUCCESS. Target user returned in query.");
|
||||
} else {
|
||||
console.log("❌ Get Favorites FAILED. Target user NOT returned by query.");
|
||||
console.log("Query used: favoritesForUser @> [email]");
|
||||
}
|
||||
|
||||
// 7. Cleanup
|
||||
await deleteFavorite(db, targetUser.id, favoriter);
|
||||
console.log("\n[Cleanup] Removed test favorite.");
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Script failed:', error);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -5,7 +5,7 @@ import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||
import { Logger } from 'winston';
|
||||
import { ZodError } from 'zod';
|
||||
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 { BusinessListing, BusinessListingSchema } from '../models/db.model';
|
||||
import { BusinessListingCriteria, JwtUser } from '../models/main.model';
|
||||
@@ -18,27 +18,30 @@ export class BusinessListingService {
|
||||
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
||||
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
|
||||
private geoService?: GeoService,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
private getWhereConditions(criteria: BusinessListingCriteria, user: JwtUser): SQL[] {
|
||||
const whereConditions: SQL[] = [];
|
||||
this.logger.info('getWhereConditions start', { criteria: JSON.stringify(criteria) });
|
||||
|
||||
if (criteria.city && criteria.searchType === 'exact') {
|
||||
whereConditions.push(sql`(${businesses_json.data}->'location'->>'name') ILIKE ${criteria.city.name}`);
|
||||
}
|
||||
|
||||
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);
|
||||
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) {
|
||||
this.logger.debug('Adding state filter', { 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() !== '') {
|
||||
const searchTerm = `%${criteria.title.trim()}%`;
|
||||
whereConditions.push(
|
||||
or(
|
||||
sql`(${businesses_json.data}->>'title') ILIKE ${searchTerm}`,
|
||||
sql`(${businesses_json.data}->>'description') ILIKE ${searchTerm}`
|
||||
)
|
||||
sql`((${businesses_json.data}->>'title') ILIKE ${searchTerm} OR (${businesses_json.data}->>'description') ILIKE ${searchTerm})`
|
||||
);
|
||||
}
|
||||
if (criteria.brokerName) {
|
||||
const { firstname, lastname } = splitName(criteria.brokerName);
|
||||
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 {
|
||||
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) {
|
||||
whereConditions.push(eq(users_json.email, criteria.email));
|
||||
whereConditions.push(eq(schema.users_json.email, criteria.email));
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -135,24 +141,21 @@ export class BusinessListingService {
|
||||
const query = this.conn
|
||||
.select({
|
||||
business: businesses_json,
|
||||
brokerFirstName: sql`${users_json.data}->>'firstname'`.as('brokerFirstName'),
|
||||
brokerLastName: sql`${users_json.data}->>'lastname'`.as('brokerLastName'),
|
||||
brokerFirstName: sql`${schema.users_json.data}->>'firstname'`.as('brokerFirstName'),
|
||||
brokerLastName: sql`${schema.users_json.data}->>'lastname'`.as('brokerLastName'),
|
||||
})
|
||||
.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);
|
||||
|
||||
// Uncomment for debugging filter issues:
|
||||
// this.logger.info('Filter Criteria:', { criteria });
|
||||
// this.logger.info('Where Conditions Count:', { count: whereConditions.length });
|
||||
this.logger.warn('Filter Criteria:', { criteria: JSON.stringify(criteria) });
|
||||
|
||||
if (whereConditions.length > 0) {
|
||||
const whereClause = and(...whereConditions);
|
||||
query.where(whereClause);
|
||||
const whereClause = sql.join(whereConditions, sql` AND `);
|
||||
query.where(sql`(${whereClause})`);
|
||||
|
||||
// Uncomment for debugging SQL queries:
|
||||
// this.logger.info('Generated SQL:', { sql: query.toSQL() });
|
||||
this.logger.warn('Generated SQL:', { sql: query.toSQL().sql, params: query.toSQL().params });
|
||||
}
|
||||
|
||||
// Sortierung
|
||||
@@ -228,13 +231,13 @@ export class BusinessListingService {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (whereConditions.length > 0) {
|
||||
const whereClause = and(...whereConditions);
|
||||
countQuery.where(whereClause);
|
||||
const whereClause = sql.join(whereConditions, sql` AND `);
|
||||
countQuery.where(sql`(${whereClause})`);
|
||||
}
|
||||
|
||||
const [{ value: totalCount }] = await countQuery;
|
||||
|
||||
@@ -13,7 +13,13 @@ export class BusinessListingsController {
|
||||
constructor(
|
||||
private readonly listingsService: BusinessListingService,
|
||||
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Post('favorites/all')
|
||||
async findFavorites(@Request() req): Promise<any> {
|
||||
return await this.listingsService.findFavoriteListings(req.user as JwtUser);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Get(':slugOrId')
|
||||
@@ -21,11 +27,7 @@ export class BusinessListingsController {
|
||||
// Support both slug (e.g., "restaurant-austin-tx-a3f7b2c1") and UUID
|
||||
return await this.listingsService.findBusinessBySlugOrId(slugOrId, req.user as JwtUser);
|
||||
}
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('favorites/all')
|
||||
async findFavorites(@Request() req): Promise<any> {
|
||||
return await this.listingsService.findFavoriteListings(req.user as JwtUser);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Get('user/:userid')
|
||||
async findByUserId(@Request() req, @Param('userid') userid: string): Promise<BusinessListing[]> {
|
||||
|
||||
@@ -15,7 +15,13 @@ export class CommercialPropertyListingsController {
|
||||
private readonly listingsService: CommercialPropertyService,
|
||||
private fileService: FileService,
|
||||
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Post('favorites/all')
|
||||
async findFavorites(@Request() req): Promise<any> {
|
||||
return await this.listingsService.findFavoriteListings(req.user as JwtUser);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Get(':slugOrId')
|
||||
@@ -24,12 +30,6 @@ export class CommercialPropertyListingsController {
|
||||
return await this.listingsService.findCommercialBySlugOrId(slugOrId, req.user as JwtUser);
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Get('favorites/all')
|
||||
async findFavorites(@Request() req): Promise<any> {
|
||||
return await this.listingsService.findFavoriteListings(req.user as JwtUser);
|
||||
}
|
||||
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
@Get('user/:email')
|
||||
async findByEmail(@Request() req, @Param('email') email: string): Promise<CommercialPropertyListing[]> {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { FileService } from '../file/file.service';
|
||||
import { GeoService } from '../geo/geo.service';
|
||||
import { CommercialPropertyListing, CommercialPropertyListingSchema } from '../models/db.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';
|
||||
|
||||
@Injectable()
|
||||
@@ -20,7 +20,7 @@ export class CommercialPropertyService {
|
||||
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
|
||||
private fileService?: FileService,
|
||||
private geoService?: GeoService,
|
||||
) {}
|
||||
) { }
|
||||
private getWhereConditions(criteria: CommercialPropertyListingCriteria, user: JwtUser): SQL[] {
|
||||
const whereConditions: SQL[] = [];
|
||||
|
||||
@@ -32,7 +32,10 @@ export class CommercialPropertyService {
|
||||
whereConditions.push(sql`${getDistanceQuery(commercials_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`);
|
||||
}
|
||||
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) {
|
||||
@@ -48,12 +51,32 @@ export class CommercialPropertyService {
|
||||
}
|
||||
|
||||
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') {
|
||||
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;
|
||||
}
|
||||
// #### 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 whereConditions = this.getWhereConditions(criteria, user);
|
||||
|
||||
this.logger.warn('Filter Criteria:', { criteria: JSON.stringify(criteria) });
|
||||
|
||||
if (whereConditions.length > 0) {
|
||||
const whereClause = and(...whereConditions);
|
||||
query.where(whereClause);
|
||||
const whereClause = sql.join(whereConditions, sql` AND `);
|
||||
query.where(sql`(${whereClause})`);
|
||||
|
||||
this.logger.warn('Generated SQL:', { sql: query.toSQL().sql, params: query.toSQL().params });
|
||||
}
|
||||
// Sortierung
|
||||
switch (criteria.sortBy) {
|
||||
@@ -103,8 +130,8 @@ export class CommercialPropertyService {
|
||||
const whereConditions = this.getWhereConditions(criteria, user);
|
||||
|
||||
if (whereConditions.length > 0) {
|
||||
const whereClause = and(...whereConditions);
|
||||
countQuery.where(whereClause);
|
||||
const whereClause = sql.join(whereConditions, sql` AND `);
|
||||
countQuery.where(sql`(${whereClause})`);
|
||||
}
|
||||
|
||||
const [{ value: totalCount }] = await countQuery;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { UserService } from '../user/user.service';
|
||||
import { BrokerListingsController } from './broker-listings.controller';
|
||||
import { BusinessListingsController } from './business-listings.controller';
|
||||
import { CommercialPropertyListingsController } from './commercial-property-listings.controller';
|
||||
import { UserListingsController } from './user-listings.controller';
|
||||
|
||||
import { FirebaseAdminModule } from 'src/firebase-admin/firebase-admin.module';
|
||||
import { GeoModule } from '../geo/geo.module';
|
||||
@@ -16,7 +17,7 @@ import { UnknownListingsController } from './unknown-listings.controller';
|
||||
|
||||
@Module({
|
||||
imports: [DrizzleModule, AuthModule, GeoModule,FirebaseAdminModule],
|
||||
controllers: [BusinessListingsController, CommercialPropertyListingsController, UnknownListingsController, BrokerListingsController],
|
||||
controllers: [BusinessListingsController, CommercialPropertyListingsController, UnknownListingsController, BrokerListingsController, UserListingsController],
|
||||
providers: [BusinessListingService, CommercialPropertyService, FileService, UserService, BusinessListingService, CommercialPropertyService, GeoService],
|
||||
exports: [BusinessListingService, CommercialPropertyService],
|
||||
})
|
||||
|
||||
29
bizmatch-server/src/listings/user-listings.controller.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Controller, Delete, Param, Post, Request, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '../jwt-auth/auth.guard';
|
||||
import { JwtUser } from '../models/main.model';
|
||||
import { UserService } from '../user/user.service';
|
||||
|
||||
@Controller('listings/user')
|
||||
export class UserListingsController {
|
||||
constructor(private readonly userService: UserService) { }
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Post('favorite/:id')
|
||||
async addFavorite(@Request() req, @Param('id') id: string) {
|
||||
await this.userService.addFavorite(id, req.user as JwtUser);
|
||||
return { success: true, message: 'Added to favorites' };
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Delete('favorite/:id')
|
||||
async deleteFavorite(@Request() req, @Param('id') id: string) {
|
||||
await this.userService.deleteFavorite(id, req.user as JwtUser);
|
||||
return { success: true, message: 'Removed from favorites' };
|
||||
}
|
||||
|
||||
@UseGuards(AuthGuard)
|
||||
@Post('favorites/all')
|
||||
async getFavorites(@Request() req) {
|
||||
return await this.userService.getFavoriteUsers(req.user as JwtUser);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LoggerService } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import express from 'express';
|
||||
import helmet from 'helmet';
|
||||
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
@@ -22,6 +23,37 @@ async function bootstrap() {
|
||||
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
||||
allowedHeaders: 'Content-Type, Accept, Authorization, x-hide-loading',
|
||||
});
|
||||
|
||||
// Security Headers with helmet
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "https://fonts.googleapis.com"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
||||
imgSrc: ["'self'", "data:", "https:", "blob:"],
|
||||
connectSrc: ["'self'", "https://api.bizmatch.net", "https://*.firebaseapp.com", "https://*.googleapis.com"],
|
||||
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
|
||||
objectSrc: ["'none'"],
|
||||
mediaSrc: ["'self'"],
|
||||
frameSrc: ["'self'"],
|
||||
},
|
||||
},
|
||||
crossOriginEmbedderPolicy: false, // Disable for now to avoid breaking existing functionality
|
||||
hsts: {
|
||||
maxAge: 31536000,
|
||||
includeSubDomains: true,
|
||||
preload: true,
|
||||
},
|
||||
frameguard: {
|
||||
action: 'sameorigin', // Allow same-origin framing
|
||||
},
|
||||
crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' }, // Allow popups for OAuth
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' }, // Allow cross-origin resources
|
||||
}),
|
||||
);
|
||||
|
||||
await app.listen(process.env.PORT || 3001);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -34,6 +34,7 @@ export const CustomerTypeEnum = z.enum(['buyer', 'seller', 'professional']);
|
||||
export const SubscriptionTypeEnum = z.enum(['free', 'professional', 'broker']);
|
||||
export const CustomerSubTypeEnum = z.enum(['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']);
|
||||
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 type EventTypeEnum = z.infer<typeof ZodEventTypeEnum>;
|
||||
const PropertyTypeEnum = z.enum(['retail', 'land', 'industrial', 'office', 'mixedUse', 'multifamily', 'uncategorized']);
|
||||
@@ -186,6 +187,7 @@ export const UserSchema = z
|
||||
updated: z.date().optional().nullable(),
|
||||
subscriptionId: z.string().optional().nullable(),
|
||||
subscriptionPlan: SubscriptionTypeEnum.optional().nullable(),
|
||||
favoritesForUser: z.array(z.string()),
|
||||
showInDirectory: z.boolean(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
@@ -369,7 +371,7 @@ export const ShareByEMailSchema = z.object({
|
||||
listingTitle: z.string().optional().nullable(),
|
||||
url: z.string().url({ message: 'Invalid URL format' }).optional().nullable(),
|
||||
id: z.string().optional().nullable(),
|
||||
type: ListingsCategoryEnum,
|
||||
type: ShareCategoryEnum,
|
||||
});
|
||||
export type ShareByEMail = z.infer<typeof ShareByEMailSchema>;
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ export interface CommercialPropertyListingCriteria extends ListCriteria {
|
||||
minPrice: number;
|
||||
maxPrice: number;
|
||||
title: string;
|
||||
brokerName: string;
|
||||
criteriaType: 'commercialPropertyListings';
|
||||
}
|
||||
export interface UserListingCriteria extends ListCriteria {
|
||||
@@ -358,6 +359,7 @@ export function createDefaultUser(email: string, firstname: string, lastname: st
|
||||
updated: new Date(),
|
||||
subscriptionId: null,
|
||||
subscriptionPlan: subscriptionPlan,
|
||||
favoritesForUser: [],
|
||||
showInDirectory: false,
|
||||
};
|
||||
}
|
||||
|
||||
60
bizmatch-server/src/scripts/debug-favorites.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { Client } from 'pg';
|
||||
import * as schema from '../drizzle/schema';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import * as dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
const client = new Client({
|
||||
connectionString: process.env.PG_CONNECTION,
|
||||
});
|
||||
|
||||
async function main() {
|
||||
await client.connect();
|
||||
const db = drizzle(client, { schema });
|
||||
|
||||
const testEmail = 'knuth.timo@gmail.com';
|
||||
const targetEmail = 'target.user@example.com';
|
||||
|
||||
console.log('--- Starting Debug Script ---');
|
||||
|
||||
// 1. Simulate finding a user to favorite (using a dummy or existing one)
|
||||
// For safety, let's just query existing users to see if any have favorites set
|
||||
const usersWithFavorites = await db.select({
|
||||
id: schema.users_json.id,
|
||||
email: schema.users_json.email,
|
||||
favorites: sql`${schema.users_json.data}->'favoritesForUser'`
|
||||
}).from(schema.users_json);
|
||||
|
||||
console.log(`Found ${usersWithFavorites.length} users.`);
|
||||
|
||||
const usersWithAnyFavorites = usersWithFavorites.filter(u => u.favorites !== null);
|
||||
console.log(`Users with 'favoritesForUser' field:`, JSON.stringify(usersWithAnyFavorites, null, 2));
|
||||
|
||||
// 2. Test the specific WHERE clause used in the service
|
||||
// .where(sql`${schema.users_json.data}->'favoritesForUser' @> ${JSON.stringify([user.email])}::jsonb`);
|
||||
|
||||
console.log(`Testing query for email: ${testEmail}`);
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
.select({
|
||||
id: schema.users_json.id,
|
||||
email: schema.users_json.email
|
||||
})
|
||||
.from(schema.users_json)
|
||||
.where(sql`${schema.users_json.data}->'favoritesForUser' @> ${JSON.stringify([testEmail])}::jsonb`);
|
||||
|
||||
console.log('Query Result:', result);
|
||||
} catch (e) {
|
||||
console.error('Query Failed:', e);
|
||||
}
|
||||
|
||||
await client.end();
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
|
||||
|
||||
//test
|
||||
@@ -18,7 +18,7 @@ interface SitemapIndexEntry {
|
||||
|
||||
@Injectable()
|
||||
export class SitemapService {
|
||||
private readonly baseUrl = 'https://biz-match.com';
|
||||
private readonly baseUrl = 'https://www.bizmatch.net';
|
||||
private readonly URLS_PER_SITEMAP = 10000; // Google best practice
|
||||
|
||||
constructor(@Inject(PG_CONNECTION) private readonly db: NodePgDatabase<typeof schema>) { }
|
||||
|
||||
@@ -19,7 +19,7 @@ export class UserService {
|
||||
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
|
||||
private fileService: FileService,
|
||||
private geoService: GeoService,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
private getWhereConditions(criteria: UserListingCriteria): SQL[] {
|
||||
const whereConditions: SQL[] = [];
|
||||
@@ -158,4 +158,38 @@ export class UserService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async addFavorite(id: string, user: JwtUser): Promise<void> {
|
||||
const existingUser = await this.getUserById(id);
|
||||
if (!existingUser) return;
|
||||
|
||||
const favorites = existingUser.favoritesForUser || [];
|
||||
if (!favorites.includes(user.email)) {
|
||||
existingUser.favoritesForUser = [...favorites, user.email];
|
||||
const { id: _, ...rest } = existingUser;
|
||||
const drizzleUser = { email: existingUser.email, data: rest };
|
||||
await this.conn.update(schema.users_json).set(drizzleUser).where(eq(schema.users_json.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFavorite(id: string, user: JwtUser): Promise<void> {
|
||||
const existingUser = await this.getUserById(id);
|
||||
if (!existingUser) return;
|
||||
|
||||
const favorites = existingUser.favoritesForUser || [];
|
||||
if (favorites.includes(user.email)) {
|
||||
existingUser.favoritesForUser = favorites.filter(email => email !== user.email);
|
||||
const { id: _, ...rest } = existingUser;
|
||||
const drizzleUser = { email: existingUser.email, data: rest };
|
||||
await this.conn.update(schema.users_json).set(drizzleUser).where(eq(schema.users_json.id, id));
|
||||
}
|
||||
}
|
||||
|
||||
async getFavoriteUsers(user: JwtUser): Promise<User[]> {
|
||||
const data = await this.conn
|
||||
.select()
|
||||
.from(schema.users_json)
|
||||
.where(sql`${schema.users_json.data}->'favoritesForUser' ? ${user.email}`);
|
||||
return data.map(u => ({ id: u.id, email: u.email, ...(u.data as User) }) as User);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,41 @@
|
||||
# STAGE 1: Build
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
# Wir erstellen ein Arbeitsverzeichnis, das eine Ebene über dem Projekt liegt
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# 1. Wir kopieren die Backend-Models an die Stelle, wo Angular sie erwartet
|
||||
# Deine Pfade suchen nach ../bizmatch-server, also legen wir es daneben.
|
||||
COPY bizmatch-server/src/models ./bizmatch-server/src/models
|
||||
|
||||
# 2. Jetzt kümmern wir uns um das Frontend
|
||||
# Wir kopieren erst die package Files für besseres Caching
|
||||
COPY bizmatch/package*.json ./bizmatch/
|
||||
|
||||
# Wechseln in den Frontend Ordner zum Installieren
|
||||
WORKDIR /usr/src/app/bizmatch
|
||||
RUN npm ci
|
||||
|
||||
# 3. Den Rest des Frontends kopieren
|
||||
COPY bizmatch/ .
|
||||
|
||||
# 4. Bauen
|
||||
RUN npm run build:ssr
|
||||
|
||||
# --- STAGE 2: Runtime ---
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# GANZEN dist-Ordner kopieren, nicht nur bizmatch
|
||||
COPY dist ./dist
|
||||
COPY package*.json ./
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=4000
|
||||
|
||||
# Kopiere das Ergebnis aus dem Builder (Pfad beachten!)
|
||||
COPY --from=builder /usr/src/app/bizmatch/dist /app/dist
|
||||
COPY --from=builder /usr/src/app/bizmatch/package*.json /app/
|
||||
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
EXPOSE 4200
|
||||
EXPOSE 4000
|
||||
|
||||
CMD ["node", "dist/bizmatch/server/server.mjs"]
|
||||
|
||||
@@ -69,8 +69,8 @@
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "2kb",
|
||||
"maximumError": "4kb"
|
||||
"maximumWarning": "30kb",
|
||||
"maximumError": "30kb"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
@@ -101,7 +101,8 @@
|
||||
],
|
||||
"optimization": true,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
"sourceMap": true,
|
||||
"outputHashing": "all"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
services:
|
||||
bizmatch-ssr:
|
||||
build: .
|
||||
image: bizmatch-ssr
|
||||
container_name: bizmatch-ssr
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '4200:4200'
|
||||
environment:
|
||||
NODE_ENV: DEVELOPMENT
|
||||
1
bizmatch/public/googleccd5315437d68a49.html
Normal file
@@ -0,0 +1 @@
|
||||
google-site-verification: googleccd5315437d68a49.html
|
||||
@@ -26,6 +26,74 @@ export async function app(): Promise<express.Express> {
|
||||
server.set('view engine', 'html');
|
||||
server.set('views', browserDistFolder);
|
||||
|
||||
// Sitemap XML endpoints - MUST be before static files middleware
|
||||
server.get('/sitemap.xml', async (req, res) => {
|
||||
try {
|
||||
const sitemapIndexXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<sitemap>
|
||||
<loc>https://www.bizmatch.net/sitemap-static.xml</loc>
|
||||
<lastmod>${new Date().toISOString().split('T')[0]}</lastmod>
|
||||
</sitemap>
|
||||
</sitemapindex>`;
|
||||
|
||||
res.header('Content-Type', 'application/xml; charset=utf-8');
|
||||
res.send(sitemapIndexXml);
|
||||
} catch (error) {
|
||||
console.error('[SSR] Error generating sitemap index:', error);
|
||||
res.status(500).send('Error generating sitemap');
|
||||
}
|
||||
});
|
||||
|
||||
server.get('/sitemap-static.xml', async (req, res) => {
|
||||
try {
|
||||
const sitemapXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://www.bizmatch.net/</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.bizmatch.net/home</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.bizmatch.net/businessListings</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.bizmatch.net/commercialPropertyListings</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.bizmatch.net/brokerListings</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.bizmatch.net/terms-of-use</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://www.bizmatch.net/privacy-statement</loc>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
</urlset>`;
|
||||
|
||||
res.header('Content-Type', 'application/xml; charset=utf-8');
|
||||
res.send(sitemapXml);
|
||||
} catch (error) {
|
||||
console.error('[SSR] Error generating static sitemap:', error);
|
||||
res.status(500).send('Error generating sitemap');
|
||||
}
|
||||
});
|
||||
|
||||
// Example Express Rest API endpoints
|
||||
// server.get('/api/**', (req, res) => { });
|
||||
// Serve static files from /browser
|
||||
@@ -36,6 +104,8 @@ export async function app(): Promise<express.Express> {
|
||||
// All regular routes use the Angular engine
|
||||
server.get('*', async (req, res, next) => {
|
||||
console.log(`[SSR] Handling request: ${req.method} ${req.url}`);
|
||||
// Cache SSR-rendered pages at CDN level
|
||||
res.setHeader('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=600');
|
||||
try {
|
||||
const response = await angularApp.handle(req);
|
||||
if (response) {
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
import { RenderMode, ServerRoute } from '@angular/ssr';
|
||||
|
||||
export const serverRoutes: ServerRoute[] = [
|
||||
{
|
||||
path: '**',
|
||||
renderMode: RenderMode.Server
|
||||
}
|
||||
{ path: 'home', renderMode: RenderMode.Server }, // Das hatten wir vorhin gefixt
|
||||
|
||||
// WICHTIG: Alle geschützten Routen nur im Browser rendern!
|
||||
// Damit überspringt der Server den AuthGuard Check komplett und schickt
|
||||
// nur eine leere Hülle (index.html), die der Browser dann füllt.
|
||||
{ path: 'account', renderMode: RenderMode.Client },
|
||||
{ path: 'account/**', renderMode: RenderMode.Client },
|
||||
{ path: 'myListings', renderMode: RenderMode.Client },
|
||||
{ path: 'myFavorites', renderMode: RenderMode.Client },
|
||||
{ path: 'createBusinessListing', renderMode: RenderMode.Client },
|
||||
{ path: 'createCommercialPropertyListing', renderMode: RenderMode.Client },
|
||||
{ path: 'editBusinessListing/**', renderMode: RenderMode.Client },
|
||||
{ path: 'editCommercialPropertyListing/**', renderMode: RenderMode.Client },
|
||||
|
||||
// Statische Seiten
|
||||
{ path: 'terms-of-use', renderMode: RenderMode.Prerender },
|
||||
{ path: 'privacy-statement', renderMode: RenderMode.Prerender },
|
||||
|
||||
// Fallback
|
||||
{ path: '**', renderMode: RenderMode.Server }
|
||||
];
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
import { Routes } from '@angular/router';
|
||||
// Core components (eagerly loaded - needed for initial navigation)
|
||||
import { LogoutComponent } from './components/logout/logout.component';
|
||||
import { NotFoundComponent } from './components/not-found/not-found.component';
|
||||
import { TestSsrComponent } from './components/test-ssr/test-ssr.component';
|
||||
|
||||
import { EmailAuthorizedComponent } from './components/email-authorized/email-authorized.component';
|
||||
import { EmailVerificationComponent } from './components/email-verification/email-verification.component';
|
||||
import { LoginRegisterComponent } from './components/login-register/login-register.component';
|
||||
|
||||
// Guards
|
||||
import { AuthGuard } from './guards/auth.guard';
|
||||
import { ListingCategoryGuard } from './guards/listing-category.guard';
|
||||
import { UserListComponent } from './pages/admin/user-list/user-list.component';
|
||||
import { DetailsBusinessListingComponent } from './pages/details/details-business-listing/details-business-listing.component';
|
||||
import { DetailsCommercialPropertyListingComponent } from './pages/details/details-commercial-property-listing/details-commercial-property-listing.component';
|
||||
import { DetailsUserComponent } from './pages/details/details-user/details-user.component';
|
||||
|
||||
// Public pages - HomeComponent stays eagerly loaded as landing page
|
||||
import { HomeComponent } from './pages/home/home.component';
|
||||
import { BrokerListingsComponent } from './pages/listings/broker-listings/broker-listings.component';
|
||||
import { BusinessListingsComponent } from './pages/listings/business-listings/business-listings.component';
|
||||
import { CommercialPropertyListingsComponent } from './pages/listings/commercial-property-listings/commercial-property-listings.component';
|
||||
import { AccountComponent } from './pages/subscription/account/account.component';
|
||||
import { EditBusinessListingComponent } from './pages/subscription/edit-business-listing/edit-business-listing.component';
|
||||
import { EditCommercialPropertyListingComponent } from './pages/subscription/edit-commercial-property-listing/edit-commercial-property-listing.component';
|
||||
import { EmailUsComponent } from './pages/subscription/email-us/email-us.component';
|
||||
import { FavoritesComponent } from './pages/subscription/favorites/favorites.component';
|
||||
import { MyListingComponent } from './pages/subscription/my-listing/my-listing.component';
|
||||
import { SuccessComponent } from './pages/success/success.component';
|
||||
import { TermsOfUseComponent } from './pages/legal/terms-of-use.component';
|
||||
import { PrivacyStatementComponent } from './pages/legal/privacy-statement.component';
|
||||
|
||||
// Note: All listing and details components are now lazy-loaded for better initial bundle size
|
||||
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: 'test-ssr',
|
||||
@@ -33,17 +27,17 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'businessListings',
|
||||
component: BusinessListingsComponent,
|
||||
loadComponent: () => import('./pages/listings/business-listings/business-listings.component').then(m => m.BusinessListingsComponent),
|
||||
runGuardsAndResolvers: 'always',
|
||||
},
|
||||
{
|
||||
path: 'commercialPropertyListings',
|
||||
component: CommercialPropertyListingsComponent,
|
||||
loadComponent: () => import('./pages/listings/commercial-property-listings/commercial-property-listings.component').then(m => m.CommercialPropertyListingsComponent),
|
||||
runGuardsAndResolvers: 'always',
|
||||
},
|
||||
{
|
||||
path: 'brokerListings',
|
||||
component: BrokerListingsComponent,
|
||||
loadComponent: () => import('./pages/listings/broker-listings/broker-listings.component').then(m => m.BrokerListingsComponent),
|
||||
runGuardsAndResolvers: 'always',
|
||||
},
|
||||
{
|
||||
@@ -54,11 +48,11 @@ export const routes: Routes = [
|
||||
// Listings Details - New SEO-friendly slug-based URLs
|
||||
{
|
||||
path: 'business/:slug',
|
||||
component: DetailsBusinessListingComponent,
|
||||
loadComponent: () => import('./pages/details/details-business-listing/details-business-listing.component').then(m => m.DetailsBusinessListingComponent),
|
||||
},
|
||||
{
|
||||
path: 'commercial-property/:slug',
|
||||
component: DetailsCommercialPropertyListingComponent,
|
||||
loadComponent: () => import('./pages/details/details-commercial-property-listing/details-commercial-property-listing.component').then(m => m.DetailsCommercialPropertyListingComponent),
|
||||
},
|
||||
// Backward compatibility redirects for old UUID-based URLs
|
||||
{
|
||||
@@ -96,61 +90,61 @@ export const routes: Routes = [
|
||||
// User Details
|
||||
{
|
||||
path: 'details-user/:id',
|
||||
component: DetailsUserComponent,
|
||||
loadComponent: () => import('./pages/details/details-user/details-user.component').then(m => m.DetailsUserComponent),
|
||||
},
|
||||
// #########
|
||||
// User edit
|
||||
// User edit (lazy-loaded)
|
||||
{
|
||||
path: 'account',
|
||||
component: AccountComponent,
|
||||
loadComponent: () => import('./pages/subscription/account/account.component').then(m => m.AccountComponent),
|
||||
canActivate: [AuthGuard],
|
||||
},
|
||||
{
|
||||
path: 'account/:id',
|
||||
component: AccountComponent,
|
||||
loadComponent: () => import('./pages/subscription/account/account.component').then(m => m.AccountComponent),
|
||||
canActivate: [AuthGuard],
|
||||
},
|
||||
// #########
|
||||
// Create, Update Listings
|
||||
// Create, Update Listings (lazy-loaded)
|
||||
{
|
||||
path: 'editBusinessListing/:id',
|
||||
component: EditBusinessListingComponent,
|
||||
loadComponent: () => import('./pages/subscription/edit-business-listing/edit-business-listing.component').then(m => m.EditBusinessListingComponent),
|
||||
canActivate: [AuthGuard],
|
||||
},
|
||||
{
|
||||
path: 'createBusinessListing',
|
||||
component: EditBusinessListingComponent,
|
||||
loadComponent: () => import('./pages/subscription/edit-business-listing/edit-business-listing.component').then(m => m.EditBusinessListingComponent),
|
||||
canActivate: [AuthGuard],
|
||||
},
|
||||
{
|
||||
path: 'editCommercialPropertyListing/:id',
|
||||
component: EditCommercialPropertyListingComponent,
|
||||
loadComponent: () => import('./pages/subscription/edit-commercial-property-listing/edit-commercial-property-listing.component').then(m => m.EditCommercialPropertyListingComponent),
|
||||
canActivate: [AuthGuard],
|
||||
},
|
||||
{
|
||||
path: 'createCommercialPropertyListing',
|
||||
component: EditCommercialPropertyListingComponent,
|
||||
loadComponent: () => import('./pages/subscription/edit-commercial-property-listing/edit-commercial-property-listing.component').then(m => m.EditCommercialPropertyListingComponent),
|
||||
canActivate: [AuthGuard],
|
||||
},
|
||||
// #########
|
||||
// My Listings
|
||||
// My Listings (lazy-loaded)
|
||||
{
|
||||
path: 'myListings',
|
||||
component: MyListingComponent,
|
||||
loadComponent: () => import('./pages/subscription/my-listing/my-listing.component').then(m => m.MyListingComponent),
|
||||
canActivate: [AuthGuard],
|
||||
},
|
||||
// #########
|
||||
// My Favorites
|
||||
// My Favorites (lazy-loaded)
|
||||
{
|
||||
path: 'myFavorites',
|
||||
component: FavoritesComponent,
|
||||
loadComponent: () => import('./pages/subscription/favorites/favorites.component').then(m => m.FavoritesComponent),
|
||||
canActivate: [AuthGuard],
|
||||
},
|
||||
// #########
|
||||
// EMAil Us
|
||||
// Email Us (lazy-loaded)
|
||||
{
|
||||
path: 'emailUs',
|
||||
component: EmailUsComponent,
|
||||
loadComponent: () => import('./pages/subscription/email-us/email-us.component').then(m => m.EmailUsComponent),
|
||||
// canActivate: [AuthGuard],
|
||||
},
|
||||
// #########
|
||||
@@ -174,9 +168,11 @@ export const routes: Routes = [
|
||||
path: 'success',
|
||||
component: SuccessComponent,
|
||||
},
|
||||
// #########
|
||||
// Admin Pages (lazy-loaded)
|
||||
{
|
||||
path: 'admin/users',
|
||||
component: UserListComponent,
|
||||
loadComponent: () => import('./pages/admin/user-list/user-list.component').then(m => m.UserListComponent),
|
||||
canActivate: [AuthGuard],
|
||||
},
|
||||
// #########
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ConfirmationService } from './confirmation.service';
|
||||
<button
|
||||
(click)="confirmationService.reject()"
|
||||
type="button"
|
||||
class="absolute top-3 end-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
class="absolute top-3 end-2.5 text-gray-600 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
>
|
||||
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
|
||||
@@ -21,11 +21,11 @@ import { ConfirmationService } from './confirmation.service';
|
||||
<span class="sr-only">Close modal</span>
|
||||
</button>
|
||||
<div class="p-4 md:p-5 text-center">
|
||||
<svg class="mx-auto mb-4 text-gray-400 w-12 h-12 dark:text-gray-200" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<svg class="mx-auto mb-4 text-gray-600 w-12 h-12 dark:text-gray-200" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 11V6m0 8h.01M19 10a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
@let confirmation = (confirmationService.confirmation$ | async);
|
||||
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{{ confirmation?.message }}</h3>
|
||||
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-600">{{ confirmation?.message }}</h3>
|
||||
@if(confirmation?.buttons==='both'){
|
||||
<button
|
||||
(click)="confirmationService.accept()"
|
||||
@@ -37,7 +37,7 @@ import { ConfirmationService } from './confirmation.service';
|
||||
<button
|
||||
(click)="confirmationService.reject()"
|
||||
type="button"
|
||||
class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
|
||||
class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-600 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
|
||||
>
|
||||
No, cancel
|
||||
</button>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<button
|
||||
(click)="eMailService.reject()"
|
||||
type="button"
|
||||
class="end-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
class="end-2.5 text-gray-600 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
>
|
||||
<svg class="w-3 h-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="flex flex-col lg:flex-row items-center mb-4 lg:mb-0">
|
||||
<!-- <img src="/assets/images/header-logo.png" alt="BizMatch Logo" class="h-8 mb-2 lg:mb-0 lg:mr-4" /> -->
|
||||
<a routerLink="/home" class="flex items-center space-x-3 rtl:space-x-reverse">
|
||||
<img src="/assets/images/header-logo.png" class="h-8 mb-2 lg:mb-0 lg:mr-4" alt="BizMatch Logo" />
|
||||
<img src="/assets/images/header-logo.png" class="h-8 mb-2 lg:mb-0 lg:mr-4" alt="BizMatch Logo" width="120" height="32" />
|
||||
</a>
|
||||
<p class="text-sm text-neutral-600 text-center lg:text-left">© {{ currentYear }} Bizmatch All rights reserved.</p>
|
||||
</div>
|
||||
@@ -18,14 +18,13 @@
|
||||
|
||||
<div class="flex flex-col lg:flex-row items-center order-2 lg:order-3">
|
||||
<div class="mb-4 lg:mb-0 lg:mr-6 text-center lg:text-right">
|
||||
<p class="text-sm text-neutral-600 mb-1 lg:mb-2">BizMatch, Inc., 1001 Blucher Street, Corpus</p>
|
||||
<p class="text-sm text-neutral-600">Christi, Texas 78401</p>
|
||||
<p class="text-sm text-neutral-600 mb-1 lg:mb-2">Contact: BizMatch, Inc.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 lg:mb-0 flex flex-col items-center lg:items-end">
|
||||
<a class="text-sm text-neutral-600 mb-1 lg:mb-2 hover:text-primary-600 w-full"> <i
|
||||
<a href="tel:+1-800-840-6025" class="text-sm text-neutral-600 mb-1 lg:mb-2 hover:text-primary-600 w-full"> <i
|
||||
class="fas fa-phone-alt mr-2"></i>1-800-840-6025 </a>
|
||||
<a class="text-sm text-neutral-600 hover:text-primary-600"> <i
|
||||
<a href="mailto:info@bizmatch.net" class="text-sm text-neutral-600 hover:text-primary-600"> <i
|
||||
class="fas fa-envelope mr-2"></i>info@bizmatch.net </a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
<div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4">
|
||||
<a routerLink="/home" class="flex items-center space-x-3 rtl:space-x-reverse">
|
||||
<img src="/assets/images/header-logo.png" class="h-10 w-auto"
|
||||
alt="BizMatch - Business Marketplace for Buying and Selling Businesses" />
|
||||
alt="BizMatch - Business Marketplace for Buying and Selling Businesses" width="150" height="40" />
|
||||
</a>
|
||||
<div class="flex items-center md:order-2 space-x-3 rtl:space-x-reverse">
|
||||
<!-- Filter button -->
|
||||
@if(isFilterUrl()){
|
||||
|
||||
<div class="relative">
|
||||
<button type="button" id="sortDropdownButton"
|
||||
<button type="button" id="sortDropdownButton" aria-label="Sort listings" aria-haspopup="listbox"
|
||||
class="max-sm:hidden px-4 py-2 text-sm font-medium bg-white border border-neutral-200 rounded-lg hover:bg-neutral-100 hover:text-primary-600 dark:bg-neutral-800 dark:text-neutral-400 dark:border-neutral-600 dark:hover:text-white dark:hover:bg-neutral-700"
|
||||
(click)="toggleSortDropdown()"
|
||||
[ngClass]="{ 'text-primary-500': selectOptions.getSortByOption(sortBy) !== 'Sort', 'text-neutral-900': selectOptions.getSortByOption(sortBy) === 'Sort' }">
|
||||
@@ -192,7 +192,7 @@
|
||||
class="inline-flex items-center py-2 px-3 rounded hover:bg-neutral-100 md:hover:bg-transparent md:hover:text-primary-600 md:p-0 dark:text-white md:dark:hover:text-primary-500 dark:hover:bg-neutral-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-neutral-700"
|
||||
(click)="closeMenusAndSetCriteria('brokerListings')">
|
||||
<img src="/assets/images/icon_professionals.png" alt="Professionals"
|
||||
class="w-5 h-5 mr-2 object-contain bg-transparent" style="mix-blend-mode: darken;" />
|
||||
class="w-5 h-5 mr-2 object-contain bg-transparent" style="mix-blend-mode: darken;" width="20" height="20" />
|
||||
<span>Professionals</span>
|
||||
</a>
|
||||
</li>
|
||||
@@ -201,7 +201,7 @@
|
||||
</div>
|
||||
<!-- Mobile filter button -->
|
||||
<div class="md:hidden flex justify-center pb-4">
|
||||
<button (click)="toggleSortDropdown()" type="button" id="sortDropdownMobileButton"
|
||||
<button (click)="toggleSortDropdown()" type="button" id="sortDropdownMobileButton" aria-label="Sort listings" aria-haspopup="listbox"
|
||||
class="mx-4 w-1/2 px-4 py-2 text-sm font-medium bg-white border border-neutral-200 rounded-lg hover:bg-neutral-100 hover:text-primary-600 focus:ring-2 focus:ring-primary-600 focus:text-primary-600 dark:bg-neutral-800 dark:text-neutral-400 dark:border-neutral-600 dark:hover:text-white dark:hover:bg-neutral-700"
|
||||
[ngClass]="{ 'text-primary-500': selectOptions.getSortByOption(sortBy) !== 'Sort', 'text-neutral-900': selectOptions.getSortByOption(sortBy) === 'Sort' }">
|
||||
<i class="fas fa-sort mr-2"></i>{{ selectOptions.getSortByOption(sortBy) }}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { CommonModule, isPlatformBrowser } from '@angular/common';
|
||||
import { Component, HostListener, OnDestroy, OnInit, AfterViewInit, PLATFORM_ID, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NavigationEnd, Router, RouterModule } from '@angular/router';
|
||||
import { faUserGear } from '@fortawesome/free-solid-svg-icons';
|
||||
import { APP_ICONS } from '../../utils/fontawesome-icons';
|
||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||
import { Collapse, Dropdown, initFlowbite } from 'flowbite';
|
||||
import { filter, Observable, Subject, takeUntil } from 'rxjs';
|
||||
@@ -35,7 +35,7 @@ export class HeaderComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||
keycloakUser: KeycloakUser;
|
||||
user: User;
|
||||
activeItem;
|
||||
faUserGear = faUserGear;
|
||||
faUserGear = APP_ICONS.faUserGear;
|
||||
profileUrl: string;
|
||||
env = environment;
|
||||
private filterDropdown: Dropdown | null = null;
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
placeholder="Please enter E-Mail Address"
|
||||
class="w-full px-3 py-2 pl-10 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<fa-icon [icon]="envelope" class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></fa-icon>
|
||||
<fa-icon [icon]="envelope" class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-600"></fa-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
placeholder="Please enter Password"
|
||||
class="w-full px-3 py-2 pl-10 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<fa-icon [icon]="lock" class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></fa-icon>
|
||||
<fa-icon [icon]="lock" class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-600"></fa-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
placeholder="Repeat Password"
|
||||
class="w-full px-3 py-2 pl-10 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<fa-icon [icon]="lock" class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></fa-icon>
|
||||
<fa-icon [icon]="lock" class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-600"></fa-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Component } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||
import { faArrowRight, faEnvelope, faLock, faUserPlus } from '@fortawesome/free-solid-svg-icons';
|
||||
import { APP_ICONS } from '../../utils/fontawesome-icons';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { LoadingService } from '../../services/loading.service';
|
||||
@Component({
|
||||
@@ -18,10 +18,10 @@ export class LoginRegisterComponent {
|
||||
confirmPassword: string = '';
|
||||
isLoginMode: boolean = true; // true: Login, false: Registration
|
||||
errorMessage: string = '';
|
||||
envelope = faEnvelope;
|
||||
lock = faLock;
|
||||
arrowRight = faArrowRight;
|
||||
userplus = faUserPlus;
|
||||
envelope = APP_ICONS.faEnvelope;
|
||||
lock = APP_ICONS.faLock;
|
||||
arrowRight = APP_ICONS.faArrowRight;
|
||||
userplus = APP_ICONS.faUserPlus;
|
||||
constructor(private authService: AuthService, private route: ActivatedRoute, private router: Router, private loadingService: LoadingService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex space-x-4 mb-4">
|
||||
<button class="text-primary-600 font-medium border-b-2 border-primary-600 pb-2">Filter ({{ numberOfResults$ | async }})</button>
|
||||
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-primary-500" (click)="clearFilter()"></i>
|
||||
<button type="button" aria-label="Clear all filters" data-tooltip-target="tooltip-light" class="flex self-center ml-2 cursor-pointer text-primary-500 p-1" (click)="clearFilter()"><i class="fa-solid fa-trash-can"></i></button>
|
||||
<div id="tooltip-light" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip">
|
||||
Clear all Filter
|
||||
<div class="tooltip-arrow" data-popper-arrow></div>
|
||||
@@ -25,22 +25,22 @@
|
||||
<!-- Display active filters as tags -->
|
||||
<div class="flex flex-wrap gap-2 mb-4" *ngIf="hasActiveFilters()">
|
||||
<span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.types?.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
Types: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Types: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
|
||||
</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">
|
||||
Professional Name: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Professional Name: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.companyName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
Company: {{ criteria.companyName }} <button (click)="removeFilter('companyName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Company: {{ criteria.companyName }} <button (click)="removeFilter('companyName')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.counties?.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
Areas Served: {{ criteria.counties.join(', ') }} <button (click)="removeFilter('counties')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Areas Served: {{ criteria.counties.join(', ') }} <button (click)="removeFilter('counties')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
|
||||
</span>
|
||||
</div>
|
||||
@if(criteria.criteriaType==='brokerListings') {
|
||||
@@ -48,7 +48,7 @@
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="state" class="block mb-2 text-sm font-medium text-neutral-900">Location - State</label>
|
||||
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select>
|
||||
<ng-select id="state-select" aria-label="Location - State" class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select>
|
||||
</div>
|
||||
<div>
|
||||
<app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-neutral-900 font-medium" [state]="criteria.state"></app-validated-city>
|
||||
@@ -141,7 +141,7 @@
|
||||
<div *ngIf="!isModal" class="space-y-6 pb-10">
|
||||
<div class="flex space-x-4 mb-4">
|
||||
<h3 class="text-xl font-semibold text-neutral-900">Filter ({{ numberOfResults$ | async }})</h3>
|
||||
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-primary-500" (click)="clearFilter()"></i>
|
||||
<button type="button" aria-label="Clear all filters" data-tooltip-target="tooltip-light" class="flex self-center ml-2 cursor-pointer text-primary-500 p-1" (click)="clearFilter()"><i class="fa-solid fa-trash-can"></i></button>
|
||||
<div id="tooltip-light" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip">
|
||||
Clear all Filter
|
||||
<div class="tooltip-arrow" data-popper-arrow></div>
|
||||
@@ -150,29 +150,29 @@
|
||||
<!-- Display active filters as tags -->
|
||||
<div class="flex flex-wrap gap-2" *ngIf="hasActiveFilters()">
|
||||
<span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.types?.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
Types: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Types: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
|
||||
</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">
|
||||
Professional Name: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Professional Name: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.companyName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
Company: {{ criteria.companyName }} <button (click)="removeFilter('companyName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Company: {{ criteria.companyName }} <button (click)="removeFilter('companyName')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.counties?.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
Areas Served: {{ criteria.counties.join(', ') }} <button (click)="removeFilter('counties')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Areas Served: {{ criteria.counties.join(', ') }} <button (click)="removeFilter('counties')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
|
||||
</span>
|
||||
</div>
|
||||
@if(criteria.criteriaType==='brokerListings') {
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="state" class="block mb-2 text-sm font-medium text-neutral-900">Location - State</label>
|
||||
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select>
|
||||
<ng-select id="state-select" aria-label="Location - State" class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select>
|
||||
</div>
|
||||
<div>
|
||||
<app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-neutral-900 font-medium" [state]="criteria.state"></app-validated-city>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex space-x-4 mb-4">
|
||||
<button class="text-primary-600 font-medium border-b-2 border-primary-600 pb-2">Filter ({{ numberOfResults$ | async }})</button>
|
||||
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-primary-500" (click)="clearFilter()"></i>
|
||||
<button type="button" aria-label="Clear all filters" data-tooltip-target="tooltip-light" class="flex self-center ml-2 cursor-pointer text-primary-500 p-1" (click)="clearFilter()"><i class="fa-solid fa-trash-can"></i></button>
|
||||
<div id="tooltip-light" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip">
|
||||
Clear all Filter
|
||||
<div class="tooltip-arrow" data-popper-arrow></div>
|
||||
@@ -25,19 +25,22 @@
|
||||
<!-- Display active filters as tags -->
|
||||
<div class="flex flex-wrap gap-2 mb-4" *ngIf="hasActiveFilters()">
|
||||
<span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.minPrice || criteria.maxPrice" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.types.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
|
||||
</span>
|
||||
<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 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
|
||||
</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 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
|
||||
</span>
|
||||
</div>
|
||||
@if(criteria.criteriaType==='commercialPropertyListings') {
|
||||
@@ -45,7 +48,7 @@
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="state" class="block mb-2 text-sm font-medium text-neutral-900">Location - State</label>
|
||||
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select>
|
||||
<ng-select id="state-select" aria-label="Location - State" class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select>
|
||||
</div>
|
||||
<div>
|
||||
<app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-neutral-900 font-medium" [state]="criteria.state"></app-validated-city>
|
||||
@@ -113,6 +116,17 @@
|
||||
placeholder="Select categories"
|
||||
></ng-select>
|
||||
</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>
|
||||
}
|
||||
@@ -123,7 +137,7 @@
|
||||
<div *ngIf="!isModal" class="space-y-6 pb-10">
|
||||
<div class="flex space-x-4 mb-4">
|
||||
<h3 class="text-xl font-semibold text-neutral-900">Filter ({{ numberOfResults$ | async }})</h3>
|
||||
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-primary-500" (click)="clearFilter()"></i>
|
||||
<button type="button" aria-label="Clear all filters" data-tooltip-target="tooltip-light" class="flex self-center ml-2 cursor-pointer text-primary-500 p-1" (click)="clearFilter()"><i class="fa-solid fa-trash-can"></i></button>
|
||||
<div id="tooltip-light" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip">
|
||||
Clear all Filter
|
||||
<div class="tooltip-arrow" data-popper-arrow></div>
|
||||
@@ -132,26 +146,29 @@
|
||||
<!-- Display active filters as tags -->
|
||||
<div class="flex flex-wrap gap-2" *ngIf="hasActiveFilters()">
|
||||
<span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.minPrice || criteria.maxPrice" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.types.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
|
||||
</span>
|
||||
<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 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
|
||||
</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 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
|
||||
</span>
|
||||
</div>
|
||||
@if(criteria.criteriaType==='commercialPropertyListings') {
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="state" class="block mb-2 text-sm font-medium text-neutral-900">Location - State</label>
|
||||
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select>
|
||||
<ng-select id="state-select" aria-label="Location - State" class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select>
|
||||
</div>
|
||||
<div>
|
||||
<app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-neutral-900 font-medium" [state]="criteria.state"></app-validated-city>
|
||||
@@ -217,6 +234,17 @@
|
||||
placeholder="e.g. Office Space"
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -48,7 +48,7 @@ export class SearchModalCommercialComponent implements OnInit, OnDestroy {
|
||||
private filterStateService: FilterStateService,
|
||||
private listingService: ListingsService,
|
||||
private searchService: SearchService,
|
||||
) {}
|
||||
) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
// Load counties
|
||||
@@ -143,6 +143,9 @@ export class SearchModalCommercialComponent implements OnInit, OnDestroy {
|
||||
case 'title':
|
||||
updates.title = null;
|
||||
break;
|
||||
case 'brokerName':
|
||||
updates.brokerName = null;
|
||||
break;
|
||||
}
|
||||
|
||||
this.updateCriteria(updates);
|
||||
@@ -280,6 +283,7 @@ export class SearchModalCommercialComponent implements OnInit, OnDestroy {
|
||||
minPrice: null,
|
||||
maxPrice: null,
|
||||
title: null,
|
||||
brokerName: null,
|
||||
prompt: null,
|
||||
page: 1,
|
||||
start: 0,
|
||||
@@ -290,7 +294,15 @@ export class SearchModalCommercialComponent implements OnInit, OnDestroy {
|
||||
hasActiveFilters(): boolean {
|
||||
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 {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex space-x-4 mb-4">
|
||||
<button class="text-primary-600 font-medium border-b-2 border-primary-600 pb-2">Filter ({{ numberOfResults$ | async }})</button>
|
||||
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-primary-500" (click)="clearFilter()"></i>
|
||||
<button type="button" aria-label="Clear all filters" data-tooltip-target="tooltip-light" class="flex self-center ml-2 cursor-pointer text-primary-500 p-1" (click)="clearFilter()"><i class="fa-solid fa-trash-can"></i></button>
|
||||
<div id="tooltip-light" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip">
|
||||
Clear all Filter
|
||||
<div class="tooltip-arrow" data-popper-arrow></div>
|
||||
@@ -25,44 +25,44 @@
|
||||
<!-- Display active filters as tags -->
|
||||
<div class="flex flex-wrap gap-2 mb-4" *ngIf="hasActiveFilters()">
|
||||
<span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
State: {{ criteria.state }} <button (click)="removeFilter('state')" aria-label="Remove state filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" aria-label="Remove city filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.minPrice || criteria.maxPrice" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" aria-label="Remove price filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.minRevenue || criteria.maxRevenue" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
Revenue: {{ criteria.minRevenue || 'Any' }} - {{ criteria.maxRevenue || 'Any' }} <button (click)="removeFilter('revenue')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Revenue: {{ criteria.minRevenue || 'Any' }} - {{ criteria.maxRevenue || 'Any' }} <button (click)="removeFilter('revenue')" aria-label="Remove revenue filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.minCashFlow || criteria.maxCashFlow" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
Cashflow: {{ criteria.minCashFlow || 'Any' }} - {{ criteria.maxCashFlow || 'Any' }} <button (click)="removeFilter('cashflow')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Cashflow: {{ criteria.minCashFlow || 'Any' }} - {{ criteria.maxCashFlow || 'Any' }} <button (click)="removeFilter('cashflow')" aria-label="Remove cashflow filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
|
||||
</span>
|
||||
<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')" aria-label="Remove title filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.types.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" aria-label="Remove categories filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
|
||||
</span>
|
||||
<span *ngIf="selectedPropertyType" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
Property Type: {{ getSelectedPropertyTypeName() }} <button (click)="removeFilter('propertyType')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Property Type: {{ getSelectedPropertyTypeName() }} <button (click)="removeFilter('propertyType')" aria-label="Remove property type filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.minNumberEmployees || criteria.maxNumberEmployees" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
Employees: {{ criteria.minNumberEmployees || 'Any' }} - {{ criteria.maxNumberEmployees || 'Any' }} <button (click)="removeFilter('employees')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Employees: {{ criteria.minNumberEmployees || 'Any' }} - {{ criteria.maxNumberEmployees || 'Any' }} <button (click)="removeFilter('employees')" aria-label="Remove employees filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.establishedMin" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
Established: {{ criteria.establishedMin || 'Any' }} <button (click)="removeFilter('established')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Established: {{ criteria.establishedMin || 'Any' }} <button (click)="removeFilter('established')" aria-label="Remove established filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
|
||||
</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>
|
||||
Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" aria-label="Remove broker filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="state" class="block mb-2 text-sm font-medium text-neutral-900">Location - State</label>
|
||||
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select>
|
||||
<label for="state-select" class="block mb-2 text-sm font-medium text-neutral-900">Location - State</label>
|
||||
<ng-select id="state-select" class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state" aria-label="Location - State"></ng-select>
|
||||
</div>
|
||||
<div>
|
||||
<app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-neutral-900 font-medium" [state]="criteria.state"></app-validated-city>
|
||||
@@ -148,6 +148,7 @@
|
||||
[multiple]="true"
|
||||
[closeOnSelect]="true"
|
||||
placeholder="Select categories"
|
||||
aria-label="Category"
|
||||
></ng-select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -160,6 +161,7 @@
|
||||
[ngModel]="selectedPropertyType"
|
||||
(ngModelChange)="onPropertyTypeChange($event)"
|
||||
placeholder="Select property type"
|
||||
aria-label="Type of Property"
|
||||
></ng-select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -220,7 +222,7 @@
|
||||
<div *ngIf="!isModal" class="space-y-6">
|
||||
<div class="flex space-x-4 mb-4">
|
||||
<h3 class="text-xl font-semibold text-neutral-900">Filter ({{ numberOfResults$ | async }})</h3>
|
||||
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-primary-500" (click)="clearFilter()"></i>
|
||||
<button type="button" aria-label="Clear all filters" data-tooltip-target="tooltip-light" class="flex self-center ml-2 cursor-pointer text-primary-500 p-1" (click)="clearFilter()"><i class="fa-solid fa-trash-can"></i></button>
|
||||
<div id="tooltip-light" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip">
|
||||
Clear all Filter
|
||||
<div class="tooltip-arrow" data-popper-arrow></div>
|
||||
@@ -229,44 +231,44 @@
|
||||
<!-- Display active filters as tags -->
|
||||
<div class="flex flex-wrap gap-2" *ngIf="hasActiveFilters()">
|
||||
<span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
State: {{ criteria.state }} <button (click)="removeFilter('state')" aria-label="Remove state filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" aria-label="Remove city filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.minPrice || criteria.maxPrice" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" aria-label="Remove price filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.minRevenue || criteria.maxRevenue" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
Revenue: {{ criteria.minRevenue || 'Any' }} - {{ criteria.maxRevenue || 'Any' }} <button (click)="removeFilter('revenue')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Revenue: {{ criteria.minRevenue || 'Any' }} - {{ criteria.maxRevenue || 'Any' }} <button (click)="removeFilter('revenue')" aria-label="Remove revenue filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.minCashFlow || criteria.maxCashFlow" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
Cashflow: {{ criteria.minCashFlow || 'Any' }} - {{ criteria.maxCashFlow || 'Any' }} <button (click)="removeFilter('cashflow')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Cashflow: {{ criteria.minCashFlow || 'Any' }} - {{ criteria.maxCashFlow || 'Any' }} <button (click)="removeFilter('cashflow')" aria-label="Remove cashflow filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
|
||||
</span>
|
||||
<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')" aria-label="Remove title filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.types.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" aria-label="Remove categories filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
|
||||
</span>
|
||||
<span *ngIf="selectedPropertyType" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
Property Type: {{ getSelectedPropertyTypeName() }} <button (click)="removeFilter('propertyType')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Property Type: {{ getSelectedPropertyTypeName() }} <button (click)="removeFilter('propertyType')" aria-label="Remove property type filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.minNumberEmployees || criteria.maxNumberEmployees" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
Employees: {{ criteria.minNumberEmployees || 'Any' }} - {{ criteria.maxNumberEmployees || 'Any' }} <button (click)="removeFilter('employees')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Employees: {{ criteria.minNumberEmployees || 'Any' }} - {{ criteria.maxNumberEmployees || 'Any' }} <button (click)="removeFilter('employees')" aria-label="Remove employees filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
|
||||
</span>
|
||||
<span *ngIf="criteria.establishedMin" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
|
||||
Years established: {{ criteria.establishedMin || 'Any' }} <button (click)="removeFilter('established')" class="ml-1 text-red-500 hover:text-red-700">×</button>
|
||||
Years established: {{ criteria.establishedMin || 'Any' }} <button (click)="removeFilter('established')" aria-label="Remove established filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
|
||||
</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>
|
||||
Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" aria-label="Remove broker filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
|
||||
</span>
|
||||
</div>
|
||||
@if(criteria.criteriaType==='businessListings') {
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="state" class="block mb-2 text-sm font-medium text-neutral-900">Location - State</label>
|
||||
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select>
|
||||
<ng-select id="state-select" aria-label="Location - State" class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select>
|
||||
</div>
|
||||
<div>
|
||||
<app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-neutral-900 font-medium" [state]="criteria.state"></app-validated-city>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, forwardRef } from '@angular/core';
|
||||
import { Component, forwardRef, ViewEncapsulation } from '@angular/core';
|
||||
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { QuillModule } from 'ngx-quill';
|
||||
import { BaseInputComponent } from '../base-input/base-input.component';
|
||||
@@ -9,9 +9,11 @@ import { ValidationMessagesService } from '../validation-messages.service';
|
||||
@Component({
|
||||
selector: 'app-validated-quill',
|
||||
templateUrl: './validated-quill.component.html',
|
||||
styles: `quill-editor {
|
||||
styleUrls: ['../../../../node_modules/quill/dist/quill.snow.css'],
|
||||
styles: [`quill-editor {
|
||||
width: 100%;
|
||||
}`,
|
||||
}`],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, QuillModule, TooltipComponent],
|
||||
providers: [
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, PLATFORM_ID, Inject } from '@angular/core';
|
||||
import { CanActivate, Router } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { createLogger } from '../utils/utils';
|
||||
const logger = createLogger('AuthGuard');
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(private authService: AuthService, private router: Router) {}
|
||||
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private router: Router,
|
||||
@Inject(PLATFORM_ID) private platformId: Object
|
||||
) {}
|
||||
|
||||
async canActivate(): Promise<boolean> {
|
||||
// 1. SSR CHECK: Wenn wir auf dem Server sind, immer erlauben!
|
||||
// Der Server soll nicht redirecten, sondern einfach das HTML rendern.
|
||||
if (!isPlatformBrowser(this.platformId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. CLIENT CHECK: Das läuft nur im Browser
|
||||
const token = await this.authService.getToken();
|
||||
if (token) {
|
||||
return true;
|
||||
|
||||
@@ -5,34 +5,37 @@
|
||||
}
|
||||
|
||||
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg overflow-hidden relative">
|
||||
<button (click)="historyService.goBack()"
|
||||
class="absolute top-4 right-4 bg-red-500 text-white rounded-full w-8 h-8 flex items-center justify-center hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 print:hidden">
|
||||
<button (click)="historyService.goBack()" aria-label="Go back"
|
||||
class="absolute top-4 right-4 bg-red-500 text-white rounded-full w-10 h-10 flex items-center justify-center hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 print:hidden">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
@if(listing){
|
||||
<div class="p-6 flex flex-col lg:flex-row">
|
||||
<!-- Left column -->
|
||||
<div class="w-full lg:w-1/2 pr-0 lg:pr-6">
|
||||
<h1 class="text-2xl font-bold mb-4">{{ listing.title }}</h1>
|
||||
<p class="mb-4" [innerHTML]="description"></p>
|
||||
<h1 class="text-2xl font-bold mb-4 break-words">{{ listing.title }}</h1>
|
||||
<p class="mb-4 break-words" [innerHTML]="description"></p>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div *ngFor="let detail of listingDetails; let i = index" class="flex flex-col sm:flex-row"
|
||||
[ngClass]="{ 'bg-neutral-100': i % 2 === 0 }">
|
||||
<div class="w-full sm:w-1/3 font-semibold p-2">{{ detail.label }}</div>
|
||||
|
||||
<div class="w-full sm:w-2/3 p-2" *ngIf="!detail.isHtml && !detail.isListingBy">{{ detail.value }}</div>
|
||||
<div class="w-full sm:w-2/3 p-2 break-words" *ngIf="!detail.isHtml && !detail.isListingBy">{{ detail.value
|
||||
}}</div>
|
||||
|
||||
<div class="w-full sm:w-2/3 p-2 flex space-x-2" [innerHTML]="detail.value"
|
||||
<div class="w-full sm:w-2/3 p-2 flex space-x-2 break-words" [innerHTML]="detail.value"
|
||||
*ngIf="detail.isHtml && !detail.isListingBy"></div>
|
||||
|
||||
<div class="w-full sm:w-2/3 p-2 flex space-x-2" *ngIf="detail.isListingBy && listingUser">
|
||||
<a routerLink="/details-user/{{ listingUser.id }}"
|
||||
class="text-primary-600 dark:text-primary-500 hover:underline">{{ listingUser.firstname }} {{
|
||||
listingUser.lastname }}</a>
|
||||
<img *ngIf="listing.imageName"
|
||||
ngSrc="{{ env.imageBaseUrl }}/pictures/logo/{{ listing.imageName }}.avif?_ts={{ ts }}"
|
||||
class="mr-5 lg:mb-0" style="max-height: 30px; max-width: 100px" width="100" height="30" alt="Business logo for {{ listingUser.firstname }} {{ listingUser.lastname }}" />
|
||||
<div class="relative w-[100px] h-[30px] mr-5 lg:mb-0" *ngIf="listing.imageName">
|
||||
<img ngSrc="{{ env.imageBaseUrl }}/pictures/logo/{{ listing.imageName }}.avif?_ts={{ ts }}" fill
|
||||
class="object-contain"
|
||||
alt="Business logo for {{ listingUser.firstname }} {{ listingUser.lastname }}" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,7 +51,7 @@
|
||||
} @if(user){
|
||||
<div class="inline">
|
||||
<button class="share share-save text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||
(click)="save()" [disabled]="listing.favoritesForUser.includes(user.email)">
|
||||
(click)="toggleFavorite()">
|
||||
<i class="fa-regular fa-heart"></i>
|
||||
@if(listing.favoritesForUser.includes(user.email)){
|
||||
<span class="ml-2">Saved ...</span>
|
||||
@@ -142,10 +145,13 @@
|
||||
<h2 class="text-2xl font-bold mb-6 text-gray-900">Frequently Asked Questions</h2>
|
||||
<div class="space-y-4">
|
||||
@for (faq of businessFAQs; track $index) {
|
||||
<details class="group border border-gray-200 rounded-lg overflow-hidden hover:border-primary-300 transition-colors">
|
||||
<summary class="flex items-center justify-between cursor-pointer p-4 bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||
<details
|
||||
class="group border border-gray-200 rounded-lg overflow-hidden hover:border-primary-300 transition-colors">
|
||||
<summary
|
||||
class="flex items-center justify-between cursor-pointer p-4 bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||
<h3 class="text-lg font-semibold text-gray-900">{{ faq.question }}</h3>
|
||||
<svg class="w-5 h-5 text-gray-600 group-open:rotate-180 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-5 h-5 text-gray-600 group-open:rotate-180 transition-transform" fill="none"
|
||||
stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</summary>
|
||||
@@ -157,7 +163,8 @@
|
||||
</div>
|
||||
<div class="mt-6 p-4 bg-primary-50 border-l-4 border-primary-500 rounded">
|
||||
<p class="text-sm text-gray-700">
|
||||
<strong class="text-primary-700">Have more questions?</strong> Contact the seller directly using the form above or reach out to our support team for assistance.
|
||||
<strong class="text-primary-700">Have more questions?</strong> Contact the seller directly using the form
|
||||
above or reach out to our support team for assistance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,13 +31,27 @@ import dayjs from 'dayjs';
|
||||
import { AuthService } from '../../../services/auth.service';
|
||||
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({
|
||||
selector: 'app-details-business-listing',
|
||||
standalone: true,
|
||||
imports: [SharedModule, ValidatedInputComponent, ValidatedTextareaComponent, ValidatedNgSelectComponent, LeafletModule, BreadcrumbsComponent, ShareButton, NgOptimizedImage],
|
||||
providers: [],
|
||||
providers: [
|
||||
provideShareButtonsOptions(
|
||||
shareIcons(),
|
||||
withConfig({
|
||||
debug: false,
|
||||
sharerMethod: SharerMethods.Anchor,
|
||||
}),
|
||||
),
|
||||
],
|
||||
templateUrl: './details-business-listing.component.html',
|
||||
styleUrl: '../details.scss',
|
||||
styleUrls: [
|
||||
'../details.scss',
|
||||
'../../../../../node_modules/leaflet/dist/leaflet.css',
|
||||
'../../../../../node_modules/ngx-sharebuttons/themes/default.scss'
|
||||
],
|
||||
})
|
||||
export class DetailsBusinessListingComponent extends BaseDetailsComponent {
|
||||
// listings: Array<BusinessListing>;
|
||||
@@ -379,13 +393,27 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async save() {
|
||||
await this.listingsService.addToFavorites(this.listing.id, 'business');
|
||||
this.listing.favoritesForUser.push(this.user.email);
|
||||
this.auditService.createEvent(this.listing.id, 'favorite', this.user?.email);
|
||||
}
|
||||
isAlreadyFavorite() {
|
||||
return this.listing.favoritesForUser.includes(this.user.email);
|
||||
async toggleFavorite() {
|
||||
try {
|
||||
const isFavorited = this.listing.favoritesForUser.includes(this.user.email);
|
||||
|
||||
if (isFavorited) {
|
||||
// Remove from favorites
|
||||
await this.listingsService.removeFavorite(this.listing.id, 'business');
|
||||
this.listing.favoritesForUser = this.listing.favoritesForUser.filter(
|
||||
email => email !== this.user.email
|
||||
);
|
||||
} else {
|
||||
// Add to favorites
|
||||
await this.listingsService.addToFavorites(this.listing.id, 'business');
|
||||
this.listing.favoritesForUser.push(this.user.email);
|
||||
this.auditService.createEvent(this.listing.id, 'favorite', this.user?.email);
|
||||
}
|
||||
|
||||
this.cdref.detectChanges();
|
||||
} catch (error) {
|
||||
console.error('Error toggling favorite:', error);
|
||||
}
|
||||
}
|
||||
async showShareByEMail() {
|
||||
const result = await this.emailService.showShareByEMail({
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
<div class="bg-white drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg rounded-lg overflow-hidden">
|
||||
@if(listing){
|
||||
<div class="p-6 relative">
|
||||
<h1 class="text-3xl font-bold mb-4">{{ listing?.title }}</h1>
|
||||
<button (click)="historyService.goBack()"
|
||||
class="print:hidden absolute top-4 right-4 bg-red-500 text-white rounded-full w-8 h-8 flex items-center justify-center hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50">
|
||||
<h1 class="text-3xl font-bold mb-4 break-words">{{ listing?.title }}</h1>
|
||||
<button (click)="historyService.goBack()" aria-label="Go back"
|
||||
class="print:hidden absolute top-4 right-4 bg-red-500 text-white rounded-full w-10 h-10 flex items-center justify-center hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
<div class="flex flex-col lg:flex-row">
|
||||
<div class="w-full lg:w-1/2 pr-0 lg:pr-4">
|
||||
<p class="mb-4" [innerHTML]="description"></p>
|
||||
<p class="mb-4 break-words" [innerHTML]="description"></p>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div *ngFor="let detail of propertyDetails; let i = index" class="flex flex-col sm:flex-row"
|
||||
@@ -20,10 +20,11 @@
|
||||
<div class="w-full sm:w-1/3 font-semibold p-2">{{ detail.label }}</div>
|
||||
|
||||
<!-- Standard Text -->
|
||||
<div class="w-full sm:w-2/3 p-2" *ngIf="!detail.isHtml && !detail.isListingBy">{{ detail.value }}</div>
|
||||
<div class="w-full sm:w-2/3 p-2 break-words" *ngIf="!detail.isHtml && !detail.isListingBy">{{ detail.value
|
||||
}}</div>
|
||||
|
||||
<!-- HTML Content (nicht für RouterLink) -->
|
||||
<div class="w-full sm:w-2/3 p-2 flex space-x-2" [innerHTML]="detail.value"
|
||||
<div class="w-full sm:w-2/3 p-2 flex space-x-2 break-words" [innerHTML]="detail.value"
|
||||
*ngIf="detail.isHtml && !detail.isListingBy"></div>
|
||||
|
||||
<!-- Speziell für Listing By mit RouterLink -->
|
||||
@@ -31,9 +32,11 @@
|
||||
<a [routerLink]="['/details-user', detail.user.id]"
|
||||
class="text-primary-600 dark:text-primary-500 hover:underline"> {{ detail.user.firstname }} {{
|
||||
detail.user.lastname }} </a>
|
||||
<img *ngIf="detail.user.hasCompanyLogo"
|
||||
[ngSrc]="detail.imageBaseUrl + '/pictures/logo/' + detail.imagePath + '.avif?_ts=' + detail.ts"
|
||||
class="mr-5 lg:mb-0" style="max-height: 30px; max-width: 100px" width="100" height="30" alt="Company logo for {{ detail.user.firstname }} {{ detail.user.lastname }}" />
|
||||
<div class="relative w-[100px] h-[30px] mr-5 lg:mb-0" *ngIf="detail.user.hasCompanyLogo">
|
||||
<img [ngSrc]="detail.imageBaseUrl + '/pictures/logo/' + detail.imagePath + '.avif?_ts=' + detail.ts"
|
||||
fill class="object-contain"
|
||||
alt="Company logo for {{ detail.user.firstname }} {{ detail.user.lastname }}" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,7 +52,7 @@
|
||||
} @if(user){
|
||||
<div class="inline">
|
||||
<button class="share share-save text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||
(click)="save()" [disabled]="listing.favoritesForUser.includes(user.email)">
|
||||
(click)="toggleFavorite()">
|
||||
<i class="fa-regular fa-heart"></i>
|
||||
@if(listing.favoritesForUser.includes(user.email)){
|
||||
<span class="ml-2">Saved ...</span>
|
||||
@@ -156,10 +159,13 @@
|
||||
<h2 class="text-2xl font-bold mb-6 text-gray-900">Frequently Asked Questions</h2>
|
||||
<div class="space-y-4">
|
||||
@for (faq of propertyFAQs; track $index) {
|
||||
<details class="group border border-gray-200 rounded-lg overflow-hidden hover:border-primary-300 transition-colors">
|
||||
<summary class="flex items-center justify-between cursor-pointer p-4 bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||
<details
|
||||
class="group border border-gray-200 rounded-lg overflow-hidden hover:border-primary-300 transition-colors">
|
||||
<summary
|
||||
class="flex items-center justify-between cursor-pointer p-4 bg-gray-50 hover:bg-gray-100 transition-colors">
|
||||
<h3 class="text-lg font-semibold text-gray-900">{{ faq.question }}</h3>
|
||||
<svg class="w-5 h-5 text-gray-600 group-open:rotate-180 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-5 h-5 text-gray-600 group-open:rotate-180 transition-transform" fill="none"
|
||||
stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</summary>
|
||||
@@ -171,7 +177,8 @@
|
||||
</div>
|
||||
<div class="mt-6 p-4 bg-primary-50 border-l-4 border-primary-500 rounded">
|
||||
<p class="text-sm text-gray-700">
|
||||
<strong class="text-primary-700">Have more questions?</strong> Contact the seller directly using the form above or reach out to our support team for assistance.
|
||||
<strong class="text-primary-700">Have more questions?</strong> Contact the seller directly using the form
|
||||
above or reach out to our support team for assistance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { 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 { ActivatedRoute, Router } from '@angular/router';
|
||||
import { LeafletModule } from '@bluehalo/ngx-leaflet';
|
||||
import { faTimes } from '@fortawesome/free-solid-svg-icons';
|
||||
import { APP_ICONS } from '../../../utils/fontawesome-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { GalleryModule, ImageItem } from 'ng-gallery';
|
||||
import { GALLERY_CONFIG, GalleryConfig, GalleryModule, ImageItem } from 'ng-gallery';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { CommercialPropertyListing, EventTypeEnum, ShareByEMail, User } from '../../../../../../bizmatch-server/src/models/db.model';
|
||||
import { CommercialPropertyListingCriteria, ErrorResponse, KeycloakUser, MailInfo } from '../../../../../../bizmatch-server/src/models/main.model';
|
||||
@@ -30,14 +30,35 @@ import { createMailInfo, map2User } from '../../../utils/utils';
|
||||
import { BaseDetailsComponent } from '../base-details.component';
|
||||
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
|
||||
import { ShareButton } from 'ngx-sharebuttons/button';
|
||||
import { provideShareButtonsOptions, SharerMethods, withConfig } from 'ngx-sharebuttons';
|
||||
import { shareIcons } from 'ngx-sharebuttons/icons';
|
||||
|
||||
@Component({
|
||||
selector: 'app-details-commercial-property-listing',
|
||||
standalone: true,
|
||||
imports: [SharedModule, ValidatedInputComponent, ValidatedTextareaComponent, ValidatedNgSelectComponent, GalleryModule, LeafletModule, BreadcrumbsComponent, ShareButton, NgOptimizedImage],
|
||||
providers: [],
|
||||
providers: [
|
||||
provideShareButtonsOptions(
|
||||
shareIcons(),
|
||||
withConfig({
|
||||
debug: false,
|
||||
sharerMethod: SharerMethods.Anchor,
|
||||
}),
|
||||
),
|
||||
{
|
||||
provide: GALLERY_CONFIG,
|
||||
useValue: {
|
||||
autoHeight: true,
|
||||
imageSize: 'cover',
|
||||
} as GalleryConfig,
|
||||
},
|
||||
],
|
||||
templateUrl: './details-commercial-property-listing.component.html',
|
||||
styleUrl: '../details.scss',
|
||||
styleUrls: [
|
||||
'../details.scss',
|
||||
'../../../../../node_modules/leaflet/dist/leaflet.css',
|
||||
'../../../../../node_modules/ngx-sharebuttons/themes/default.scss'
|
||||
],
|
||||
})
|
||||
export class DetailsCommercialPropertyListingComponent extends BaseDetailsComponent {
|
||||
responsiveOptions = [
|
||||
@@ -69,7 +90,7 @@ export class DetailsCommercialPropertyListingComponent extends BaseDetailsCompon
|
||||
ts = new Date().getTime();
|
||||
env = environment;
|
||||
errorResponse: ErrorResponse;
|
||||
faTimes = faTimes;
|
||||
faTimes = APP_ICONS.faTimes;
|
||||
propertyDetails = [];
|
||||
images: Array<ImageItem> = [];
|
||||
relatedListings: CommercialPropertyListing[] = [];
|
||||
@@ -92,6 +113,7 @@ export class DetailsCommercialPropertyListingComponent extends BaseDetailsCompon
|
||||
private emailService: EMailService,
|
||||
public authService: AuthService,
|
||||
private seoService: SeoService,
|
||||
private cdref: ChangeDetectorRef,
|
||||
) {
|
||||
super();
|
||||
this.mailinfo = { sender: { name: '', email: '', phoneNumber: '', state: '', comments: '' }, email: '', url: environment.mailinfoUrl };
|
||||
@@ -314,13 +336,27 @@ export class DetailsCommercialPropertyListingComponent extends BaseDetailsCompon
|
||||
getImageIndices(): number[] {
|
||||
return this.listing && this.listing.imageOrder ? this.listing.imageOrder.slice(1).map((e, i) => i + 1) : [];
|
||||
}
|
||||
async save() {
|
||||
await this.listingsService.addToFavorites(this.listing.id, 'commercialProperty');
|
||||
this.listing.favoritesForUser.push(this.user.email);
|
||||
this.auditService.createEvent(this.listing.id, 'favorite', this.user?.email);
|
||||
}
|
||||
isAlreadyFavorite() {
|
||||
return this.listing.favoritesForUser.includes(this.user.email);
|
||||
async toggleFavorite() {
|
||||
try {
|
||||
const isFavorited = this.listing.favoritesForUser.includes(this.user.email);
|
||||
|
||||
if (isFavorited) {
|
||||
// Remove from favorites
|
||||
await this.listingsService.removeFavorite(this.listing.id, 'commercialProperty');
|
||||
this.listing.favoritesForUser = this.listing.favoritesForUser.filter(
|
||||
email => email !== this.user.email
|
||||
);
|
||||
} else {
|
||||
// Add to favorites
|
||||
await this.listingsService.addToFavorites(this.listing.id, 'commercialProperty');
|
||||
this.listing.favoritesForUser.push(this.user.email);
|
||||
this.auditService.createEvent(this.listing.id, 'favorite', this.user?.email);
|
||||
}
|
||||
|
||||
this.cdref.detectChanges();
|
||||
} catch (error) {
|
||||
console.error('Error toggling favorite:', error);
|
||||
}
|
||||
}
|
||||
async showShareByEMail() {
|
||||
const result = await this.emailService.showShareByEMail({
|
||||
|
||||
@@ -11,9 +11,12 @@
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- <img src="https://placehold.co/80x80" alt="Profile picture of Avery Brown smiling" class="w-20 h-20 rounded-full" /> -->
|
||||
@if(user.hasProfile){
|
||||
<img ngSrc="{{ env.imageBaseUrl }}/pictures//profile/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}" class="w-20 h-20 rounded-full object-cover" width="80" height="80" priority alt="Profile picture of {{ user.firstname }} {{ user.lastname }}" />
|
||||
<img ngSrc="{{ env.imageBaseUrl }}/pictures//profile/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}"
|
||||
class="w-20 h-20 rounded-full object-cover" width="80" height="80" priority
|
||||
alt="Profile picture of {{ user.firstname }} {{ user.lastname }}" />
|
||||
} @else {
|
||||
<img ngSrc="assets/images/person_placeholder.jpg" class="w-20 h-20 rounded-full" width="80" height="80" priority alt="Default profile picture" />
|
||||
<img ngSrc="assets/images/person_placeholder.jpg" class="w-20 h-20 rounded-full" width="80" height="80" priority
|
||||
alt="Default profile picture" />
|
||||
}
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold flex items-center">
|
||||
@@ -32,25 +35,86 @@
|
||||
</p>
|
||||
</div>
|
||||
@if(user.hasCompanyLogo){
|
||||
<img ngSrc="{{ env.imageBaseUrl }}/pictures/logo/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}" class="w-11 h-14" width="44" height="56" alt="Company logo of {{ user.companyName }}" />
|
||||
<div class="relative w-14 h-14">
|
||||
<img ngSrc="{{ env.imageBaseUrl }}/pictures/logo/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}" fill
|
||||
class="object-contain" alt="Company logo of {{ user.companyName }}" />
|
||||
</div>
|
||||
}
|
||||
<!-- <img src="https://placehold.co/45x60" class="w-11 h-14" /> -->
|
||||
</div>
|
||||
<button
|
||||
(click)="historyService.goBack()"
|
||||
class="absolute top-4 right-4 bg-red-500 text-white rounded-full w-8 h-8 flex items-center justify-center hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50"
|
||||
>
|
||||
<button (click)="historyService.goBack()" aria-label="Go back"
|
||||
class="absolute top-4 right-4 bg-red-500 text-white rounded-full w-10 h-10 flex items-center justify-center hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="p-4 text-neutral-700">{{ user.description }}</p>
|
||||
<p class="p-4 text-neutral-700 break-words">{{ 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>
|
||||
}
|
||||
<div class="inline">
|
||||
<button type="button" class="share share-save text-white font-bold text-xs py-1.5 px-2 inline-flex items-center"
|
||||
(click)="toggleFavorite()">
|
||||
<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 -->
|
||||
<div class="p-4">
|
||||
<h2 class="text-xl font-semibold mb-4">Company Profile</h2>
|
||||
<p class="text-neutral-700 mb-4" [innerHTML]="companyOverview"></p>
|
||||
<p class="text-neutral-700 mb-4 break-words" [innerHTML]="companyOverview"></p>
|
||||
|
||||
<!-- Profile Details -->
|
||||
<div class="space-y-2">
|
||||
@@ -82,7 +146,7 @@
|
||||
<!-- Services -->
|
||||
<div class="mt-6">
|
||||
<h3 class="font-semibold mb-2">Services we offer</h3>
|
||||
<p class="text-neutral-700 mb-4" [innerHTML]="offeredServices"></p>
|
||||
<p class="text-neutral-700 mb-4 break-words" [innerHTML]="offeredServices"></p>
|
||||
</div>
|
||||
|
||||
<!-- Areas Served -->
|
||||
@@ -90,7 +154,8 @@
|
||||
<h3 class="font-semibold mb-2">Areas (Counties) we serve</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@for (area of user.areasServed; track area) {
|
||||
<span class="bg-primary-100 text-primary-800 px-2 py-1 rounded-full text-sm">{{ area.county }}{{ area.county ? '-' : '' }}{{ area.state }}</span>
|
||||
<span class="bg-primary-100 text-primary-800 px-2 py-1 rounded-full text-sm">{{ area.county }}{{ area.county ?
|
||||
'-' : '' }}{{ area.state }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,7 +164,8 @@
|
||||
<div class="mt-6">
|
||||
<h3 class="font-semibold mb-2">Licensed In</h3>
|
||||
@for (license of user.licensedIn; track license) {
|
||||
<span class="bg-success-100 text-success-800 px-2 py-1 rounded-full text-sm">{{ license.registerNo }}-{{ license.state }}</span>
|
||||
<span class="bg-success-100 text-success-800 px-2 py-1 rounded-full text-sm">{{ license.registerNo }}-{{
|
||||
license.state }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -112,7 +178,8 @@
|
||||
<h2 class="text-xl font-semibold mb-4">My Business Listings For Sale</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@for (listing of businessListings; track listing) {
|
||||
<div class="border rounded-lg p-4 hover:cursor-pointer" [routerLink]="['/business', listing.slug || listing.id]">
|
||||
<div class="border rounded-lg p-4 hover:cursor-pointer"
|
||||
[routerLink]="['/business', listing.slug || listing.id]">
|
||||
<div class="flex items-center mb-2">
|
||||
<i [class]="selectOptions.getIconAndTextColorType(listing.type)" class="mr-2"></i>
|
||||
<span class="font-medium">{{ selectOptions.getBusiness(listing.type) }}</span>
|
||||
@@ -127,12 +194,17 @@
|
||||
<h2 class="text-xl font-semibold mb-4">My Commercial Property Listings For Sale</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@for (listing of commercialPropListings; track listing) {
|
||||
<div class="border rounded-lg p-4 hover:cursor-pointer" [routerLink]="['/commercial-property', listing.slug || listing.id]">
|
||||
<div class="border rounded-lg p-4 hover:cursor-pointer"
|
||||
[routerLink]="['/commercial-property', listing.slug || listing.id]">
|
||||
<div class="flex items-center space-x-4">
|
||||
@if (listing.imageOrder?.length>0){
|
||||
<img ngSrc="{{ env.imageBaseUrl }}/pictures/property/{{ listing.imagePath }}/{{ listing.serialId }}/{{ listing.imageOrder[0] }}?_ts={{ ts }}" class="w-12 h-12 object-cover rounded" width="48" height="48" alt="Property image for {{ listing.title }}" />
|
||||
<img
|
||||
ngSrc="{{ env.imageBaseUrl }}/pictures/property/{{ listing.imagePath }}/{{ listing.serialId }}/{{ listing.imageOrder[0] }}?_ts={{ ts }}"
|
||||
class="w-12 h-12 object-cover rounded" width="48" height="48"
|
||||
alt="Property image for {{ listing.title }}" />
|
||||
} @else {
|
||||
<img ngSrc="assets/images/placeholder_properties.jpg" class="w-12 h-12 object-cover rounded" width="48" height="48" alt="Property placeholder image" />
|
||||
<img ngSrc="assets/images/placeholder_properties.jpg" class="w-12 h-12 object-cover rounded" width="48"
|
||||
height="48" alt="Property placeholder image" />
|
||||
}
|
||||
<div>
|
||||
<p class="font-medium">{{ selectOptions.getCommercialProperty(listing.type) }}</p>
|
||||
@@ -142,8 +214,6 @@
|
||||
</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>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { ChangeDetectorRef, Component } from '@angular/core';
|
||||
import { NgOptimizedImage } from '@angular/common';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
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 { environment } from '../../../../environments/environment';
|
||||
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
|
||||
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 { ImageService } from '../../../services/image.service';
|
||||
import { ListingsService } from '../../../services/listings.service';
|
||||
@@ -15,13 +18,28 @@ import { SelectOptionsService } from '../../../services/select-options.service';
|
||||
import { UserService } from '../../../services/user.service';
|
||||
import { SharedModule } from '../../../shared/shared/shared.module';
|
||||
import { formatPhoneNumber, map2User } from '../../../utils/utils';
|
||||
import { ShareButton } from 'ngx-sharebuttons/button';
|
||||
import { provideShareButtonsOptions, SharerMethods, withConfig } from 'ngx-sharebuttons';
|
||||
import { shareIcons } from 'ngx-sharebuttons/icons';
|
||||
|
||||
@Component({
|
||||
selector: 'app-details-user',
|
||||
standalone: true,
|
||||
imports: [SharedModule, BreadcrumbsComponent, NgOptimizedImage],
|
||||
imports: [SharedModule, BreadcrumbsComponent, NgOptimizedImage, ShareButton],
|
||||
providers: [
|
||||
provideShareButtonsOptions(
|
||||
shareIcons(),
|
||||
withConfig({
|
||||
debug: false,
|
||||
sharerMethod: SharerMethods.Anchor,
|
||||
}),
|
||||
),
|
||||
],
|
||||
templateUrl: './details-user.component.html',
|
||||
styleUrl: '../details.scss',
|
||||
styleUrls: [
|
||||
'../details.scss',
|
||||
'../../../../../node_modules/ngx-sharebuttons/themes/default.scss'
|
||||
],
|
||||
})
|
||||
export class DetailsUserComponent {
|
||||
private id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined;
|
||||
@@ -47,13 +65,16 @@ export class DetailsUserComponent {
|
||||
private router: Router,
|
||||
private userService: UserService,
|
||||
private listingsService: ListingsService,
|
||||
|
||||
public selectOptions: SelectOptionsService,
|
||||
private sanitizer: DomSanitizer,
|
||||
private imageService: ImageService,
|
||||
public historyService: HistoryService,
|
||||
public authService: AuthService,
|
||||
) {}
|
||||
private auditService: AuditService,
|
||||
private emailService: EMailService,
|
||||
private messageService: MessageService,
|
||||
private cdref: ChangeDetectorRef,
|
||||
) { }
|
||||
|
||||
async ngOnInit() {
|
||||
this.user = await this.userService.getById(this.id);
|
||||
@@ -66,4 +87,97 @@ export class DetailsUserComponent {
|
||||
this.companyOverview = this.sanitizer.bypassSecurityTrustHtml(this.user.companyOverview ? this.user.companyOverview : '');
|
||||
this.offeredServices = this.sanitizer.bypassSecurityTrustHtml(this.user.offeredServices ? this.user.offeredServices : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle professional favorite status
|
||||
*/
|
||||
async toggleFavorite() {
|
||||
try {
|
||||
const isFavorited = this.user.favoritesForUser?.includes(this.keycloakUser.email);
|
||||
|
||||
if (isFavorited) {
|
||||
// Remove from favorites
|
||||
await this.listingsService.removeFavorite(this.user.id, 'user');
|
||||
this.user.favoritesForUser = this.user.favoritesForUser.filter(
|
||||
email => email !== this.keycloakUser.email
|
||||
);
|
||||
} else {
|
||||
// Add to favorites
|
||||
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);
|
||||
}
|
||||
|
||||
this.cdref.detectChanges();
|
||||
} catch (error) {
|
||||
console.error('Error toggling favorite', error);
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
<header class="w-full flex justify-between items-center p-4 bg-white top-0 z-10 h-16 md:h-20">
|
||||
<img src="/assets/images/header-logo.png" alt="Logo" class="h-8 md:h-10 w-auto" />
|
||||
<img src="/assets/images/header-logo.png" alt="BizMatch - Business & Property Marketplace" class="h-8 md:h-10 w-auto" width="120" height="40" />
|
||||
<div class="hidden md:flex items-center space-x-4">
|
||||
@if(user){
|
||||
<a routerLink="/account" class="text-primary-600 border border-primary-600 px-3 py-2 rounded">Account</a>
|
||||
} @else {
|
||||
<!-- <a routerLink="/pricing" class="text-neutral-800">Pricing</a> -->
|
||||
<a routerLink="/login" [queryParams]="{ mode: 'login' }"
|
||||
class="text-primary-600 border border-primary-600 px-3 py-2 rounded">Log In</a>
|
||||
<a routerLink="/login" [queryParams]="{ mode: 'register' }" class="text-white bg-primary-600 px-4 py-2 rounded">Sign
|
||||
Up</a>
|
||||
<a routerLink="/login" [queryParams]="{ mode: 'login' }" class="text-primary-600 border border-primary-600 px-3 py-2 rounded">Log In</a>
|
||||
<a routerLink="/login" [queryParams]="{ mode: 'register' }" class="text-white bg-primary-600 px-4 py-2 rounded">Sign Up</a>
|
||||
<!-- <a routerLink="/login" class="text-primary-500 hover:underline">Login/Register</a> -->
|
||||
}
|
||||
</div>
|
||||
<button (click)="toggleMenu()" class="md:hidden text-neutral-600">
|
||||
<button
|
||||
(click)="toggleMenu()"
|
||||
class="md:hidden text-neutral-700 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center"
|
||||
aria-label="Open navigation menu"
|
||||
[attr.aria-expanded]="isMenuOpen"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16m-7 6h7"></path>
|
||||
</svg>
|
||||
@@ -28,7 +31,11 @@
|
||||
<a routerLink="/login" [queryParams]="{ mode: 'login' }" class="text-white text-xl py-2">Log In</a>
|
||||
<a routerLink="/login" [queryParams]="{ mode: 'register' }" class="text-white text-xl py-2">Sign Up</a>
|
||||
}
|
||||
<button (click)="toggleMenu()" class="text-white mt-4">
|
||||
<button
|
||||
(click)="toggleMenu()"
|
||||
class="text-white mt-4"
|
||||
aria-label="Close navigation menu"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
@@ -40,87 +47,131 @@
|
||||
<!-- 1. px-4 für <main> (vorher px-2 sm:px-4) -->
|
||||
<main class="flex flex-col items-center justify-center px-4 w-full flex-grow">
|
||||
<div
|
||||
class="bg-cover-custom pb-12 md:py-20 flex flex-col w-full rounded-xl lg:rounded-2xl md:drop-shadow-custom-md lg:drop-shadow-custom-lg min-h-[calc(100vh_-_20rem)] lg:min-h-[calc(100vh_-_10rem)] max-sm:bg-contain max-sm:bg-bottom max-sm:bg-no-repeat max-sm:min-h-[60vh] max-sm:bg-primary-600">
|
||||
<div class="flex justify-center w-full">
|
||||
class="relative overflow-hidden pb-12 md:py-20 flex flex-col w-full rounded-xl lg:rounded-2xl md:drop-shadow-custom-md lg:drop-shadow-custom-lg min-h-[calc(100vh_-_20rem)] lg:min-h-[calc(100vh_-_10rem)] max-sm:min-h-[60vh] max-sm:bg-primary-600"
|
||||
>
|
||||
<!-- Optimized Background Image -->
|
||||
<picture class="absolute inset-0 w-full h-full z-0 pointer-events-none">
|
||||
<source srcset="/assets/images/flags_bg.avif" type="image/avif">
|
||||
<img
|
||||
width="2500"
|
||||
height="1285"
|
||||
fetchpriority="high"
|
||||
loading="eager"
|
||||
src="/assets/images/flags_bg.jpg"
|
||||
alt=""
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
</picture>
|
||||
|
||||
<!-- Gradient Overlay -->
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-black/35 via-black/15 to-transparent z-0 pointer-events-none"></div>
|
||||
|
||||
<div class="flex justify-center w-full relative z-10">
|
||||
<!-- 3. Für Mobile: m-2 statt max-w-xs; ab sm: wieder max-width und kein Margin -->
|
||||
<div class="w-full m-2 sm:m-0 sm:max-w-md md:max-w-xl lg:max-w-2xl xl:max-w-3xl">
|
||||
<!-- Hero-Container -->
|
||||
<section class="relative">
|
||||
<!-- Dein Hintergrundbild liegt hier per CSS oder absolutem <img> -->
|
||||
|
||||
<!-- 1) Overlay: sorgt für Kontrast auf hellem Himmel -->
|
||||
<div aria-hidden="true" class="pointer-events-none absolute inset-0"></div>
|
||||
<!-- 1) Overlay: sorgt für Kontrast auf hellem Himmel (Previous overlay removed, using new global overlay) -->
|
||||
<!-- <div aria-hidden="true" class="pointer-events-none absolute inset-0"></div> -->
|
||||
|
||||
<!-- 2) Textblock -->
|
||||
<div class="relative z-10 mx-auto max-w-4xl px-6 sm:px-6 py-4 sm:py-16 text-center text-white">
|
||||
<h1
|
||||
class="text-[1.55rem] sm:text-4xl md:text-5xl lg:text-6xl font-extrabold tracking-tight leading-tight drop-shadow-[0_2px_6px_rgba(0,0,0,0.55)]">
|
||||
Buy & Sell Businesses and Commercial Properties</h1>
|
||||
<h1 class="text-[1.55rem] sm:text-4xl md:text-5xl lg:text-6xl font-extrabold tracking-tight leading-tight drop-shadow-[0_2px_6px_rgba(0,0,0,0.55)]">Buy & Sell Businesses and Commercial Properties</h1>
|
||||
|
||||
<p
|
||||
class="mt-3 sm:mt-4 text-base sm:text-lg md:text-xl lg:text-2xl font-medium text-white/90 drop-shadow-[0_1.5px_4px_rgba(0,0,0,0.6)]">
|
||||
Find profitable businesses for sale, commercial real estate, and franchise opportunities across the United
|
||||
States</p>
|
||||
<p class="mt-3 sm:mt-4 text-base sm:text-lg md:text-xl lg:text-2xl font-medium text-white/90 drop-shadow-[0_1.5px_4px_rgba(0,0,0,0.6)]">
|
||||
Buy profitable businesses for sale or sell your business to qualified buyers. Browse commercial real estate and franchise opportunities across the United States.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Restliche Anpassungen (Innenabstände, Button-Paddings etc.) bleiben wie im vorherigen Schritt -->
|
||||
<div
|
||||
class="search-form-container bg-white bg-opacity-80 pb-4 md:pb-6 pt-2 px-3 sm:px-4 md:px-6 rounded-lg shadow-lg w-full"
|
||||
[ngClass]="{ 'pt-6': aiSearch }">
|
||||
<div class="search-form-container bg-white bg-opacity-80 pb-4 md:pb-6 pt-2 px-3 sm:px-4 md:px-6 rounded-lg shadow-lg w-full" [ngClass]="{ 'pt-6': aiSearch }">
|
||||
@if(!aiSearch){
|
||||
<div
|
||||
class="text-sm lg:text-base mb-1 text-center text-neutral-500 border-neutral-200 dark:text-neutral-400 dark:border-neutral-700 flex justify-between">
|
||||
<ul class="flex flex-wrap -mb-px w-full">
|
||||
<li class="w-[33%]">
|
||||
<a (click)="changeTab('business')" [ngClass]="
|
||||
<div class="text-sm lg:text-base mb-1 text-center text-neutral-700 border-neutral-200 dark:text-neutral-300 dark:border-neutral-700 flex justify-between">
|
||||
<ul class="flex flex-wrap -mb-px w-full" role="tablist" aria-label="Search categories">
|
||||
<li class="w-[33%]" role="presentation">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
[attr.aria-selected]="activeTabAction === 'business'"
|
||||
(click)="changeTab('business')"
|
||||
[ngClass]="
|
||||
activeTabAction === 'business'
|
||||
? ['text-primary-600', 'border-primary-600', 'active', 'dark:text-primary-500', 'dark:border-primary-500']
|
||||
: ['border-transparent', 'hover:text-neutral-600', 'hover:border-neutral-300', 'dark:hover:text-neutral-300']
|
||||
: ['border-transparent', 'text-neutral-700', 'hover:text-neutral-900', 'hover:border-neutral-400', 'dark:hover:text-neutral-300']
|
||||
"
|
||||
class="tab-link hover:cursor-pointer inline-flex items-center justify-center px-1 py-2 md:p-4 border-b-2 rounded-t-lg">
|
||||
<img src="/assets/images/business_logo.png" alt="Search businesses for sale"
|
||||
class="tab-icon w-6 h-6 md:w-7 md:h-7 mr-1 md:mr-2 object-contain" width="28" height="28" />
|
||||
class="tab-link w-full hover:cursor-pointer inline-flex items-center justify-center px-3 py-3 md:p-4 border-b-2 rounded-t-lg bg-transparent min-h-[44px]"
|
||||
[attr.aria-controls]="'tabpanel-search'"
|
||||
id="tab-business"
|
||||
>
|
||||
<img src="/assets/images/business_logo.png" alt="" aria-hidden="true" class="tab-icon w-6 h-6 md:w-7 md:h-7 mr-1 md:mr-2 object-contain" width="28" height="28" />
|
||||
<span>Businesses</span>
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
@if ((numberOfCommercial$ | async) > 0) {
|
||||
<li class="w-[33%]">
|
||||
<a (click)="changeTab('commercialProperty')" [ngClass]="
|
||||
<li class="w-[33%]" role="presentation">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
[attr.aria-selected]="activeTabAction === 'commercialProperty'"
|
||||
(click)="changeTab('commercialProperty')"
|
||||
[ngClass]="
|
||||
activeTabAction === 'commercialProperty'
|
||||
? ['text-primary-600', 'border-primary-600', 'active', 'dark:text-primary-500', 'dark:border-primary-500']
|
||||
: ['border-transparent', 'hover:text-neutral-600', 'hover:border-neutral-300', 'dark:hover:text-neutral-300']
|
||||
: ['border-transparent', 'text-neutral-700', 'hover:text-neutral-900', 'hover:border-neutral-400', 'dark:hover:text-neutral-300']
|
||||
"
|
||||
class="tab-link hover:cursor-pointer inline-flex items-center justify-center px-1 py-2 md:p-4 border-b-2 rounded-t-lg">
|
||||
<img src="/assets/images/properties_logo.png" alt="Search commercial properties for sale"
|
||||
class="tab-icon w-6 h-6 md:w-7 md:h-7 mr-1 md:mr-2 object-contain" width="28" height="28" />
|
||||
class="tab-link w-full hover:cursor-pointer inline-flex items-center justify-center px-3 py-3 md:p-4 border-b-2 rounded-t-lg bg-transparent min-h-[44px]"
|
||||
[attr.aria-controls]="'tabpanel-search'"
|
||||
id="tab-properties"
|
||||
>
|
||||
<img src="/assets/images/properties_logo.png" alt="" aria-hidden="true" class="tab-icon w-6 h-6 md:w-7 md:h-7 mr-1 md:mr-2 object-contain" width="28" height="28" />
|
||||
<span>Properties</span>
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
<li class="w-[33%]">
|
||||
<a (click)="changeTab('broker')" [ngClass]="
|
||||
<li class="w-[33%]" role="presentation">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
[attr.aria-selected]="activeTabAction === 'broker'"
|
||||
(click)="changeTab('broker')"
|
||||
[ngClass]="
|
||||
activeTabAction === 'broker'
|
||||
? ['text-primary-600', 'border-primary-600', 'active', 'dark:text-primary-500', 'dark:border-primary-500']
|
||||
: ['border-transparent', 'hover:text-neutral-600', 'hover:border-neutral-300', 'dark:hover:text-neutral-300']
|
||||
: ['border-transparent', 'text-neutral-700', 'hover:text-neutral-900', 'hover:border-neutral-400', 'dark:hover:text-neutral-300']
|
||||
"
|
||||
class="tab-link hover:cursor-pointer inline-flex items-center justify-center px-1 py-2 md:p-4 border-b-2 rounded-t-lg">
|
||||
<img src="/assets/images/icon_professionals.png" alt="Search business professionals and brokers"
|
||||
class="tab-icon w-6 h-6 md:w-7 md:h-7 mr-1 md:mr-2 object-contain bg-transparent"
|
||||
style="mix-blend-mode: darken;" />
|
||||
class="tab-link w-full hover:cursor-pointer inline-flex items-center justify-center px-3 py-3 md:p-4 border-b-2 rounded-t-lg bg-transparent min-h-[44px]"
|
||||
[attr.aria-controls]="'tabpanel-search'"
|
||||
id="tab-professionals"
|
||||
>
|
||||
<img
|
||||
src="/assets/images/icon_professionals.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="tab-icon w-6 h-6 md:w-7 md:h-7 mr-1 md:mr-2 object-contain"
|
||||
style="mix-blend-mode: darken"
|
||||
width="28" height="28"
|
||||
/>
|
||||
<span>Professionals</span>
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
} @if(criteria && !aiSearch){
|
||||
<div
|
||||
class="w-full max-w-3xl mx-auto bg-white rounded-lg flex flex-col md:flex-row md:border md:border-neutral-300">
|
||||
<div id="tabpanel-search" role="tabpanel" aria-labelledby="tab-business" class="w-full max-w-3xl mx-auto bg-white rounded-lg flex flex-col md:flex-row md:border md:border-neutral-300">
|
||||
<div class="md:flex-none md:w-48 flex-1 md:border-r border-neutral-300 overflow-hidden mb-2 md:mb-0">
|
||||
<div class="relative max-sm:border border-neutral-300 rounded-md">
|
||||
<label for="type-filter" class="sr-only">Filter by type</label>
|
||||
<select
|
||||
id="type-filter"
|
||||
aria-label="Filter by type"
|
||||
|
||||
class="appearance-none bg-transparent w-full py-4 px-4 pr-8 focus:outline-none md:border-none rounded-md md:rounded-none min-h-[52px]"
|
||||
[ngModel]="criteria.types" (ngModelChange)="onTypesChange($event)"
|
||||
[ngClass]="{ 'placeholder-selected': criteria.types.length === 0 }">
|
||||
[ngModel]="criteria.types"
|
||||
(ngModelChange)="onTypesChange($event)"
|
||||
[ngClass]="{ 'placeholder-selected': criteria.types.length === 0 }"
|
||||
>
|
||||
<option [value]="[]">{{ getPlaceholderLabel() }}</option>
|
||||
@for(type of getTypes(); track type){
|
||||
<option [value]="type.value">{{ type.name }}</option>
|
||||
@@ -134,12 +185,28 @@
|
||||
|
||||
<div class="md:flex-auto md:w-36 flex-grow md:border-r border-neutral-300 mb-2 md:mb-0">
|
||||
<div class="relative max-sm:border border-neutral-300 rounded-md">
|
||||
<ng-select class="custom md:border-none rounded-md md:rounded-none" [multiple]="false"
|
||||
[hideSelected]="true" [trackByFn]="trackByFn" [minTermLength]="2" [loading]="cityLoading"
|
||||
typeToSearchText="Please enter 2 or more characters" [typeahead]="cityInput$" [ngModel]="cityOrState"
|
||||
(ngModelChange)="setCityOrState($event)" placeholder="Enter City or State ..." groupBy="type">
|
||||
@for (city of cities$ | async; track city.id) { @let state = city.type==='city'?city.content.state:'';
|
||||
@let separator = city.type==='city'?' - ':'';
|
||||
<label for="location-search" class="sr-only">Search by city or state</label>
|
||||
<ng-select
|
||||
class="custom md:border-none rounded-md md:rounded-none"
|
||||
[multiple]="false"
|
||||
[hideSelected]="true"
|
||||
[trackByFn]="trackByFn"
|
||||
[minTermLength]="2"
|
||||
[loading]="cityLoading"
|
||||
typeToSearchText="Please enter 2 or more characters"
|
||||
[typeahead]="cityInput$"
|
||||
[ngModel]="cityOrState"
|
||||
(ngModelChange)="setCityOrState($event)"
|
||||
placeholder="Enter City or State ..."
|
||||
groupBy="type"
|
||||
labelForId="location-search"
|
||||
aria-label="Search by city or state"
|
||||
[inputAttrs]="{'aria-describedby': 'location-search-hint'}"
|
||||
>
|
||||
<ng-template ng-footer-tmp>
|
||||
<span id="location-search-hint" class="sr-only">Enter at least 2 characters to search for a city or state</span>
|
||||
</ng-template>
|
||||
@for (city of cities$ | async; track city.id) { @let state = city.type==='city'?city.content.state:''; @let separator = city.type==='city'?' - ':'';
|
||||
<ng-option [value]="city">{{ city.content.name }}{{ separator }}{{ state }}</ng-option>
|
||||
}
|
||||
</ng-select>
|
||||
@@ -148,10 +215,15 @@
|
||||
@if (criteria.radius && !aiSearch){
|
||||
<div class="md:flex-none md:w-36 flex-1 md:border-r border-neutral-300 mb-2 md:mb-0">
|
||||
<div class="relative max-sm:border border-neutral-300 rounded-md">
|
||||
<label for="radius-filter" class="sr-only">Filter by radius</label>
|
||||
<select
|
||||
id="radius-filter"
|
||||
aria-label="Filter by radius"
|
||||
class="appearance-none bg-transparent w-full py-4 px-4 pr-8 focus:outline-none md:border-none rounded-md md:rounded-none min-h-[52px]"
|
||||
(ngModelChange)="onRadiusChange($event)" [ngModel]="criteria.radius"
|
||||
[ngClass]="{ 'placeholder-selected': !criteria.radius }">
|
||||
(ngModelChange)="onRadiusChange($event)"
|
||||
[ngModel]="criteria.radius"
|
||||
[ngClass]="{ 'placeholder-selected': !criteria.radius }"
|
||||
>
|
||||
<option [value]="null">City Radius</option>
|
||||
@for(dist of selectOptions.distances; track dist){
|
||||
<option [value]="dist.value">{{ dist.name }}</option>
|
||||
@@ -165,17 +237,13 @@
|
||||
}
|
||||
<div class="bg-primary-500 hover:bg-primary-600 max-sm:rounded-md search-button">
|
||||
@if( numberOfResults$){
|
||||
<button
|
||||
class="w-full h-full text-white font-bold py-2 px-4 md:py-3 md:px-6 focus:outline-none rounded-md md:rounded-none min-h-[52px] flex items-center justify-center gap-2"
|
||||
(click)="search()">
|
||||
<i class="fas fa-search"></i>
|
||||
<button aria-label="Search listings" class="w-full h-full text-white font-bold py-2 px-4 md:py-3 md:px-6 focus:outline-none rounded-md md:rounded-none min-h-[52px] flex items-center justify-center gap-2" (click)="search()">
|
||||
<i class="fas fa-search" aria-hidden="true"></i>
|
||||
<span>Search {{ numberOfResults$ | async }}</span>
|
||||
</button>
|
||||
}@else {
|
||||
<button
|
||||
class="w-full h-full text-white font-bold py-2 px-4 md:py-3 md:px-6 focus:outline-none rounded-md md:rounded-none min-h-[52px] flex items-center justify-center gap-2"
|
||||
(click)="search()">
|
||||
<i class="fas fa-search"></i>
|
||||
<button aria-label="Search listings" class="w-full h-full text-white font-bold py-2 px-4 md:py-3 md:px-6 focus:outline-none rounded-md md:rounded-none min-h-[52px] flex items-center justify-center gap-2" (click)="search()">
|
||||
<i class="fas fa-search" aria-hidden="true"></i>
|
||||
<span>Search</span>
|
||||
</button>
|
||||
}
|
||||
@@ -188,8 +256,8 @@
|
||||
</div>
|
||||
|
||||
<!-- FAQ Section for SEO/AEO -->
|
||||
<div class="w-full px-4 mt-12 max-w-4xl mx-auto">
|
||||
<!-- <div class="w-full px-4 mt-12 max-w-4xl mx-auto">
|
||||
<app-faq [faqItems]="faqItems"></app-faq>
|
||||
</div>
|
||||
</div> -->
|
||||
</main>
|
||||
<!-- ==== ANPASSUNGEN ENDE ==== -->
|
||||
@@ -1,45 +1,7 @@
|
||||
.bg-cover-custom {
|
||||
position: relative;
|
||||
// Prioritize AVIF format (69KB) over JPG (26MB)
|
||||
background-image: url('/assets/images/flags_bg.avif');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border-radius: 20px;
|
||||
|
||||
// Fallback for browsers that don't support AVIF
|
||||
@supports not (background-image: url('/assets/images/flags_bg.avif')) {
|
||||
background-image: url('/assets/images/flags_bg.jpg');
|
||||
}
|
||||
|
||||
// Add gradient overlay for better text contrast
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(0, 0, 0, 0.35) 0%,
|
||||
rgba(0, 0, 0, 0.15) 40%,
|
||||
rgba(0, 0, 0, 0.05) 70%,
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
border-radius: 20px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
// Ensure content stays above overlay
|
||||
> * {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
select:not([size]) {
|
||||
background-image: unset;
|
||||
}
|
||||
|
||||
[type='text'],
|
||||
[type='email'],
|
||||
[type='url'],
|
||||
@@ -57,39 +19,51 @@ textarea,
|
||||
select {
|
||||
border: unset;
|
||||
}
|
||||
|
||||
.toggle-checkbox:checked {
|
||||
right: 0;
|
||||
border-color: rgb(125 211 252);
|
||||
}
|
||||
.toggle-checkbox:checked + .toggle-label {
|
||||
|
||||
.toggle-checkbox:checked+.toggle-label {
|
||||
background-color: rgb(125 211 252);
|
||||
}
|
||||
|
||||
:host ::ng-deep .ng-select.ng-select-single .ng-select-container {
|
||||
min-height: 52px;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
|
||||
.ng-value-container .ng-input {
|
||||
top: 12px;
|
||||
}
|
||||
|
||||
span.ng-arrow-wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
color: #000; /* Standard-Textfarbe für das Dropdown */
|
||||
color: #000;
|
||||
/* Standard-Textfarbe für das Dropdown */
|
||||
// background-color: #fff; /* Hintergrundfarbe für das Dropdown */
|
||||
}
|
||||
|
||||
select option {
|
||||
color: #000; /* Textfarbe für Dropdown-Optionen */
|
||||
color: #000;
|
||||
/* Textfarbe für Dropdown-Optionen */
|
||||
}
|
||||
|
||||
select.placeholder-selected {
|
||||
color: #999; /* Farbe für den Platzhalter */
|
||||
color: #6b7280;
|
||||
/* gray-500 - besserer Kontrast für WCAG AA */
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: #555; /* Dunkleres Grau */
|
||||
opacity: 1; /* Stellt sicher, dass die Deckkraft 100% ist */
|
||||
color: #555;
|
||||
/* Dunkleres Grau */
|
||||
opacity: 1;
|
||||
/* Stellt sicher, dass die Deckkraft 100% ist */
|
||||
}
|
||||
|
||||
/* Stellt sicher, dass die Optionen im Dropdown immer schwarz sind */
|
||||
@@ -97,10 +71,14 @@ select:focus option,
|
||||
select:hover option {
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
input[type='text'][name='aiSearchText'] {
|
||||
padding: 14px; /* Innerer Abstand */
|
||||
font-size: 16px; /* Schriftgröße anpassen */
|
||||
box-sizing: border-box; /* Padding und Border in die Höhe und Breite einrechnen */
|
||||
padding: 14px;
|
||||
/* Innerer Abstand */
|
||||
font-size: 16px;
|
||||
/* Schriftgröße anpassen */
|
||||
box-sizing: border-box;
|
||||
/* Padding und Border in die Höhe und Breite einrechnen */
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
@@ -130,7 +108,9 @@ input[type='text'][name='aiSearchText'] {
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.6s, height 0.6s;
|
||||
transition:
|
||||
width 0.6s,
|
||||
height 0.6s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -181,6 +161,7 @@ select,
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
@@ -190,7 +171,9 @@ select,
|
||||
// Trust section container - more prominent
|
||||
.trust-section-container {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
transition: box-shadow 0.3s ease, transform 0.3s ease;
|
||||
transition:
|
||||
box-shadow 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12);
|
||||
@@ -207,7 +190,9 @@ select,
|
||||
}
|
||||
|
||||
.trust-icon {
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
}
|
||||
|
||||
.trust-badge:hover .trust-icon {
|
||||
@@ -227,11 +212,15 @@ select,
|
||||
// Search form container enhancement
|
||||
.search-form-container {
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
// KEIN backdrop-filter hier!
|
||||
background-color: rgba(255, 255, 255, 0.95) !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1); // Dunklerer Rand für Kontrast
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
// Falls Firefox das Element "vergisst", erzwingen wir eine Ebene
|
||||
transform: translateZ(0);
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
// Header button improvements
|
||||
@@ -240,6 +229,7 @@ header {
|
||||
transition: all 0.2s ease-in-out;
|
||||
|
||||
&.text-blue-600.border.border-blue-600 {
|
||||
|
||||
// Log In button
|
||||
&:hover {
|
||||
background-color: rgba(37, 99, 235, 0.05);
|
||||
@@ -252,6 +242,7 @@ header {
|
||||
}
|
||||
|
||||
&.bg-blue-600 {
|
||||
|
||||
// Register button
|
||||
&:hover {
|
||||
background-color: rgb(29, 78, 216);
|
||||
@@ -265,3 +256,16 @@ header {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Screen reader only - visually hidden but accessible
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ChangeDetectorRef, Component, ElementRef, ViewChild } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, ViewChild } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { NgSelectModule } from '@ng-select/ng-select';
|
||||
import { UntilDestroy } from '@ngneat/until-destroy';
|
||||
import { catchError, concat, distinctUntilChanged, Observable, of, Subject, switchMap, tap } from 'rxjs';
|
||||
import { BusinessListingCriteria, CityAndStateResult, CommercialPropertyListingCriteria, GeoResult, KeycloakUser, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
|
||||
import { FaqComponent, FAQItem } from '../../components/faq/faq.component';
|
||||
import { FAQItem } from '../../components/faq/faq.component';
|
||||
import { ModalService } from '../../components/search-modal/modal.service';
|
||||
import { TooltipComponent } from '../../components/tooltip/tooltip.component';
|
||||
import { AiService } from '../../services/ai.service';
|
||||
@@ -24,9 +24,10 @@ import { map2User } from '../../utils/utils';
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, RouterModule, NgSelectModule, FaqComponent],
|
||||
imports: [CommonModule, FormsModule, RouterModule, NgSelectModule],
|
||||
templateUrl: './home.component.html',
|
||||
styleUrl: './home.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class HomeComponent {
|
||||
placeholders: string[] = ['Property close to Houston less than 10M', 'Franchise business in Austin price less than 500K'];
|
||||
@@ -125,57 +126,23 @@ export class HomeComponent {
|
||||
// Set SEO meta tags for home page
|
||||
this.seoService.updateMetaTags({
|
||||
title: 'BizMatch - Buy & Sell Businesses and Commercial Properties',
|
||||
description: 'Find profitable businesses for sale, commercial real estate, and franchise opportunities across the United States. Browse thousands of listings from verified sellers and brokers.',
|
||||
description: 'Buy and sell businesses, commercial properties, and franchises. Browse thousands of verified listings across the United States.',
|
||||
keywords: 'business for sale, businesses for sale, buy business, sell business, commercial property, commercial real estate, franchise opportunities, business broker, business marketplace',
|
||||
type: 'website'
|
||||
});
|
||||
|
||||
// Add Organization schema for brand identity and FAQ schema for AEO
|
||||
// Add Organization schema for brand identity
|
||||
// NOTE: FAQ schema removed because FAQ section is hidden (violates Google's visible content requirement)
|
||||
// FAQ content is preserved in component for future use when FAQ section is made visible
|
||||
const organizationSchema = this.seoService.generateOrganizationSchema();
|
||||
const faqSchema = this.seoService.generateFAQPageSchema(
|
||||
this.faqItems.map(item => ({
|
||||
question: item.question,
|
||||
answer: item.answer
|
||||
}))
|
||||
);
|
||||
|
||||
// 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
|
||||
const searchBoxSchema = this.seoService.generateSearchBoxSchema();
|
||||
|
||||
this.seoService.injectMultipleSchemas([organizationSchema, faqSchema, howToSchema, searchBoxSchema]);
|
||||
// Inject schemas (FAQ schema excluded - content not visible to users)
|
||||
this.seoService.injectMultipleSchemas([organizationSchema, searchBoxSchema]);
|
||||
|
||||
// Clear all filters and sort options on initial load
|
||||
this.filterStateService.resetCriteria('businessListings');
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
>
|
||||
<i class="fas fa-arrow-left text-lg"></i>
|
||||
</button>
|
||||
<h1 class="text-3xl font-bold text-neutral-900 mb-6 pr-14">Privacy Statement</h1>
|
||||
<h1 class="text-3xl font-bold text-neutral-900 mb-6 pr-14">BizMatch Privacy Policy and Data Protection</h1>
|
||||
|
||||
<section id="content" role="main">
|
||||
<article class="post page">
|
||||
@@ -21,7 +21,7 @@
|
||||
Policy.
|
||||
</p>
|
||||
<p class="mb-4">
|
||||
We may update our Privacy Policy from time to time. Our Privacy Policy was last updated in Febuary 2018 and is effective upon acceptance for new users. By continuing to use our websites or otherwise
|
||||
We may update our Privacy Policy from time to time. Our Privacy Policy was last updated in February 2018 and is effective upon acceptance for new users. By continuing to use our websites or otherwise
|
||||
continuing to deal with us, you accept this Privacy Policy.
|
||||
</p>
|
||||
<p class="font-bold mb-4 mt-6">Collection of personal information</p>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
>
|
||||
<i class="fas fa-arrow-left text-lg"></i>
|
||||
</button>
|
||||
<h1 class="text-3xl font-bold text-neutral-900 mb-6 pr-14">Terms of Use</h1>
|
||||
<h1 class="text-3xl font-bold text-neutral-900 mb-6 pr-14">BizMatch Terms of Use and User Agreement</h1>
|
||||
|
||||
<section id="content" role="main">
|
||||
<article class="post page">
|
||||
|
||||
@@ -13,11 +13,14 @@
|
||||
</div>
|
||||
|
||||
<!-- SEO-optimized heading -->
|
||||
<div class="mb-6">
|
||||
<!-- <div class="mb-6">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-neutral-900 mb-2">Professional Business Brokers & Advisors</h1>
|
||||
<p class="text-lg text-neutral-600">Connect with licensed business brokers, CPAs, attorneys, and other
|
||||
professionals across the United States.</p>
|
||||
</div>
|
||||
<div class="mt-4 text-base text-neutral-700 max-w-4xl">
|
||||
<p>BizMatch connects business buyers and sellers with experienced professionals. Find qualified business brokers to help with your business sale or acquisition. Our platform features verified professionals including business brokers, M&A advisors, CPAs, and attorneys specializing in business transactions across the United States. Whether you're looking to buy or sell a business, our network of professionals can guide you through the process.</p>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!-- Mobile Filter Button -->
|
||||
<div class="md:hidden mb-4">
|
||||
@@ -34,7 +37,24 @@
|
||||
<!-- Professional Cards -->
|
||||
@for (user of users; track user) {
|
||||
<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 type="button" class="bg-white rounded-full p-2 shadow-lg transition-colors"
|
||||
[class.bg-red-50]="isFavorite(user)"
|
||||
[attr.aria-label]="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"
|
||||
aria-label="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">
|
||||
@if(user.hasProfile){
|
||||
<img src="{{ env.imageBaseUrl }}/pictures/profile/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}"
|
||||
@@ -119,7 +139,8 @@
|
||||
<h2 class="text-center text-black text-xl font-semibold leading-loose pb-2">There're no professionals here
|
||||
</h2>
|
||||
<p class="text-center text-black text-base font-normal leading-relaxed pb-4">Try changing your filters to
|
||||
<br />see professionals</p>
|
||||
<br />see professionals
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button (click)="clearAllFilters()"
|
||||
class="w-full px-3 py-2 rounded-full border border-neutral-300 text-neutral-900 text-xs font-semibold leading-4">Clear
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { UntilDestroy } from '@ngneat/until-destroy';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
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 { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.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 { SelectOptionsService } from '../../../services/select-options.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()
|
||||
@Component({
|
||||
selector: 'app-broker-listings',
|
||||
@@ -28,6 +29,7 @@ import { assignProperties, resetUserListingCriteria } from '../../../utils/utils
|
||||
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, CustomerSubTypeComponent, BreadcrumbsComponent, SearchModalBrokerComponent],
|
||||
templateUrl: './broker-listings.component.html',
|
||||
styleUrls: ['./broker-listings.component.scss', '../../pages.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BrokerListingsComponent implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
@@ -56,6 +58,7 @@ export class BrokerListingsComponent implements OnInit, OnDestroy {
|
||||
page = 1;
|
||||
pageCount = 1;
|
||||
sortBy: SortByOptions = null; // Neu: Separate Property
|
||||
currentUser: KeycloakUser | null = null; // Current logged-in user
|
||||
constructor(
|
||||
public altText: AltTextService,
|
||||
public selectOptions: SelectOptionsService,
|
||||
@@ -70,6 +73,7 @@ export class BrokerListingsComponent implements OnInit, OnDestroy {
|
||||
private modalService: ModalService,
|
||||
private criteriaChangeService: CriteriaChangeService,
|
||||
private filterStateService: FilterStateService,
|
||||
private authService: AuthService,
|
||||
) {
|
||||
this.loadSortBy();
|
||||
}
|
||||
@@ -77,7 +81,11 @@ export class BrokerListingsComponent implements OnInit, OnDestroy {
|
||||
const storedSortBy = sessionStorage.getItem('professionalsSortBy');
|
||||
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
|
||||
this.filterStateService
|
||||
.getState$('brokerListings')
|
||||
@@ -144,4 +152,93 @@ export class BrokerListingsComponent implements OnInit, OnDestroy {
|
||||
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 {
|
||||
console.log('Toggling favorite for:', professional.email, 'Current user:', this.currentUser.email);
|
||||
console.log('Before update, favorites:', professional.favoritesForUser);
|
||||
|
||||
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 = [];
|
||||
}
|
||||
// Use spread to create new array reference
|
||||
professional.favoritesForUser = [...professional.favoritesForUser, this.currentUser.email];
|
||||
}
|
||||
|
||||
console.log('After update, favorites:', professional.favoritesForUser);
|
||||
this.cdRef.markForCheck();
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,12 @@
|
||||
|
||||
<!-- SEO-optimized heading -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-neutral-900 mb-2">Businesses for Sale</h1>
|
||||
<p class="text-lg text-neutral-600">Discover profitable business opportunities across the United States. Browse
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-neutral-900 mb-2">Businesses for Sale - Find Your Next Business Opportunity</h1>
|
||||
<p class="text-lg text-neutral-600">Discover profitable business opportunities across the State of Texas. Browse
|
||||
verified listings from business owners and brokers.</p>
|
||||
<div class="mt-4 text-base text-neutral-700 max-w-4xl">
|
||||
<p>BizMatch features a curated selection of businesses for sale across diverse industries and price ranges. Browse opportunities in sectors like restaurants, retail, franchises, services, e-commerce, and manufacturing. Each listing includes financial details, years established, location information, and seller contact details. Our marketplace connects business buyers with sellers and brokers nationwide, making it easy to find your next business opportunity.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading Skeleton -->
|
||||
@@ -65,14 +68,14 @@
|
||||
@if(user) {
|
||||
<button class="bg-white rounded-full p-2 shadow-lg transition-colors"
|
||||
[class.bg-red-50]="isFavorite(listing)"
|
||||
[title]="isFavorite(listing) ? 'Remove from favorites' : 'Save to favorites'"
|
||||
[attr.aria-label]="isFavorite(listing) ? 'Remove from favorites' : 'Save to favorites'"
|
||||
(click)="toggleFavorite($event, listing)">
|
||||
<i
|
||||
[class]="isFavorite(listing) ? '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 listing" (click)="shareListing($event, listing)">
|
||||
aria-label="Share listing" (click)="shareListing($event, listing)">
|
||||
<i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { UntilDestroy } from '@ngneat/until-destroy';
|
||||
@@ -31,6 +31,7 @@ import { map2User } from '../../../utils/utils';
|
||||
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, SearchModalComponent, LazyLoadImageDirective, BreadcrumbsComponent],
|
||||
templateUrl: './business-listings.component.html',
|
||||
styleUrls: ['./business-listings.component.scss', '../../pages.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BusinessListingsComponent implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
@@ -86,8 +87,8 @@ export class BusinessListingsComponent implements OnInit, OnDestroy {
|
||||
|
||||
// Set SEO meta tags for business listings page
|
||||
this.seoService.updateMetaTags({
|
||||
title: 'Businesses for Sale - Find Profitable Business Opportunities | BizMatch',
|
||||
description: 'Browse thousands of businesses for sale across the United States. Find restaurants, franchises, retail stores, and more. Verified listings from business owners and brokers.',
|
||||
title: 'Businesses for Sale | BizMatch',
|
||||
description: 'Browse thousands of businesses for sale including restaurants, franchises, and retail stores. Verified listings nationwide.',
|
||||
keywords: 'businesses for sale, buy a business, business opportunities, franchise for sale, restaurant for sale, retail business for sale, business broker listings',
|
||||
type: 'website'
|
||||
});
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
<!-- SEO-optimized heading -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-neutral-900 mb-2">Commercial Properties for Sale</h1>
|
||||
<p class="text-lg text-neutral-600">Find office buildings, retail spaces, warehouses, and industrial properties across the United States. Investment opportunities from verified sellers and commercial real estate brokers.</p>
|
||||
<p class="text-lg text-neutral-600">Discover selected investment properties and commercial spaces. Connect with verified sellers and brokers to find your next asset.</p>
|
||||
<div class="mt-4 text-base text-neutral-700 max-w-4xl">
|
||||
<p>BizMatch presents commercial real estate opportunities for sale or lease. View investment properties with detailed information on square footage, zoning, pricing, and location. Our platform connects property buyers and investors directly with sellers and commercial real estate brokers, focusing on transparent and valuable transactions.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(listings?.length > 0) {
|
||||
@@ -23,17 +26,23 @@
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
@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">
|
||||
<!-- Favorites Button -->
|
||||
@if(user) {
|
||||
<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-red-50]="isFavorite(listing)"
|
||||
[class.opacity-100]="isFavorite(listing)"
|
||||
[title]="isFavorite(listing) ? 'Remove from favorites' : 'Save to favorites'"
|
||||
(click)="toggleFavorite($event, listing)">
|
||||
<i [class]="isFavorite(listing) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i>
|
||||
</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) {
|
||||
<button
|
||||
class="bg-white rounded-full p-2 shadow-lg transition-colors"
|
||||
[class.bg-red-50]="isFavorite(listing)"
|
||||
[class.opacity-100]="isFavorite(listing)"
|
||||
[attr.aria-label]="isFavorite(listing) ? 'Remove from favorites' : 'Save to favorites'"
|
||||
(click)="toggleFavorite($event, listing)">
|
||||
<i [class]="isFavorite(listing) ? '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"
|
||||
aria-label="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){
|
||||
<img [appLazyLoad]="env.imageBaseUrl + '/pictures/property/' + listing.imagePath + '/' + listing.serialId + '/' + listing.imageOrder[0]"
|
||||
[alt]="altText.generatePropertyListingAlt(listing)"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { UntilDestroy } from '@ngneat/until-destroy';
|
||||
@@ -30,6 +30,7 @@ import { map2User } from '../../../utils/utils';
|
||||
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, SearchModalCommercialComponent, LazyLoadImageDirective, BreadcrumbsComponent],
|
||||
templateUrl: './commercial-property-listings.component.html',
|
||||
styleUrls: ['./commercial-property-listings.component.scss', '../../pages.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class CommercialPropertyListingsComponent implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
@@ -83,8 +84,8 @@ export class CommercialPropertyListingsComponent implements OnInit, OnDestroy {
|
||||
|
||||
// Set SEO meta tags for commercial property listings page
|
||||
this.seoService.updateMetaTags({
|
||||
title: 'Commercial Properties for Sale - Office, Retail, Industrial Real Estate | BizMatch',
|
||||
description: 'Browse commercial real estate listings including office buildings, retail spaces, warehouses, and industrial properties. Investment opportunities from verified sellers and brokers across the United States.',
|
||||
title: 'Commercial Properties for Sale | BizMatch',
|
||||
description: 'Browse commercial real estate including offices, retail, warehouses, and industrial properties. Verified investment opportunities.',
|
||||
keywords: 'commercial property for sale, commercial real estate, office building for sale, retail space for sale, warehouse for sale, industrial property, investment property, commercial property listings',
|
||||
type: 'website'
|
||||
});
|
||||
@@ -258,4 +259,44 @@ export class CommercialPropertyListingsComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,16 +18,16 @@
|
||||
<div class="w-20 h-20 w-full rounded-md flex items-center justify-center relative">
|
||||
@if(user?.hasCompanyLogo){
|
||||
<img src="{{ companyLogoUrl }}" alt="Company logo" class="max-w-full max-h-full" />
|
||||
<div
|
||||
class="absolute top-[-0.5rem] right-[0rem] bg-white rounded-full p-1 drop-shadow-custom-bg hover:cursor-pointer"
|
||||
<button type="button" aria-label="Delete company logo"
|
||||
class="absolute top-[-0.5rem] right-[0rem] bg-white rounded-full p-2 drop-shadow-custom-bg cursor-pointer min-w-[32px] min-h-[32px] flex items-center justify-center"
|
||||
(click)="deleteConfirm('logo')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
class="w-4 h-4 text-gray-600">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
} @else {
|
||||
<img src="/assets/images/placeholder.png" class="max-w-full max-h-full" />
|
||||
<img src="/assets/images/placeholder.png" class="max-w-full max-h-full" alt="" width="80" height="80" />
|
||||
}
|
||||
</div>
|
||||
<button type="button"
|
||||
@@ -41,16 +41,16 @@
|
||||
<div class="w-20 h-20 w-full rounded-md flex items-center justify-center relative">
|
||||
@if(user?.hasProfile){
|
||||
<img src="{{ profileUrl }}" alt="Profile picture" class="max-w-full max-h-full" />
|
||||
<div
|
||||
class="absolute top-[-0.5rem] right-[0rem] bg-white rounded-full p-1 drop-shadow-custom-bg hover:cursor-pointer"
|
||||
<button type="button" aria-label="Delete profile picture"
|
||||
class="absolute top-[-0.5rem] right-[0rem] bg-white rounded-full p-2 drop-shadow-custom-bg cursor-pointer min-w-[32px] min-h-[32px] flex items-center justify-center"
|
||||
(click)="deleteConfirm('profile')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
class="w-4 h-4 text-gray-600">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
} @else {
|
||||
<img src="/assets/images/placeholder.png" class="max-w-full max-h-full" />
|
||||
<img src="/assets/images/placeholder.png" class="max-w-full max-h-full" alt="" width="80" height="80" />
|
||||
}
|
||||
</div>
|
||||
<button type="button"
|
||||
@@ -129,23 +129,16 @@
|
||||
mask="(000) 000-0000"></app-validated-input>
|
||||
<app-validated-input label="Company Website" name="companyWebsite"
|
||||
[(ngModel)]="user.companyWebsite"></app-validated-input>
|
||||
<!-- <app-validated-input label="Company Location" name="companyLocation" [(ngModel)]="user.companyLocation"></app-validated-input> -->
|
||||
<!-- <app-validated-city label="Company Location" name="location" [(ngModel)]="user.location"></app-validated-city> -->
|
||||
<app-validated-location label="Company Location" name="location"
|
||||
[(ngModel)]="user.location"></app-validated-location>
|
||||
</div>
|
||||
|
||||
<!-- <div>
|
||||
<label for="companyOverview" class="block text-sm font-medium text-gray-700">Company Overview</label>
|
||||
<quill-editor [(ngModel)]="user.companyOverview" name="companyOverview" [modules]="quillModules"></quill-editor>
|
||||
</div> -->
|
||||
|
||||
<div>
|
||||
<app-validated-quill label="Company Overview" name="companyOverview"
|
||||
[(ngModel)]="user.companyOverview"></app-validated-quill>
|
||||
</div>
|
||||
<div>
|
||||
<!-- <label for="offeredServices" class="block text-sm font-medium text-gray-700">Services We Offer</label>
|
||||
<quill-editor [(ngModel)]="user.offeredServices" name="offeredServices" [modules]="quillModules"></quill-editor> -->
|
||||
<app-validated-quill label="Services We Offer" name="offeredServices"
|
||||
[(ngModel)]="user.offeredServices"></app-validated-quill>
|
||||
</div>
|
||||
@@ -233,7 +226,6 @@
|
||||
<span class="text-sm text-gray-500 ml-2">[Add more licenses or remove existing ones.]</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="flex items-center !my-8">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<div class="relative">
|
||||
@@ -243,6 +235,7 @@
|
||||
<div class="ml-3 text-gray-700 font-medium">Show your profile in Professional Directory</div>
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="flex justify-start">
|
||||
<button type="submit"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DatePipe, TitleCasePipe } from '@angular/common';
|
||||
import { ChangeDetectorRef, Component } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { faTrash } from '@fortawesome/free-solid-svg-icons';
|
||||
import { APP_ICONS } from '../../../utils/fontawesome-icons';
|
||||
import { NgSelectModule } from '@ng-select/ng-select';
|
||||
import { QuillModule } from 'ngx-quill';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
@@ -45,9 +45,15 @@ import { TOOLBAR_OPTIONS } from '../../utils/defaults';
|
||||
ValidatedCountyComponent,
|
||||
ValidatedLocationComponent,
|
||||
],
|
||||
providers: [TitleCasePipe, DatePipe],
|
||||
providers: [
|
||||
TitleCasePipe,
|
||||
DatePipe
|
||||
],
|
||||
templateUrl: './account.component.html',
|
||||
styleUrl: './account.component.scss',
|
||||
styleUrls: [
|
||||
'./account.component.scss',
|
||||
'../../../../../node_modules/quill/dist/quill.snow.css'
|
||||
],
|
||||
})
|
||||
export class AccountComponent {
|
||||
id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined;
|
||||
@@ -58,10 +64,7 @@ export class AccountComponent {
|
||||
environment = environment;
|
||||
editorModules = TOOLBAR_OPTIONS;
|
||||
env = environment;
|
||||
faTrash = faTrash;
|
||||
quillModules = {
|
||||
toolbar: [['bold', 'italic', 'underline', 'strike'], [{ list: 'ordered' }, { list: 'bullet' }], [{ header: [1, 2, 3, 4, 5, 6, false] }], [{ color: [] }, { background: [] }], ['clean']],
|
||||
};
|
||||
faTrash = APP_ICONS.faTrash;
|
||||
uploadParams: UploadParams;
|
||||
validationMessages: ValidationMessage[] = [];
|
||||
customerTypeOptions: Array<{ value: string; label: string }> = [];
|
||||
|
||||
@@ -6,7 +6,7 @@ import { SelectOptionsService } from '../../../services/select-options.service';
|
||||
import { map2User, routeListingWithState } from '../../../utils/utils';
|
||||
|
||||
import { DragDropModule } from '@angular/cdk/drag-drop';
|
||||
import { faTrash } from '@fortawesome/free-solid-svg-icons';
|
||||
import { APP_ICONS } from '../../../utils/fontawesome-icons';
|
||||
import { QuillModule } from 'ngx-quill';
|
||||
|
||||
import { NgSelectModule } from '@ng-select/ng-select';
|
||||
@@ -47,9 +47,11 @@ import { TOOLBAR_OPTIONS } from '../../utils/defaults';
|
||||
ValidatedTextareaComponent,
|
||||
ValidatedLocationComponent,
|
||||
],
|
||||
providers: [],
|
||||
templateUrl: './edit-business-listing.component.html',
|
||||
styleUrl: './edit-business-listing.component.scss',
|
||||
styleUrls: [
|
||||
'./edit-business-listing.component.scss',
|
||||
'../../../../../node_modules/quill/dist/quill.snow.css'
|
||||
],
|
||||
})
|
||||
export class EditBusinessListingComponent {
|
||||
listingsCategory = 'business';
|
||||
@@ -64,7 +66,7 @@ export class EditBusinessListingComponent {
|
||||
config = { aspectRatio: 16 / 9 };
|
||||
editorModules = TOOLBAR_OPTIONS;
|
||||
draggedImage: ImageProperty;
|
||||
faTrash = faTrash;
|
||||
faTrash = APP_ICONS.faTrash;
|
||||
data: CommercialPropertyListing;
|
||||
typesOfBusiness = [];
|
||||
quillModules = {
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
(click)="uploadPropertyPicture()"
|
||||
class="flex items-center justify-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 bg-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<svg class="mr-2 h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg class="mr-2 h-5 w-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
Upload
|
||||
|
||||
@@ -7,7 +7,7 @@ import { map2User, routeListingWithState } from '../../../utils/utils';
|
||||
|
||||
import { DragDropModule } from '@angular/cdk/drag-drop';
|
||||
import { ViewportRuler } from '@angular/cdk/scrolling';
|
||||
import { faTrash } from '@fortawesome/free-solid-svg-icons';
|
||||
import { APP_ICONS } from '../../../utils/fontawesome-icons';
|
||||
import { NgSelectModule } from '@ng-select/ng-select';
|
||||
import { NgxCurrencyDirective } from 'ngx-currency';
|
||||
import { ImageCropperComponent } from 'ngx-image-cropper';
|
||||
@@ -53,9 +53,11 @@ import { TOOLBAR_OPTIONS } from '../../utils/defaults';
|
||||
ValidatedLocationComponent,
|
||||
ImageCropAndUploadComponent,
|
||||
],
|
||||
providers: [],
|
||||
templateUrl: './edit-commercial-property-listing.component.html',
|
||||
styleUrl: './edit-commercial-property-listing.component.scss',
|
||||
styleUrls: [
|
||||
'./edit-commercial-property-listing.component.scss',
|
||||
'../../../../../node_modules/quill/dist/quill.snow.css'
|
||||
],
|
||||
})
|
||||
export class EditCommercialPropertyListingComponent {
|
||||
@ViewChild('fileInput') fileInput!: ElementRef<HTMLInputElement>;
|
||||
@@ -91,7 +93,7 @@ export class EditCommercialPropertyListingComponent {
|
||||
editorModules = TOOLBAR_OPTIONS;
|
||||
|
||||
draggedImage: ImageProperty;
|
||||
faTrash = faTrash;
|
||||
faTrash = APP_ICONS.faTrash;
|
||||
suggestions: string[] | undefined;
|
||||
data: BusinessListing;
|
||||
userId: string;
|
||||
|
||||
@@ -16,27 +16,52 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
@for(listing of favorites; track listing){
|
||||
@if(isBusinessOrCommercial(listing)){
|
||||
<tr 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.state }}</td>
|
||||
<td class="py-2 px-4">${{ listing.price.toLocaleString() }}</td>
|
||||
<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 flex">
|
||||
@if(listing.listingsCategory==='business'){
|
||||
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2" [routerLink]="['/business', listing.slug || listing.id]">
|
||||
@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"
|
||||
[routerLink]="['/business', $any(listing).slug || listing.id]">
|
||||
<i class="fa-regular fa-eye"></i>
|
||||
</button>
|
||||
|
||||
} @if(listing.listingsCategory==='commercialProperty'){
|
||||
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2" [routerLink]="['/commercial-property', listing.slug || listing.id]">
|
||||
} @if($any(listing).listingsCategory==='commercialProperty'){
|
||||
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
|
||||
[routerLink]="['/commercial-property', $any(listing).slug || listing.id]">
|
||||
<i class="fa-regular fa-eye"></i>
|
||||
</button>
|
||||
}
|
||||
<button class="bg-red-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2" (click)="confirmDelete(listing)">
|
||||
<button class="bg-red-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
|
||||
(click)="confirmDelete(listing)">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
} @else {
|
||||
<tr class="border-b">
|
||||
<td class="py-2 px-4">{{ $any(listing).firstname }} {{ $any(listing).lastname }}</td>
|
||||
<td class="py-2 px-4">Professional</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">-</td>
|
||||
<td class="py-2 px-4 flex">
|
||||
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
|
||||
[routerLink]="['/details-user', listing.id]">
|
||||
<i class="fa-regular fa-eye"></i>
|
||||
</button>
|
||||
<button class="bg-red-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
|
||||
(click)="confirmDelete(listing)">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -45,25 +70,47 @@
|
||||
<!-- Mobile view -->
|
||||
<div class="md:hidden">
|
||||
<div *ngFor="let listing of favorites" class="bg-white drop-shadow-inner-faint rounded-lg p-4 mb-4">
|
||||
<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>
|
||||
@if(isBusinessOrCommercial(listing)){
|
||||
<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>
|
||||
<div class="flex justify-start">
|
||||
@if(listing.listingsCategory==='business'){
|
||||
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2" [routerLink]="['/business', listing.slug || listing.id]">
|
||||
@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"
|
||||
[routerLink]="['/business', $any(listing).slug || listing.id]">
|
||||
<i class="fa-regular fa-eye"></i>
|
||||
</button>
|
||||
|
||||
} @if(listing.listingsCategory==='commercialProperty'){
|
||||
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2" [routerLink]="['/commercial-property', listing.slug || listing.id]">
|
||||
} @if($any(listing).listingsCategory==='commercialProperty'){
|
||||
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
|
||||
[routerLink]="['/commercial-property', $any(listing).slug || listing.id]">
|
||||
<i class="fa-regular fa-eye"></i>
|
||||
</button>
|
||||
}
|
||||
<button class="bg-red-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2" (click)="confirmDelete(listing)">
|
||||
<button class="bg-red-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
|
||||
(click)="confirmDelete(listing)">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<h2 class="text-xl font-semibold mb-2">{{ $any(listing).firstname }} {{ $any(listing).lastname }}</h2>
|
||||
<p class="text-gray-600 mb-2">Category: Professional</p>
|
||||
<p class="text-gray-600 mb-2">Located in: {{ listing.location?.name ? listing.location.name :
|
||||
listing.location?.county }}, {{ listing.location?.state }}</p>
|
||||
<div class="flex justify-start">
|
||||
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
|
||||
[routerLink]="['/details-user', listing.id]">
|
||||
<i class="fa-regular fa-eye"></i>
|
||||
</button>
|
||||
<button class="bg-red-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2"
|
||||
(click)="confirmDelete(listing)">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { BusinessListing, CommercialPropertyListing } from '../../../../../../bizmatch-server/src/models/db.model';
|
||||
import { BusinessListing, CommercialPropertyListing, User } from '../../../../../../bizmatch-server/src/models/db.model';
|
||||
import { KeycloakUser } from '../../../../../../bizmatch-server/src/models/main.model';
|
||||
import { ConfirmationComponent } from '../../../components/confirmation/confirmation.component';
|
||||
import { ConfirmationService } from '../../../components/confirmation/confirmation.service';
|
||||
@@ -19,28 +19,36 @@ import { map2User } from '../../../utils/utils';
|
||||
export class FavoritesComponent {
|
||||
user: KeycloakUser;
|
||||
// listings: Array<ListingType> = []; //= dataListings as unknown as Array<BusinessListing>;
|
||||
favorites: Array<BusinessListing | CommercialPropertyListing>;
|
||||
constructor(private listingsService: ListingsService, public selectOptions: SelectOptionsService, private confirmationService: ConfirmationService, private authService: AuthService) {}
|
||||
favorites: Array<BusinessListing | CommercialPropertyListing | User>;
|
||||
constructor(private listingsService: ListingsService, public selectOptions: SelectOptionsService, private confirmationService: ConfirmationService, private authService: AuthService) { }
|
||||
async ngOnInit() {
|
||||
const token = await this.authService.getToken();
|
||||
this.user = map2User(token);
|
||||
const result = await Promise.all([await this.listingsService.getFavoriteListings('business'), await this.listingsService.getFavoriteListings('commercialProperty')]);
|
||||
this.favorites = [...result[0], ...result[1]];
|
||||
const result = await Promise.all([await this.listingsService.getFavoriteListings('business'), await this.listingsService.getFavoriteListings('commercialProperty'), await this.listingsService.getFavoriteListings('user')]);
|
||||
this.favorites = [...result[0], ...result[1], ...result[2]] as Array<BusinessListing | CommercialPropertyListing | User>;
|
||||
}
|
||||
async confirmDelete(listing: BusinessListing | CommercialPropertyListing) {
|
||||
async confirmDelete(listing: BusinessListing | CommercialPropertyListing | User) {
|
||||
const confirmed = await this.confirmationService.showConfirmation({ message: `Are you sure you want to remove this listing from your Favorites?` });
|
||||
if (confirmed) {
|
||||
// this.messageService.showMessage('Listing has been deleted');
|
||||
this.deleteListing(listing);
|
||||
}
|
||||
}
|
||||
async deleteListing(listing: BusinessListing | CommercialPropertyListing) {
|
||||
if (listing.listingsCategory === 'business') {
|
||||
await this.listingsService.removeFavorite(listing.id, 'business');
|
||||
async deleteListing(listing: BusinessListing | CommercialPropertyListing | User) {
|
||||
if ('listingsCategory' in listing) {
|
||||
if (listing.listingsCategory === 'business') {
|
||||
await this.listingsService.removeFavorite(listing.id, 'business');
|
||||
} else {
|
||||
await this.listingsService.removeFavorite(listing.id, 'commercialProperty');
|
||||
}
|
||||
} else {
|
||||
await this.listingsService.removeFavorite(listing.id, 'commercialProperty');
|
||||
await this.listingsService.removeFavorite(listing.id, 'user');
|
||||
}
|
||||
const result = await Promise.all([await this.listingsService.getFavoriteListings('business'), await this.listingsService.getFavoriteListings('commercialProperty')]);
|
||||
this.favorites = [...result[0], ...result[1]];
|
||||
const result = await Promise.all([await this.listingsService.getFavoriteListings('business'), await this.listingsService.getFavoriteListings('commercialProperty'), await this.listingsService.getFavoriteListings('user')]);
|
||||
this.favorites = [...result[0], ...result[1], ...result[2]] as Array<BusinessListing | CommercialPropertyListing | User>;
|
||||
}
|
||||
|
||||
isBusinessOrCommercial(listing: any): boolean {
|
||||
return !!listing.listingsCategory;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,11 +183,8 @@ export class AuthService {
|
||||
return Promise.resolve();
|
||||
}
|
||||
isAdmin(): Observable<boolean> {
|
||||
return this.getUserRole().pipe(
|
||||
return this.userRole$.pipe(
|
||||
map(role => role === 'admin'),
|
||||
// take(1) ist optional - es beendet die Subscription, nachdem ein Wert geliefert wurde
|
||||
// Nützlich, wenn du die Methode in einem Template mit dem async pipe verwendest
|
||||
take(1),
|
||||
);
|
||||
}
|
||||
// Get current user's role from the server with caching
|
||||
@@ -196,6 +193,9 @@ export class AuthService {
|
||||
|
||||
// Cache zurücksetzen, wenn die Caching-Zeit abgelaufen ist oder kein Cache existiert
|
||||
if (!this.cachedUserRole$ || now - this.lastCacheTime > this.cacheDuration) {
|
||||
if (!this.getLocalStorageItem('authToken')) {
|
||||
return of(null);
|
||||
}
|
||||
this.lastCacheTime = now;
|
||||
let headers = new HttpHeaders().set('X-Hide-Loading', 'true').set('Accept-Language', 'en-US');
|
||||
this.cachedUserRole$ = this.http.get<{ role: UserRole | null }>(`${environment.apiBaseUrl}/bizmatch/auth/me/role`, { headers }).pipe(
|
||||
|
||||
@@ -226,6 +226,7 @@ export class FilterStateService {
|
||||
minPrice: null,
|
||||
maxPrice: null,
|
||||
title: null,
|
||||
brokerName: null,
|
||||
prompt: null,
|
||||
page: 1,
|
||||
start: 0,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getCriteriaByListingCategory, getSortByListingCategory } from '../utils
|
||||
})
|
||||
export class ListingsService {
|
||||
private apiBaseUrl = environment.apiBaseUrl;
|
||||
constructor(private http: HttpClient) {}
|
||||
constructor(private http: HttpClient) { }
|
||||
|
||||
async getListings(listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty'): Promise<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray> {
|
||||
const criteria = getCriteriaByListingCategory(listingsCategory);
|
||||
@@ -35,8 +35,8 @@ export class ListingsService {
|
||||
getListingsByEmail(email: string, listingsCategory: 'business' | 'commercialProperty'): Promise<ListingType[]> {
|
||||
return lastValueFrom(this.http.get<BusinessListing[]>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/user/${email}`));
|
||||
}
|
||||
getFavoriteListings(listingsCategory: 'business' | 'commercialProperty'): Promise<ListingType[]> {
|
||||
return lastValueFrom(this.http.get<BusinessListing[] | CommercialPropertyListing[]>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorites/all`));
|
||||
getFavoriteListings(listingsCategory: 'business' | 'commercialProperty' | 'user'): Promise<ListingType[]> {
|
||||
return lastValueFrom(this.http.post<BusinessListing[] | CommercialPropertyListing[] | any[]>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorites/all`, {}));
|
||||
}
|
||||
async save(listing: any, listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty') {
|
||||
if (listing.id) {
|
||||
@@ -51,11 +51,15 @@ export class ListingsService {
|
||||
async deleteCommercialPropertyListing(id: string, imagePath: string) {
|
||||
await lastValueFrom(this.http.delete<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/commercialProperty/listing/${id}/${imagePath}`));
|
||||
}
|
||||
async addToFavorites(id: string, listingsCategory?: 'business' | 'commercialProperty') {
|
||||
await lastValueFrom(this.http.post<void>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorite/${id}`, {}));
|
||||
async addToFavorites(id: string, listingsCategory?: 'business' | 'commercialProperty' | 'user') {
|
||||
const url = `${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorite/${id}`;
|
||||
console.log('[ListingsService] addToFavorites calling URL:', url);
|
||||
await lastValueFrom(this.http.post<void>(url, {}));
|
||||
}
|
||||
async removeFavorite(id: string, listingsCategory?: 'business' | 'commercialProperty') {
|
||||
await lastValueFrom(this.http.delete<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorite/${id}`));
|
||||
async removeFavorite(id: string, listingsCategory?: 'business' | 'commercialProperty' | 'user') {
|
||||
const url = `${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorite/${id}`;
|
||||
console.log('[ListingsService] removeFavorite calling URL:', url);
|
||||
await lastValueFrom(this.http.delete<ListingType>(url));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, inject, PLATFORM_ID } from '@angular/core';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { Injectable, inject, PLATFORM_ID, Renderer2, RendererFactory2 } from '@angular/core';
|
||||
import { isPlatformBrowser, DOCUMENT } from '@angular/common';
|
||||
import { Meta, Title } from '@angular/platform-browser';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
@@ -22,10 +22,16 @@ export class SeoService {
|
||||
private router = inject(Router);
|
||||
private platformId = inject(PLATFORM_ID);
|
||||
private isBrowser = isPlatformBrowser(this.platformId);
|
||||
private document = inject(DOCUMENT);
|
||||
private renderer: Renderer2;
|
||||
|
||||
private readonly defaultImage = 'https://biz-match.com/assets/images/bizmatch-og-image.jpg';
|
||||
private readonly defaultImage = 'https://www.bizmatch.net/assets/images/bizmatch-og-image.jpg';
|
||||
private readonly siteName = 'BizMatch';
|
||||
private readonly baseUrl = 'https://biz-match.com';
|
||||
private readonly baseUrl = 'https://www.bizmatch.net';
|
||||
|
||||
constructor(rendererFactory: RendererFactory2) {
|
||||
this.renderer = rendererFactory.createRenderer(null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base URL for SEO purposes
|
||||
@@ -109,20 +115,18 @@ export class SeoService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update canonical URL
|
||||
* Update canonical URL (SSR-compatible using Renderer2)
|
||||
*/
|
||||
private updateCanonicalUrl(url: string): void {
|
||||
if (!this.isBrowser) return;
|
||||
|
||||
let link: HTMLLinkElement | null = document.querySelector('link[rel="canonical"]');
|
||||
let link: HTMLLinkElement | null = this.document.querySelector('link[rel="canonical"]');
|
||||
|
||||
if (link) {
|
||||
link.setAttribute('href', url);
|
||||
this.renderer.setAttribute(link, 'href', url);
|
||||
} else {
|
||||
link = document.createElement('link');
|
||||
link.setAttribute('rel', 'canonical');
|
||||
link.setAttribute('href', url);
|
||||
document.head.appendChild(link);
|
||||
link = this.renderer.createElement('link');
|
||||
this.renderer.setAttribute(link, 'rel', 'canonical');
|
||||
this.renderer.setAttribute(link, 'href', url);
|
||||
this.renderer.appendChild(this.document.head, link);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,32 +273,40 @@ export class SeoService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject JSON-LD structured data into page
|
||||
* Inject JSON-LD structured data into page (SSR-compatible using Renderer2)
|
||||
*/
|
||||
injectStructuredData(schema: object): void {
|
||||
if (!this.isBrowser) return;
|
||||
// Clear existing schema scripts with the same type
|
||||
this.removeAllSchemas();
|
||||
|
||||
// Remove existing schema script
|
||||
const existingScript = document.querySelector('script[type="application/ld+json"]');
|
||||
if (existingScript) {
|
||||
existingScript.remove();
|
||||
}
|
||||
// Create new script element using Renderer2 (works in both SSR and browser)
|
||||
const script = this.renderer.createElement('script');
|
||||
this.renderer.setAttribute(script, 'type', 'application/ld+json');
|
||||
this.renderer.setAttribute(script, 'data-schema', 'true');
|
||||
|
||||
// Add new schema script
|
||||
const script = document.createElement('script');
|
||||
script.type = 'application/ld+json';
|
||||
script.text = JSON.stringify(schema);
|
||||
document.head.appendChild(script);
|
||||
// Create text node with schema JSON
|
||||
const schemaText = this.renderer.createText(JSON.stringify(schema));
|
||||
this.renderer.appendChild(script, schemaText);
|
||||
|
||||
// Append to document head
|
||||
this.renderer.appendChild(this.document.head, script);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all structured data
|
||||
* Remove all schema scripts (internal helper, SSR-compatible)
|
||||
*/
|
||||
private removeAllSchemas(): void {
|
||||
const existingScripts = this.document.querySelectorAll('script[data-schema="true"]');
|
||||
existingScripts.forEach(script => {
|
||||
this.renderer.removeChild(this.document.head, script);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all structured data (SSR-compatible)
|
||||
*/
|
||||
clearStructuredData(): void {
|
||||
if (!this.isBrowser) return;
|
||||
|
||||
const scripts = document.querySelectorAll('script[type="application/ld+json"]');
|
||||
scripts.forEach(script => script.remove());
|
||||
this.removeAllSchemas();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -418,6 +430,7 @@ export class SeoService {
|
||||
|
||||
/**
|
||||
* Generate Organization schema for the company
|
||||
* Enhanced for Knowledge Graph and entity verification
|
||||
*/
|
||||
generateOrganizationSchema(): object {
|
||||
return {
|
||||
@@ -427,18 +440,47 @@ export class SeoService {
|
||||
'url': this.baseUrl,
|
||||
'logo': `${this.baseUrl}/assets/images/bizmatch-logo.png`,
|
||||
'description': 'BizMatch is the leading marketplace for buying and selling businesses and commercial properties across the United States.',
|
||||
|
||||
// Physical address for entity verification
|
||||
'address': {
|
||||
'@type': 'PostalAddress',
|
||||
'streetAddress': '1001 Blucher Street',
|
||||
'addressLocality': 'Corpus Christi',
|
||||
'addressRegion': 'TX',
|
||||
'postalCode': '78401',
|
||||
'addressCountry': 'US'
|
||||
},
|
||||
|
||||
// Contact information (E.164 format)
|
||||
'telephone': '+1-800-840-6025',
|
||||
'email': 'info@bizmatch.net',
|
||||
|
||||
// Social media and entity verification
|
||||
'sameAs': [
|
||||
'https://www.facebook.com/bizmatch',
|
||||
'https://www.linkedin.com/company/bizmatch',
|
||||
'https://twitter.com/bizmatch'
|
||||
// Future: Add Wikidata, Crunchbase, Wikipedia when available
|
||||
],
|
||||
|
||||
// Enhanced contact point
|
||||
'contactPoint': {
|
||||
'@type': 'ContactPoint',
|
||||
'telephone': '+1-800-BIZ-MATCH',
|
||||
'telephone': '+1-800-840-6025',
|
||||
'contactType': 'Customer Service',
|
||||
'areaServed': 'US',
|
||||
'availableLanguage': 'English'
|
||||
}
|
||||
'availableLanguage': 'English',
|
||||
'email': 'info@bizmatch.net'
|
||||
},
|
||||
|
||||
// Business details for Knowledge Graph
|
||||
'foundingDate': '2020',
|
||||
'knowsAbout': [
|
||||
'Business Brokerage',
|
||||
'Commercial Real Estate',
|
||||
'Business Valuation',
|
||||
'Franchise Opportunities'
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -486,20 +528,21 @@ export class SeoService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject multiple structured data schemas
|
||||
* Inject multiple structured data schemas (SSR-compatible using Renderer2)
|
||||
*/
|
||||
injectMultipleSchemas(schemas: object[]): void {
|
||||
if (!this.isBrowser) return;
|
||||
// Clear existing schema scripts
|
||||
this.removeAllSchemas();
|
||||
|
||||
// Remove existing schema scripts
|
||||
this.clearStructuredData();
|
||||
|
||||
// Add new schema scripts
|
||||
// Add new schema scripts using Renderer2
|
||||
schemas.forEach(schema => {
|
||||
const script = document.createElement('script');
|
||||
script.type = 'application/ld+json';
|
||||
script.text = JSON.stringify(schema);
|
||||
document.head.appendChild(script);
|
||||
const script = this.renderer.createElement('script');
|
||||
this.renderer.setAttribute(script, 'type', 'application/ld+json');
|
||||
this.renderer.setAttribute(script, 'data-schema', 'true');
|
||||
|
||||
const schemaText = this.renderer.createText(JSON.stringify(schema));
|
||||
this.renderer.appendChild(script, schemaText);
|
||||
this.renderer.appendChild(this.document.head, script);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface SitemapUrl {
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SitemapService {
|
||||
private readonly baseUrl = 'https://biz-match.com';
|
||||
private readonly baseUrl = 'https://www.bizmatch.net';
|
||||
|
||||
/**
|
||||
* Generate XML sitemap content
|
||||
|
||||
19
bizmatch/src/app/utils/fontawesome-icons.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
faArrowRight,
|
||||
faEnvelope,
|
||||
faLock,
|
||||
faTimes,
|
||||
faTrash,
|
||||
faUserGear,
|
||||
faUserPlus
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
export const APP_ICONS = {
|
||||
faArrowRight,
|
||||
faEnvelope,
|
||||
faLock,
|
||||
faTimes,
|
||||
faTrash,
|
||||
faUserGear,
|
||||
faUserPlus,
|
||||
};
|
||||
25
bizmatch/src/app/utils/logger.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
// Lightweight logger implementation for both dev and production
|
||||
// Avoids dynamic require() which causes build issues
|
||||
const createLoggerImpl = (name: string) => ({
|
||||
info: (...args: any[]) => {
|
||||
if (!environment.production) {
|
||||
console.log(`[${name}]`, ...args);
|
||||
}
|
||||
},
|
||||
warn: (...args: any[]) => console.warn(`[${name}]`, ...args),
|
||||
error: (...args: any[]) => console.error(`[${name}]`, ...args),
|
||||
debug: (...args: any[]) => {
|
||||
if (!environment.production) {
|
||||
console.debug(`[${name}]`, ...args);
|
||||
}
|
||||
},
|
||||
trace: (...args: any[]) => {
|
||||
if (!environment.production) {
|
||||
console.trace(`[${name}]`, ...args);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const createLogger = createLoggerImpl;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router } from '@angular/router';
|
||||
import { ConsoleFormattedStream, INFO, createLogger as _createLogger, stdSerializers } from 'browser-bunyan';
|
||||
import { createLogger as _createLogger } from './logger';
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import onChange from 'on-change';
|
||||
import { SortByOptions, User } from '../../../../bizmatch-server/src/models/db.model';
|
||||
@@ -49,6 +49,7 @@ export function createEmptyCommercialPropertyListingCriteria(): CommercialProper
|
||||
minPrice: null,
|
||||
maxPrice: null,
|
||||
title: '',
|
||||
brokerName: '',
|
||||
searchType: 'exact',
|
||||
radius: null,
|
||||
};
|
||||
@@ -140,14 +141,8 @@ export function createMailInfo(user?: User): MailInfo {
|
||||
listing: null,
|
||||
};
|
||||
}
|
||||
export function createLogger(name: string, level: number = INFO, options: any = {}) {
|
||||
return _createLogger({
|
||||
name,
|
||||
streams: [{ level, stream: new ConsoleFormattedStream() }],
|
||||
serializers: stdSerializers,
|
||||
src: true,
|
||||
...options,
|
||||
});
|
||||
export function createLogger(name: string, level?: number, options?: any) {
|
||||
return _createLogger(name);
|
||||
}
|
||||
export function formatPhoneNumber(phone: string): string {
|
||||
const cleaned = ('' + phone).replace(/\D/g, '');
|
||||
@@ -361,8 +356,8 @@ export function getCriteriaByListingCategory(listingsCategory: 'business' | 'pro
|
||||
listingsCategory === 'business'
|
||||
? sessionStorage.getItem('businessListings')
|
||||
: listingsCategory === 'commercialProperty'
|
||||
? sessionStorage.getItem('commercialPropertyListings')
|
||||
: sessionStorage.getItem('brokerListings');
|
||||
? sessionStorage.getItem('commercialPropertyListings')
|
||||
: sessionStorage.getItem('brokerListings');
|
||||
return storedState ? JSON.parse(storedState) : null;
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 8.7 MiB |
|
Before Width: | Height: | Size: 26 MiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 662 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 662 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 667 B |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
@@ -1,6 +1,6 @@
|
||||
// Build information, automatically generated by `the_build_script` :zwinkern:
|
||||
const build = {
|
||||
timestamp: "GER: 06.01.2026 22:33 | TX: 01/06/2026 3:33 PM"
|
||||
timestamp: "GER: 06.02.2026 12:50 | TX: 02/06/2026 5:50 AM"
|
||||
};
|
||||
|
||||
export default build;
|
||||
@@ -1,8 +1,20 @@
|
||||
// SSR-safe: check if window exists (it doesn't on server-side)
|
||||
const hostname = typeof window !== 'undefined' ? window.location.hostname : 'localhost';
|
||||
// SSR-safe: check if window exists
|
||||
// Im Browser nehmen wir den aktuellen Host (z.B. localhost).
|
||||
// Auf dem Server (SSR in Docker) nehmen wir 'bizmatch-app' (der Name des Backend-Containers).
|
||||
const isBrowser = typeof window !== 'undefined' && window.navigator.userAgent !== 'node';
|
||||
const hostname = isBrowser ? window.location.hostname : 'bizmatch-app';
|
||||
// Im Server-Modus nutzen wir den internen Docker-Namen
|
||||
const internalUrl = 'http://bizmatch-app:3001';
|
||||
// Im Browser-Modus die öffentliche URL
|
||||
const publicUrl = 'https://api.bizmatch.net';
|
||||
const calculatedApiBaseUrl = isBrowser ? publicUrl : internalUrl;
|
||||
// WICHTIG: Port anpassen!
|
||||
// Lokal läuft das Backend auf 3001. Im Docker Container auch auf 3001.
|
||||
// Deine alte Config hatte hier :4200 stehen, das war falsch (das ist das Frontend).
|
||||
export const environment_base = {
|
||||
// apiBaseUrl: 'http://localhost:3000',
|
||||
apiBaseUrl: `http://${hostname}:4200`,
|
||||
// GETTER FUNCTION für die API URL (besser als statischer String für diesen Fall)
|
||||
apiBaseUrl: calculatedApiBaseUrl,
|
||||
imageBaseUrl: 'https://dev.bizmatch.net',
|
||||
buildVersion: '<BUILD_VERSION>',
|
||||
mailinfoUrl: 'https://dev.bizmatch.net',
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { environment_base } from './environment.base';
|
||||
|
||||
export const environment = environment_base;
|
||||
export const environment = { ...environment_base }; // Kopie erstellen
|
||||
|
||||
environment.production = true;
|
||||
|
||||
// WICHTIG: Diese Zeile auskommentieren, solange du lokal testest!
|
||||
// Sonst greift er immer aufs Internet zu, statt auf deinen lokalen Docker-Container.
|
||||
environment.apiBaseUrl = 'https://api.bizmatch.net';
|
||||
|
||||
environment.mailinfoUrl = 'https://www.bizmatch.net';
|
||||
environment.imageBaseUrl = 'https://api.bizmatch.net';
|
||||
environment.imageBaseUrl = 'https://www.bizmatch.net';// Ggf. auch auskommentieren, wenn Bilder lokal liegen
|
||||
|
||||
environment.POSTHOG_KEY = 'phc_eUIcIq0UPVzEDtZLy78klKhGudyagBz3goDlKx8SQFe';
|
||||
environment.POSTHOG_HOST = 'https://eu.i.posthog.com';
|
||||
@@ -35,6 +35,9 @@
|
||||
|
||||
<!-- Preload critical assets -->
|
||||
<link rel="preload" as="image" href="/assets/images/header-logo.png" type="image/png" />
|
||||
<!-- Hero background is LCP element - preload with high priority -->
|
||||
<link rel="preload" as="image" href="/assets/images/flags_bg.avif" type="image/avif" fetchpriority="high" />
|
||||
<link rel="preload" as="image" href="/assets/images/flags_bg.jpg" imagesrcset="/assets/images/flags_bg.jpg" type="image/jpeg" />
|
||||
|
||||
<!-- Prefetch common assets -->
|
||||
<link rel="prefetch" as="image" href="/assets/images/business_logo.png" />
|
||||
@@ -58,6 +61,12 @@
|
||||
<link rel="icon" href="/assets/cropped-Favicon-32x32.png" sizes="32x32" />
|
||||
<link rel="icon" href="/assets/cropped-Favicon-192x192.png" sizes="192x192" />
|
||||
<link rel="apple-touch-icon" href="/assets/cropped-Favicon-180x180.png" />
|
||||
|
||||
<!-- Schema.org Structured Data -->
|
||||
<!-- Note: Organization and WebSite schemas are now injected dynamically by SeoService -->
|
||||
<!-- with more complete data (telephone, foundingDate, knowsAbout, dual search actions) -->
|
||||
|
||||
|
||||
</head>
|
||||
|
||||
<body class="flex flex-col min-h-screen">
|
||||
|
||||