15 Commits

Author SHA1 Message Date
Timo Knuth
e25722d806 fix: SEO meta tags and H1 headings optimization
- Shortened meta titles for better SERP display (businessListings, commercialPropertyListings)
- Optimized meta descriptions to fit within 160 characters (3 pages)
- Enhanced H1 headings with descriptive, keyword-rich text (3 pages)
- Addresses Seobility recommendations for improved search visibility

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 20:19:30 +01:00
Timo Knuth
bf735ed60f feat: SEO improvements and image optimization
- Enhanced SEO service with meta tags and structured data
- Updated sitemap service and robots.txt
- Optimized listing components for better SEO
- Compressed images (saved ~31MB total)
- Added .gitattributes to enforce LF line endings

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 19:48:30 +01:00
0bbfc3f4fb dfgdfg 2026-02-02 17:26:00 -06:00
3b47540985 css change 2026-02-02 17:00:22 -06:00
21d7f16289 CSS Fix for Firefox 2026-02-02 16:49:10 -06:00
c632cd90b5 Merge branch 'timo' 2026-02-02 09:31:36 -06:00
152304aa71 comment app-faq 2026-02-02 09:30:26 -06:00
e8f493558f listings 2026-01-18 19:48:45 +01:00
31a507ad58 script 2026-01-18 19:36:17 +01:00
447027db2b Issues gitea 2.0 2026-01-15 21:35:49 +01:00
09e7ce59a9 Issues gitea 2026-01-15 21:26:07 +01:00
897ab1ff77 Final cleanup and documentation updates 2026-01-12 14:03:48 +01:00
Timo
1874d5f4ed Merge branch 'timo' of https://gitea.bizmatch.net/aknuth/bizmatch-project into timo 2026-01-12 13:59:16 +01:00
adeefb199c Fix business filtering logic and add docker sync guide 2026-01-12 13:58:45 +01:00
c2d7a53039 dfg 2025-11-30 12:20:01 -06:00
56 changed files with 5129 additions and 4184 deletions

View File

@@ -13,7 +13,22 @@
"Bash(sudo chown:*)", "Bash(sudo chown:*)",
"Bash(chmod:*)", "Bash(chmod:*)",
"Bash(npm audit:*)", "Bash(npm audit:*)",
"Bash(npm view:*)" "Bash(npm view:*)",
"Bash(npm run build:ssr:*)",
"Bash(pkill:*)",
"WebSearch",
"Bash(lsof:*)",
"Bash(xargs:*)",
"Bash(curl:*)",
"Bash(grep:*)",
"Bash(cat:*)",
"Bash(NODE_ENV=development npm run build:ssr:*)",
"Bash(ls:*)",
"WebFetch(domain:angular.dev)",
"Bash(killall:*)",
"Bash(echo:*)",
"Bash(npm run build:*)",
"Bash(npx tsc:*)"
] ]
} }
} }

4
.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.jpeg binary

27
DOCKER_SYNC_GUIDE.md Normal file
View 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.
.

View File

@@ -1,73 +1,73 @@
# Final Project Summary & Deployment Guide # Final Project Summary & Deployment Guide
## Recent Changes (Last 3 Git Pushes) ## Recent Changes (Last 3 Git Pushes)
Here is a summary of the most recent activity on the repository: Here is a summary of the most recent activity on the repository:
1. **`e3e726d`** - Timo, 3 minutes ago 1. **`e3e726d`** - Timo, 3 minutes ago
* **Message**: `feat: Initialize BizMatch application with core UI components, routing, listing pages, backend services, migration scripts, and vulnerability management.` * **Message**: `feat: Initialize BizMatch application with core UI components, routing, listing pages, backend services, migration scripts, and vulnerability management.`
* **Impact**: Major initialization of the application structure, including core features and security baselines. * **Impact**: Major initialization of the application structure, including core features and security baselines.
2. **`e32e43d`** - Timo, 10 hours ago 2. **`e32e43d`** - Timo, 10 hours ago
* **Message**: `docs: Add comprehensive deployment guide for BizMatch project.` * **Message**: `docs: Add comprehensive deployment guide for BizMatch project.`
* **Impact**: Added documentation for deployment procedures. * **Impact**: Added documentation for deployment procedures.
3. **`b52e47b`** - Timo, 10 hours ago 3. **`b52e47b`** - Timo, 10 hours ago
* **Message**: `feat: Initialize Angular SSR application with core pages, components, and server setup.` * **Message**: `feat: Initialize Angular SSR application with core pages, components, and server setup.`
* **Impact**: Initial naming and setup of the Angular SSR environment. * **Impact**: Initial naming and setup of the Angular SSR environment.
--- ---
## Deployment Instructions ## Deployment Instructions
### 1. Prerequisites ### 1. Prerequisites
* **Node.js**: Version **20.x** or higher is recommended. * **Node.js**: Version **20.x** or higher is recommended.
* **Package Manager**: `npm`. * **Package Manager**: `npm`.
### 2. Building for Production (SSR) ### 2. Building for Production (SSR)
The application is configured for **Angular SSR (Server-Side Rendering)**. You must build the application specifically for this mode. The application is configured for **Angular SSR (Server-Side Rendering)**. You must build the application specifically for this mode.
**Steps:** **Steps:**
1. Navigate to the project directory: 1. Navigate to the project directory:
```bash ```bash
cd bizmatch cd bizmatch
``` ```
2. Install dependencies: 2. Install dependencies:
```bash ```bash
npm install npm install
``` ```
3. Build the project: 3. Build the project:
```bash ```bash
npm run build:ssr npm run build:ssr
``` ```
* This command executes `node version.js` (to update build versions) and then `ng build --configuration prod`. * This command executes `node version.js` (to update build versions) and then `ng build --configuration prod`.
* Output will be generated in `dist/bizmatch/browser` and `dist/bizmatch/server`. * Output will be generated in `dist/bizmatch/browser` and `dist/bizmatch/server`.
### 3. Running the Application ### 3. Running the Application
To start the production server: To start the production server:
```bash ```bash
npm run serve:ssr npm run serve:ssr
``` ```
* **Entry Point**: `dist/bizmatch/server/server.mjs` * **Entry Point**: `dist/bizmatch/server/server.mjs`
* **Port**: The server listens on `process.env.PORT` or defaults to **4200**. * **Port**: The server listens on `process.env.PORT` or defaults to **4200**.
### 4. Critical Deployment Checks (SSR & Polyfills) ### 4. Critical Deployment Checks (SSR & Polyfills)
**⚠️ IMPORTANT:** **⚠️ IMPORTANT:**
The application uses a custom **DOM Polyfill** to support third-party libraries that might rely on browser-specific objects (like `window`, `document`) during server-side rendering. The application uses a custom **DOM Polyfill** to support third-party libraries that might rely on browser-specific objects (like `window`, `document`) during server-side rendering.
* **Polyfill Location**: `src/ssr-dom-polyfill.ts` * **Polyfill Location**: `src/ssr-dom-polyfill.ts`
* **Server Verification**: Open `server.ts` and ensure the polyfill is imported **BEFORE** any other imports: * **Server Verification**: Open `server.ts` and ensure the polyfill is imported **BEFORE** any other imports:
```typescript ```typescript
// IMPORTANT: DOM polyfill must be imported FIRST // IMPORTANT: DOM polyfill must be imported FIRST
import './src/ssr-dom-polyfill'; import './src/ssr-dom-polyfill';
``` ```
* **Why is this important?** * **Why is this important?**
If this import is removed or moved down, you may encounter `ReferenceError: window is not defined` or `document is not defined` errors when the server tries to render pages containing Leaflet maps or other browser-only libraries. If this import is removed or moved down, you may encounter `ReferenceError: window is not defined` or `document is not defined` errors when the server tries to render pages containing Leaflet maps or other browser-only libraries.
### 5. Environment Variables & Security ### 5. Environment Variables & Security
* Ensure all necessary environment variables (e.g., Database URLs, API Keys) are configured in your deployment environment. * Ensure all necessary environment variables (e.g., Database URLs, API Keys) are configured in your deployment environment.
* Since `server.ts` is an Express app, you can extend it to handle specialized headers or proxy configurations if needed. * Since `server.ts` is an Express app, you can extend it to handle specialized headers or proxy configurations if needed.
### 6. Vulnerability Status ### 6. Vulnerability Status
* Please refer to `FINAL_VULNERABILITY_STATUS.md` for the most recent security audit and known issues. * Please refer to `FINAL_VULNERABILITY_STATUS.md` for the most recent security audit and known issues.

View File

@@ -109,4 +109,4 @@
"coverageDirectory": "../coverage", "coverageDirectory": "../coverage",
"testEnvironment": "node" "testEnvironment": "node"
} }
} }

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

View File

@@ -5,7 +5,7 @@ import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger } from 'winston'; import { Logger } from 'winston';
import { ZodError } from 'zod'; import { ZodError } from 'zod';
import * as schema from '../drizzle/schema'; import * as schema from '../drizzle/schema';
import { businesses_json, PG_CONNECTION, users_json } from '../drizzle/schema'; import { businesses_json, PG_CONNECTION } from '../drizzle/schema';
import { GeoService } from '../geo/geo.service'; import { GeoService } from '../geo/geo.service';
import { BusinessListing, BusinessListingSchema } from '../models/db.model'; import { BusinessListing, BusinessListingSchema } from '../models/db.model';
import { BusinessListingCriteria, JwtUser } from '../models/main.model'; import { BusinessListingCriteria, JwtUser } from '../models/main.model';
@@ -18,27 +18,30 @@ export class BusinessListingService {
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, @Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>, @Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
private geoService?: GeoService, private geoService?: GeoService,
) {} ) { }
private getWhereConditions(criteria: BusinessListingCriteria, user: JwtUser): SQL[] { private getWhereConditions(criteria: BusinessListingCriteria, user: JwtUser): SQL[] {
const whereConditions: SQL[] = []; const whereConditions: SQL[] = [];
this.logger.info('getWhereConditions start', { criteria: JSON.stringify(criteria) });
if (criteria.city && criteria.searchType === 'exact') { if (criteria.city && criteria.searchType === 'exact') {
whereConditions.push(sql`(${businesses_json.data}->'location'->>'name') ILIKE ${criteria.city.name}`); whereConditions.push(sql`(${businesses_json.data}->'location'->>'name') ILIKE ${criteria.city.name}`);
} }
if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) { if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) {
this.logger.debug('Adding radius search filter', { city: criteria.city.name, radius: criteria.radius });
const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name); const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name);
whereConditions.push(sql`${getDistanceQuery(businesses_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`); whereConditions.push(sql`(${getDistanceQuery(businesses_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius})`);
} }
if (criteria.types && Array.isArray(criteria.types) && criteria.types.length > 0) { if (criteria.types && criteria.types.length > 0) {
const validTypes = criteria.types.filter(t => t !== null && t !== undefined && t !== ''); this.logger.warn('Adding business category filter', { types: criteria.types });
if (validTypes.length > 0) { // Use explicit SQL with IN for robust JSONB comparison
whereConditions.push(inArray(sql`${businesses_json.data}->>'type'`, validTypes)); const typeValues = criteria.types.map(t => sql`${t}`);
} whereConditions.push(sql`((${businesses_json.data}->>'type') IN (${sql.join(typeValues, sql`, `)}))`);
} }
if (criteria.state) { if (criteria.state) {
this.logger.debug('Adding state filter', { state: criteria.state });
whereConditions.push(sql`(${businesses_json.data}->'location'->>'state') = ${criteria.state}`); whereConditions.push(sql`(${businesses_json.data}->'location'->>'state') = ${criteria.state}`);
} }
@@ -105,27 +108,30 @@ export class BusinessListingService {
if (criteria.title && criteria.title.trim() !== '') { if (criteria.title && criteria.title.trim() !== '') {
const searchTerm = `%${criteria.title.trim()}%`; const searchTerm = `%${criteria.title.trim()}%`;
whereConditions.push( whereConditions.push(
or( sql`((${businesses_json.data}->>'title') ILIKE ${searchTerm} OR (${businesses_json.data}->>'description') ILIKE ${searchTerm})`
sql`(${businesses_json.data}->>'title') ILIKE ${searchTerm}`,
sql`(${businesses_json.data}->>'description') ILIKE ${searchTerm}`
)
); );
} }
if (criteria.brokerName) { if (criteria.brokerName) {
const { firstname, lastname } = splitName(criteria.brokerName); const { firstname, lastname } = splitName(criteria.brokerName);
if (firstname === lastname) { if (firstname === lastname) {
whereConditions.push(sql`(${users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${users_json.data}->>'lastname') ILIKE ${`%${lastname}%`}`); whereConditions.push(
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
);
} else { } else {
whereConditions.push(sql`(${users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} AND (${users_json.data}->>'lastname') ILIKE ${`%${lastname}%`}`); whereConditions.push(
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} AND (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
);
} }
} }
if (criteria.email) { if (criteria.email) {
whereConditions.push(eq(users_json.email, criteria.email)); whereConditions.push(eq(schema.users_json.email, criteria.email));
} }
if (user?.role !== 'admin') { if (user?.role !== 'admin') {
whereConditions.push(or(eq(businesses_json.email, user?.email), sql`(${businesses_json.data}->>'draft')::boolean IS NOT TRUE`)); whereConditions.push(
sql`((${businesses_json.email} = ${user?.email || null}) OR (${businesses_json.data}->>'draft')::boolean IS NOT TRUE)`
);
} }
whereConditions.push(and(sql`(${users_json.data}->>'customerType') = 'professional'`, sql`(${users_json.data}->>'customerSubType') = 'broker'`)); this.logger.warn('whereConditions count', { count: whereConditions.length });
return whereConditions; return whereConditions;
} }
@@ -135,24 +141,21 @@ export class BusinessListingService {
const query = this.conn const query = this.conn
.select({ .select({
business: businesses_json, business: businesses_json,
brokerFirstName: sql`${users_json.data}->>'firstname'`.as('brokerFirstName'), brokerFirstName: sql`${schema.users_json.data}->>'firstname'`.as('brokerFirstName'),
brokerLastName: sql`${users_json.data}->>'lastname'`.as('brokerLastName'), brokerLastName: sql`${schema.users_json.data}->>'lastname'`.as('brokerLastName'),
}) })
.from(businesses_json) .from(businesses_json)
.leftJoin(users_json, eq(businesses_json.email, users_json.email)); .leftJoin(schema.users_json, eq(businesses_json.email, schema.users_json.email));
const whereConditions = this.getWhereConditions(criteria, user); const whereConditions = this.getWhereConditions(criteria, user);
// Uncomment for debugging filter issues: this.logger.warn('Filter Criteria:', { criteria: JSON.stringify(criteria) });
// this.logger.info('Filter Criteria:', { criteria });
// this.logger.info('Where Conditions Count:', { count: whereConditions.length });
if (whereConditions.length > 0) { if (whereConditions.length > 0) {
const whereClause = and(...whereConditions); const whereClause = sql.join(whereConditions, sql` AND `);
query.where(whereClause); query.where(sql`(${whereClause})`);
// Uncomment for debugging SQL queries: this.logger.warn('Generated SQL:', { sql: query.toSQL().sql, params: query.toSQL().params });
// this.logger.info('Generated SQL:', { sql: query.toSQL() });
} }
// Sortierung // Sortierung
@@ -228,13 +231,13 @@ export class BusinessListingService {
} }
async getBusinessListingsCount(criteria: BusinessListingCriteria, user: JwtUser): Promise<number> { async getBusinessListingsCount(criteria: BusinessListingCriteria, user: JwtUser): Promise<number> {
const countQuery = this.conn.select({ value: count() }).from(businesses_json).leftJoin(users_json, eq(businesses_json.email, users_json.email)); const countQuery = this.conn.select({ value: count() }).from(businesses_json).leftJoin(schema.users_json, eq(businesses_json.email, schema.users_json.email));
const whereConditions = this.getWhereConditions(criteria, user); const whereConditions = this.getWhereConditions(criteria, user);
if (whereConditions.length > 0) { if (whereConditions.length > 0) {
const whereClause = and(...whereConditions); const whereClause = sql.join(whereConditions, sql` AND `);
countQuery.where(whereClause); countQuery.where(sql`(${whereClause})`);
} }
const [{ value: totalCount }] = await countQuery; const [{ value: totalCount }] = await countQuery;

View File

@@ -13,7 +13,13 @@ export class BusinessListingsController {
constructor( constructor(
private readonly listingsService: BusinessListingService, private readonly listingsService: BusinessListingService,
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, @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) @UseGuards(OptionalAuthGuard)
@Get(':slugOrId') @Get(':slugOrId')
@@ -21,11 +27,7 @@ export class BusinessListingsController {
// Support both slug (e.g., "restaurant-austin-tx-a3f7b2c1") and UUID // Support both slug (e.g., "restaurant-austin-tx-a3f7b2c1") and UUID
return await this.listingsService.findBusinessBySlugOrId(slugOrId, req.user as JwtUser); 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) @UseGuards(OptionalAuthGuard)
@Get('user/:userid') @Get('user/:userid')
async findByUserId(@Request() req, @Param('userid') userid: string): Promise<BusinessListing[]> { async findByUserId(@Request() req, @Param('userid') userid: string): Promise<BusinessListing[]> {

View File

@@ -15,7 +15,13 @@ export class CommercialPropertyListingsController {
private readonly listingsService: CommercialPropertyService, private readonly listingsService: CommercialPropertyService,
private fileService: FileService, private fileService: FileService,
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger, @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) @UseGuards(OptionalAuthGuard)
@Get(':slugOrId') @Get(':slugOrId')
@@ -24,12 +30,6 @@ export class CommercialPropertyListingsController {
return await this.listingsService.findCommercialBySlugOrId(slugOrId, req.user as JwtUser); 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) @UseGuards(OptionalAuthGuard)
@Get('user/:email') @Get('user/:email')
async findByEmail(@Request() req, @Param('email') email: string): Promise<CommercialPropertyListing[]> { async findByEmail(@Request() req, @Param('email') email: string): Promise<CommercialPropertyListing[]> {

View File

@@ -10,7 +10,7 @@ import { FileService } from '../file/file.service';
import { GeoService } from '../geo/geo.service'; import { GeoService } from '../geo/geo.service';
import { CommercialPropertyListing, CommercialPropertyListingSchema } from '../models/db.model'; import { CommercialPropertyListing, CommercialPropertyListingSchema } from '../models/db.model';
import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model'; import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model';
import { getDistanceQuery } from '../utils'; import { getDistanceQuery, splitName } from '../utils';
import { generateSlug, extractShortIdFromSlug, isSlug } from '../utils/slug.utils'; import { generateSlug, extractShortIdFromSlug, isSlug } from '../utils/slug.utils';
@Injectable() @Injectable()
@@ -20,7 +20,7 @@ export class CommercialPropertyService {
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>, @Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
private fileService?: FileService, private fileService?: FileService,
private geoService?: GeoService, private geoService?: GeoService,
) {} ) { }
private getWhereConditions(criteria: CommercialPropertyListingCriteria, user: JwtUser): SQL[] { private getWhereConditions(criteria: CommercialPropertyListingCriteria, user: JwtUser): SQL[] {
const whereConditions: SQL[] = []; const whereConditions: SQL[] = [];
@@ -32,7 +32,10 @@ export class CommercialPropertyService {
whereConditions.push(sql`${getDistanceQuery(commercials_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`); whereConditions.push(sql`${getDistanceQuery(commercials_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`);
} }
if (criteria.types && criteria.types.length > 0) { if (criteria.types && criteria.types.length > 0) {
whereConditions.push(inArray(sql`${commercials_json.data}->>'type'`, criteria.types)); this.logger.warn('Adding commercial property type filter', { types: criteria.types });
// Use explicit SQL with IN for robust JSONB comparison
const typeValues = criteria.types.map(t => sql`${t}`);
whereConditions.push(sql`((${commercials_json.data}->>'type') IN (${sql.join(typeValues, sql`, `)}))`);
} }
if (criteria.state) { if (criteria.state) {
@@ -48,12 +51,32 @@ export class CommercialPropertyService {
} }
if (criteria.title) { if (criteria.title) {
whereConditions.push(sql`(${commercials_json.data}->>'title') ILIKE ${`%${criteria.title}%`} OR (${commercials_json.data}->>'description') ILIKE ${`%${criteria.title}%`}`); whereConditions.push(
sql`((${commercials_json.data}->>'title') ILIKE ${`%${criteria.title}%`} OR (${commercials_json.data}->>'description') ILIKE ${`%${criteria.title}%`})`
);
} }
if (criteria.brokerName) {
const { firstname, lastname } = splitName(criteria.brokerName);
if (firstname === lastname) {
// Single word: search either first OR last name
whereConditions.push(
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
);
} else {
// Multiple words: search both first AND last name
whereConditions.push(
sql`((${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} AND (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`})`
);
}
}
if (user?.role !== 'admin') { if (user?.role !== 'admin') {
whereConditions.push(or(eq(commercials_json.email, user?.email), sql`(${commercials_json.data}->>'draft')::boolean IS NOT TRUE`)); whereConditions.push(
sql`((${commercials_json.email} = ${user?.email || null}) OR (${commercials_json.data}->>'draft')::boolean IS NOT TRUE)`
);
} }
// whereConditions.push(and(eq(schema.users.customerType, 'professional'))); this.logger.warn('whereConditions count', { count: whereConditions.length });
return whereConditions; return whereConditions;
} }
// #### Find by criteria ######################################## // #### Find by criteria ########################################
@@ -63,9 +86,13 @@ export class CommercialPropertyService {
const query = this.conn.select({ commercial: commercials_json }).from(commercials_json).leftJoin(schema.users_json, eq(commercials_json.email, schema.users_json.email)); const query = this.conn.select({ commercial: commercials_json }).from(commercials_json).leftJoin(schema.users_json, eq(commercials_json.email, schema.users_json.email));
const whereConditions = this.getWhereConditions(criteria, user); const whereConditions = this.getWhereConditions(criteria, user);
this.logger.warn('Filter Criteria:', { criteria: JSON.stringify(criteria) });
if (whereConditions.length > 0) { if (whereConditions.length > 0) {
const whereClause = and(...whereConditions); const whereClause = sql.join(whereConditions, sql` AND `);
query.where(whereClause); query.where(sql`(${whereClause})`);
this.logger.warn('Generated SQL:', { sql: query.toSQL().sql, params: query.toSQL().params });
} }
// Sortierung // Sortierung
switch (criteria.sortBy) { switch (criteria.sortBy) {
@@ -103,8 +130,8 @@ export class CommercialPropertyService {
const whereConditions = this.getWhereConditions(criteria, user); const whereConditions = this.getWhereConditions(criteria, user);
if (whereConditions.length > 0) { if (whereConditions.length > 0) {
const whereClause = and(...whereConditions); const whereClause = sql.join(whereConditions, sql` AND `);
countQuery.where(whereClause); countQuery.where(sql`(${whereClause})`);
} }
const [{ value: totalCount }] = await countQuery; const [{ value: totalCount }] = await countQuery;

View File

@@ -6,6 +6,7 @@ import { UserService } from '../user/user.service';
import { BrokerListingsController } from './broker-listings.controller'; import { BrokerListingsController } from './broker-listings.controller';
import { BusinessListingsController } from './business-listings.controller'; import { BusinessListingsController } from './business-listings.controller';
import { CommercialPropertyListingsController } from './commercial-property-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 { FirebaseAdminModule } from 'src/firebase-admin/firebase-admin.module';
import { GeoModule } from '../geo/geo.module'; import { GeoModule } from '../geo/geo.module';
@@ -16,7 +17,7 @@ import { UnknownListingsController } from './unknown-listings.controller';
@Module({ @Module({
imports: [DrizzleModule, AuthModule, GeoModule,FirebaseAdminModule], imports: [DrizzleModule, AuthModule, GeoModule,FirebaseAdminModule],
controllers: [BusinessListingsController, CommercialPropertyListingsController, UnknownListingsController, BrokerListingsController], controllers: [BusinessListingsController, CommercialPropertyListingsController, UnknownListingsController, BrokerListingsController, UserListingsController],
providers: [BusinessListingService, CommercialPropertyService, FileService, UserService, BusinessListingService, CommercialPropertyService, GeoService], providers: [BusinessListingService, CommercialPropertyService, FileService, UserService, BusinessListingService, CommercialPropertyService, GeoService],
exports: [BusinessListingService, CommercialPropertyService], exports: [BusinessListingService, CommercialPropertyService],
}) })

View 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);
}
}

View File

@@ -34,6 +34,7 @@ export const CustomerTypeEnum = z.enum(['buyer', 'seller', 'professional']);
export const SubscriptionTypeEnum = z.enum(['free', 'professional', 'broker']); export const SubscriptionTypeEnum = z.enum(['free', 'professional', 'broker']);
export const CustomerSubTypeEnum = z.enum(['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']); export const CustomerSubTypeEnum = z.enum(['broker', 'cpa', 'attorney', 'titleCompany', 'surveyor', 'appraiser']);
export const ListingsCategoryEnum = z.enum(['commercialProperty', 'business']); export const ListingsCategoryEnum = z.enum(['commercialProperty', 'business']);
export const ShareCategoryEnum = z.enum(['commercialProperty', 'business', 'user']);
export const ZodEventTypeEnum = z.enum(['view', 'print', 'email', 'facebook', 'x', 'linkedin', 'contact', 'favorite', 'emailus', 'pricing']); export const ZodEventTypeEnum = z.enum(['view', 'print', 'email', 'facebook', 'x', 'linkedin', 'contact', 'favorite', 'emailus', 'pricing']);
export type EventTypeEnum = z.infer<typeof ZodEventTypeEnum>; export type EventTypeEnum = z.infer<typeof ZodEventTypeEnum>;
const PropertyTypeEnum = z.enum(['retail', 'land', 'industrial', 'office', 'mixedUse', 'multifamily', 'uncategorized']); const PropertyTypeEnum = z.enum(['retail', 'land', 'industrial', 'office', 'mixedUse', 'multifamily', 'uncategorized']);
@@ -186,6 +187,7 @@ export const UserSchema = z
updated: z.date().optional().nullable(), updated: z.date().optional().nullable(),
subscriptionId: z.string().optional().nullable(), subscriptionId: z.string().optional().nullable(),
subscriptionPlan: SubscriptionTypeEnum.optional().nullable(), subscriptionPlan: SubscriptionTypeEnum.optional().nullable(),
favoritesForUser: z.array(z.string()),
showInDirectory: z.boolean(), showInDirectory: z.boolean(),
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
@@ -369,7 +371,7 @@ export const ShareByEMailSchema = z.object({
listingTitle: z.string().optional().nullable(), listingTitle: z.string().optional().nullable(),
url: z.string().url({ message: 'Invalid URL format' }).optional().nullable(), url: z.string().url({ message: 'Invalid URL format' }).optional().nullable(),
id: z.string().optional().nullable(), id: z.string().optional().nullable(),
type: ListingsCategoryEnum, type: ShareCategoryEnum,
}); });
export type ShareByEMail = z.infer<typeof ShareByEMailSchema>; export type ShareByEMail = z.infer<typeof ShareByEMailSchema>;

View File

@@ -96,6 +96,7 @@ export interface CommercialPropertyListingCriteria extends ListCriteria {
minPrice: number; minPrice: number;
maxPrice: number; maxPrice: number;
title: string; title: string;
brokerName: string;
criteriaType: 'commercialPropertyListings'; criteriaType: 'commercialPropertyListings';
} }
export interface UserListingCriteria extends ListCriteria { export interface UserListingCriteria extends ListCriteria {
@@ -358,6 +359,7 @@ export function createDefaultUser(email: string, firstname: string, lastname: st
updated: new Date(), updated: new Date(),
subscriptionId: null, subscriptionId: null,
subscriptionPlan: subscriptionPlan, subscriptionPlan: subscriptionPlan,
favoritesForUser: [],
showInDirectory: false, showInDirectory: false,
}; };
} }

View 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

View File

@@ -1,362 +1,362 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { eq, sql } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as schema from '../drizzle/schema'; import * as schema from '../drizzle/schema';
import { PG_CONNECTION } from '../drizzle/schema'; import { PG_CONNECTION } from '../drizzle/schema';
interface SitemapUrl { interface SitemapUrl {
loc: string; loc: string;
lastmod?: string; lastmod?: string;
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never'; changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
priority?: number; priority?: number;
} }
interface SitemapIndexEntry { interface SitemapIndexEntry {
loc: string; loc: string;
lastmod?: string; lastmod?: string;
} }
@Injectable() @Injectable()
export class SitemapService { 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 private readonly URLS_PER_SITEMAP = 10000; // Google best practice
constructor(@Inject(PG_CONNECTION) private readonly db: NodePgDatabase<typeof schema>) { } constructor(@Inject(PG_CONNECTION) private readonly db: NodePgDatabase<typeof schema>) { }
/** /**
* Generate sitemap index (main sitemap.xml) * Generate sitemap index (main sitemap.xml)
* Lists all sitemap files: static, business-1, business-2, commercial-1, etc. * Lists all sitemap files: static, business-1, business-2, commercial-1, etc.
*/ */
async generateSitemapIndex(): Promise<string> { async generateSitemapIndex(): Promise<string> {
const sitemaps: SitemapIndexEntry[] = []; const sitemaps: SitemapIndexEntry[] = [];
// Add static pages sitemap // Add static pages sitemap
sitemaps.push({ sitemaps.push({
loc: `${this.baseUrl}/bizmatch/sitemap/static.xml`, loc: `${this.baseUrl}/bizmatch/sitemap/static.xml`,
lastmod: this.formatDate(new Date()), lastmod: this.formatDate(new Date()),
}); });
// Count business listings // Count business listings
const businessCount = await this.getBusinessListingsCount(); const businessCount = await this.getBusinessListingsCount();
const businessPages = Math.ceil(businessCount / this.URLS_PER_SITEMAP) || 1; const businessPages = Math.ceil(businessCount / this.URLS_PER_SITEMAP) || 1;
for (let page = 1; page <= businessPages; page++) { for (let page = 1; page <= businessPages; page++) {
sitemaps.push({ sitemaps.push({
loc: `${this.baseUrl}/bizmatch/sitemap/business-${page}.xml`, loc: `${this.baseUrl}/bizmatch/sitemap/business-${page}.xml`,
lastmod: this.formatDate(new Date()), lastmod: this.formatDate(new Date()),
}); });
} }
// Count commercial property listings // Count commercial property listings
const commercialCount = await this.getCommercialPropertiesCount(); const commercialCount = await this.getCommercialPropertiesCount();
const commercialPages = Math.ceil(commercialCount / this.URLS_PER_SITEMAP) || 1; const commercialPages = Math.ceil(commercialCount / this.URLS_PER_SITEMAP) || 1;
for (let page = 1; page <= commercialPages; page++) { for (let page = 1; page <= commercialPages; page++) {
sitemaps.push({ sitemaps.push({
loc: `${this.baseUrl}/bizmatch/sitemap/commercial-${page}.xml`, loc: `${this.baseUrl}/bizmatch/sitemap/commercial-${page}.xml`,
lastmod: this.formatDate(new Date()), lastmod: this.formatDate(new Date()),
}); });
} }
// Count broker profiles // Count broker profiles
const brokerCount = await this.getBrokerProfilesCount(); const brokerCount = await this.getBrokerProfilesCount();
const brokerPages = Math.ceil(brokerCount / this.URLS_PER_SITEMAP) || 1; const brokerPages = Math.ceil(brokerCount / this.URLS_PER_SITEMAP) || 1;
for (let page = 1; page <= brokerPages; page++) { for (let page = 1; page <= brokerPages; page++) {
sitemaps.push({ sitemaps.push({
loc: `${this.baseUrl}/bizmatch/sitemap/brokers-${page}.xml`, loc: `${this.baseUrl}/bizmatch/sitemap/brokers-${page}.xml`,
lastmod: this.formatDate(new Date()), lastmod: this.formatDate(new Date()),
}); });
} }
return this.buildXmlSitemapIndex(sitemaps); return this.buildXmlSitemapIndex(sitemaps);
} }
/** /**
* Generate static pages sitemap * Generate static pages sitemap
*/ */
async generateStaticSitemap(): Promise<string> { async generateStaticSitemap(): Promise<string> {
const urls = this.getStaticPageUrls(); const urls = this.getStaticPageUrls();
return this.buildXmlSitemap(urls); return this.buildXmlSitemap(urls);
} }
/** /**
* Generate business listings sitemap (paginated) * Generate business listings sitemap (paginated)
*/ */
async generateBusinessSitemap(page: number): Promise<string> { async generateBusinessSitemap(page: number): Promise<string> {
const offset = (page - 1) * this.URLS_PER_SITEMAP; const offset = (page - 1) * this.URLS_PER_SITEMAP;
const urls = await this.getBusinessListingUrls(offset, this.URLS_PER_SITEMAP); const urls = await this.getBusinessListingUrls(offset, this.URLS_PER_SITEMAP);
return this.buildXmlSitemap(urls); return this.buildXmlSitemap(urls);
} }
/** /**
* Generate commercial property sitemap (paginated) * Generate commercial property sitemap (paginated)
*/ */
async generateCommercialSitemap(page: number): Promise<string> { async generateCommercialSitemap(page: number): Promise<string> {
const offset = (page - 1) * this.URLS_PER_SITEMAP; const offset = (page - 1) * this.URLS_PER_SITEMAP;
const urls = await this.getCommercialPropertyUrls(offset, this.URLS_PER_SITEMAP); const urls = await this.getCommercialPropertyUrls(offset, this.URLS_PER_SITEMAP);
return this.buildXmlSitemap(urls); return this.buildXmlSitemap(urls);
} }
/** /**
* Build XML sitemap index * Build XML sitemap index
*/ */
private buildXmlSitemapIndex(sitemaps: SitemapIndexEntry[]): string { private buildXmlSitemapIndex(sitemaps: SitemapIndexEntry[]): string {
const sitemapElements = sitemaps const sitemapElements = sitemaps
.map(sitemap => { .map(sitemap => {
let element = ` <sitemap>\n <loc>${sitemap.loc}</loc>`; let element = ` <sitemap>\n <loc>${sitemap.loc}</loc>`;
if (sitemap.lastmod) { if (sitemap.lastmod) {
element += `\n <lastmod>${sitemap.lastmod}</lastmod>`; element += `\n <lastmod>${sitemap.lastmod}</lastmod>`;
} }
element += '\n </sitemap>'; element += '\n </sitemap>';
return element; return element;
}) })
.join('\n'); .join('\n');
return `<?xml version="1.0" encoding="UTF-8"?> return `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${sitemapElements} ${sitemapElements}
</sitemapindex>`; </sitemapindex>`;
} }
/** /**
* Build XML sitemap string * Build XML sitemap string
*/ */
private buildXmlSitemap(urls: SitemapUrl[]): string { private buildXmlSitemap(urls: SitemapUrl[]): string {
const urlElements = urls.map(url => this.buildUrlElement(url)).join('\n '); const urlElements = urls.map(url => this.buildUrlElement(url)).join('\n ');
return `<?xml version="1.0" encoding="UTF-8"?> return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urlElements} ${urlElements}
</urlset>`; </urlset>`;
} }
/** /**
* Build single URL element * Build single URL element
*/ */
private buildUrlElement(url: SitemapUrl): string { private buildUrlElement(url: SitemapUrl): string {
let element = `<url>\n <loc>${url.loc}</loc>`; let element = `<url>\n <loc>${url.loc}</loc>`;
if (url.lastmod) { if (url.lastmod) {
element += `\n <lastmod>${url.lastmod}</lastmod>`; element += `\n <lastmod>${url.lastmod}</lastmod>`;
} }
if (url.changefreq) { if (url.changefreq) {
element += `\n <changefreq>${url.changefreq}</changefreq>`; element += `\n <changefreq>${url.changefreq}</changefreq>`;
} }
if (url.priority !== undefined) { if (url.priority !== undefined) {
element += `\n <priority>${url.priority.toFixed(1)}</priority>`; element += `\n <priority>${url.priority.toFixed(1)}</priority>`;
} }
element += '\n </url>'; element += '\n </url>';
return element; return element;
} }
/** /**
* Get static page URLs * Get static page URLs
*/ */
private getStaticPageUrls(): SitemapUrl[] { private getStaticPageUrls(): SitemapUrl[] {
return [ return [
{ {
loc: `${this.baseUrl}/`, loc: `${this.baseUrl}/`,
changefreq: 'daily', changefreq: 'daily',
priority: 1.0, priority: 1.0,
}, },
{ {
loc: `${this.baseUrl}/home`, loc: `${this.baseUrl}/home`,
changefreq: 'daily', changefreq: 'daily',
priority: 1.0, priority: 1.0,
}, },
{ {
loc: `${this.baseUrl}/businessListings`, loc: `${this.baseUrl}/businessListings`,
changefreq: 'daily', changefreq: 'daily',
priority: 0.9, priority: 0.9,
}, },
{ {
loc: `${this.baseUrl}/commercialPropertyListings`, loc: `${this.baseUrl}/commercialPropertyListings`,
changefreq: 'daily', changefreq: 'daily',
priority: 0.9, priority: 0.9,
}, },
{ {
loc: `${this.baseUrl}/brokerListings`, loc: `${this.baseUrl}/brokerListings`,
changefreq: 'daily', changefreq: 'daily',
priority: 0.8, priority: 0.8,
}, },
{ {
loc: `${this.baseUrl}/terms-of-use`, loc: `${this.baseUrl}/terms-of-use`,
changefreq: 'monthly', changefreq: 'monthly',
priority: 0.5, priority: 0.5,
}, },
{ {
loc: `${this.baseUrl}/privacy-statement`, loc: `${this.baseUrl}/privacy-statement`,
changefreq: 'monthly', changefreq: 'monthly',
priority: 0.5, priority: 0.5,
}, },
]; ];
} }
/** /**
* Count business listings (non-draft) * Count business listings (non-draft)
*/ */
private async getBusinessListingsCount(): Promise<number> { private async getBusinessListingsCount(): Promise<number> {
try { try {
const result = await this.db const result = await this.db
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(schema.businesses_json) .from(schema.businesses_json)
.where(sql`(${schema.businesses_json.data}->>'draft')::boolean IS NOT TRUE`); .where(sql`(${schema.businesses_json.data}->>'draft')::boolean IS NOT TRUE`);
return Number(result[0]?.count || 0); return Number(result[0]?.count || 0);
} catch (error) { } catch (error) {
console.error('Error counting business listings:', error); console.error('Error counting business listings:', error);
return 0; return 0;
} }
} }
/** /**
* Count commercial properties (non-draft) * Count commercial properties (non-draft)
*/ */
private async getCommercialPropertiesCount(): Promise<number> { private async getCommercialPropertiesCount(): Promise<number> {
try { try {
const result = await this.db const result = await this.db
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(schema.commercials_json) .from(schema.commercials_json)
.where(sql`(${schema.commercials_json.data}->>'draft')::boolean IS NOT TRUE`); .where(sql`(${schema.commercials_json.data}->>'draft')::boolean IS NOT TRUE`);
return Number(result[0]?.count || 0); return Number(result[0]?.count || 0);
} catch (error) { } catch (error) {
console.error('Error counting commercial properties:', error); console.error('Error counting commercial properties:', error);
return 0; return 0;
} }
} }
/** /**
* Get business listing URLs from database (paginated, slug-based) * Get business listing URLs from database (paginated, slug-based)
*/ */
private async getBusinessListingUrls(offset: number, limit: number): Promise<SitemapUrl[]> { private async getBusinessListingUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
try { try {
const listings = await this.db const listings = await this.db
.select({ .select({
id: schema.businesses_json.id, id: schema.businesses_json.id,
slug: sql<string>`${schema.businesses_json.data}->>'slug'`, slug: sql<string>`${schema.businesses_json.data}->>'slug'`,
updated: sql<Date>`(${schema.businesses_json.data}->>'updated')::timestamptz`, updated: sql<Date>`(${schema.businesses_json.data}->>'updated')::timestamptz`,
created: sql<Date>`(${schema.businesses_json.data}->>'created')::timestamptz`, created: sql<Date>`(${schema.businesses_json.data}->>'created')::timestamptz`,
}) })
.from(schema.businesses_json) .from(schema.businesses_json)
.where(sql`(${schema.businesses_json.data}->>'draft')::boolean IS NOT TRUE`) .where(sql`(${schema.businesses_json.data}->>'draft')::boolean IS NOT TRUE`)
.limit(limit) .limit(limit)
.offset(offset); .offset(offset);
return listings.map(listing => { return listings.map(listing => {
const urlSlug = listing.slug || listing.id; const urlSlug = listing.slug || listing.id;
return { return {
loc: `${this.baseUrl}/business/${urlSlug}`, loc: `${this.baseUrl}/business/${urlSlug}`,
lastmod: this.formatDate(listing.updated || listing.created), lastmod: this.formatDate(listing.updated || listing.created),
changefreq: 'weekly' as const, changefreq: 'weekly' as const,
priority: 0.8, priority: 0.8,
}; };
}); });
} catch (error) { } catch (error) {
console.error('Error fetching business listings for sitemap:', error); console.error('Error fetching business listings for sitemap:', error);
return []; return [];
} }
} }
/** /**
* Get commercial property URLs from database (paginated, slug-based) * Get commercial property URLs from database (paginated, slug-based)
*/ */
private async getCommercialPropertyUrls(offset: number, limit: number): Promise<SitemapUrl[]> { private async getCommercialPropertyUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
try { try {
const properties = await this.db const properties = await this.db
.select({ .select({
id: schema.commercials_json.id, id: schema.commercials_json.id,
slug: sql<string>`${schema.commercials_json.data}->>'slug'`, slug: sql<string>`${schema.commercials_json.data}->>'slug'`,
updated: sql<Date>`(${schema.commercials_json.data}->>'updated')::timestamptz`, updated: sql<Date>`(${schema.commercials_json.data}->>'updated')::timestamptz`,
created: sql<Date>`(${schema.commercials_json.data}->>'created')::timestamptz`, created: sql<Date>`(${schema.commercials_json.data}->>'created')::timestamptz`,
}) })
.from(schema.commercials_json) .from(schema.commercials_json)
.where(sql`(${schema.commercials_json.data}->>'draft')::boolean IS NOT TRUE`) .where(sql`(${schema.commercials_json.data}->>'draft')::boolean IS NOT TRUE`)
.limit(limit) .limit(limit)
.offset(offset); .offset(offset);
return properties.map(property => { return properties.map(property => {
const urlSlug = property.slug || property.id; const urlSlug = property.slug || property.id;
return { return {
loc: `${this.baseUrl}/commercial-property/${urlSlug}`, loc: `${this.baseUrl}/commercial-property/${urlSlug}`,
lastmod: this.formatDate(property.updated || property.created), lastmod: this.formatDate(property.updated || property.created),
changefreq: 'weekly' as const, changefreq: 'weekly' as const,
priority: 0.8, priority: 0.8,
}; };
}); });
} catch (error) { } catch (error) {
console.error('Error fetching commercial properties for sitemap:', error); console.error('Error fetching commercial properties for sitemap:', error);
return []; return [];
} }
} }
/** /**
* Format date to ISO 8601 format (YYYY-MM-DD) * Format date to ISO 8601 format (YYYY-MM-DD)
*/ */
private formatDate(date: Date | string): string { private formatDate(date: Date | string): string {
if (!date) return new Date().toISOString().split('T')[0]; if (!date) return new Date().toISOString().split('T')[0];
const d = typeof date === 'string' ? new Date(date) : date; const d = typeof date === 'string' ? new Date(date) : date;
return d.toISOString().split('T')[0]; return d.toISOString().split('T')[0];
} }
/** /**
* Generate broker profiles sitemap (paginated) * Generate broker profiles sitemap (paginated)
*/ */
async generateBrokerSitemap(page: number): Promise<string> { async generateBrokerSitemap(page: number): Promise<string> {
const offset = (page - 1) * this.URLS_PER_SITEMAP; const offset = (page - 1) * this.URLS_PER_SITEMAP;
const urls = await this.getBrokerProfileUrls(offset, this.URLS_PER_SITEMAP); const urls = await this.getBrokerProfileUrls(offset, this.URLS_PER_SITEMAP);
return this.buildXmlSitemap(urls); return this.buildXmlSitemap(urls);
} }
/** /**
* Count broker profiles (professionals with showInDirectory=true) * Count broker profiles (professionals with showInDirectory=true)
*/ */
private async getBrokerProfilesCount(): Promise<number> { private async getBrokerProfilesCount(): Promise<number> {
try { try {
const result = await this.db const result = await this.db
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(schema.users_json) .from(schema.users_json)
.where(sql` .where(sql`
(${schema.users_json.data}->>'customerType') = 'professional' (${schema.users_json.data}->>'customerType') = 'professional'
AND (${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE AND (${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE
`); `);
return Number(result[0]?.count || 0); return Number(result[0]?.count || 0);
} catch (error) { } catch (error) {
console.error('Error counting broker profiles:', error); console.error('Error counting broker profiles:', error);
return 0; return 0;
} }
} }
/** /**
* Get broker profile URLs from database (paginated) * Get broker profile URLs from database (paginated)
*/ */
private async getBrokerProfileUrls(offset: number, limit: number): Promise<SitemapUrl[]> { private async getBrokerProfileUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
try { try {
const brokers = await this.db const brokers = await this.db
.select({ .select({
email: schema.users_json.email, email: schema.users_json.email,
updated: sql<Date>`(${schema.users_json.data}->>'updated')::timestamptz`, updated: sql<Date>`(${schema.users_json.data}->>'updated')::timestamptz`,
created: sql<Date>`(${schema.users_json.data}->>'created')::timestamptz`, created: sql<Date>`(${schema.users_json.data}->>'created')::timestamptz`,
}) })
.from(schema.users_json) .from(schema.users_json)
.where(sql` .where(sql`
(${schema.users_json.data}->>'customerType') = 'professional' (${schema.users_json.data}->>'customerType') = 'professional'
AND (${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE AND (${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE
`) `)
.limit(limit) .limit(limit)
.offset(offset); .offset(offset);
return brokers.map(broker => ({ return brokers.map(broker => ({
loc: `${this.baseUrl}/details-user/${encodeURIComponent(broker.email)}`, loc: `${this.baseUrl}/details-user/${encodeURIComponent(broker.email)}`,
lastmod: this.formatDate(broker.updated || broker.created), lastmod: this.formatDate(broker.updated || broker.created),
changefreq: 'weekly' as const, changefreq: 'weekly' as const,
priority: 0.7, priority: 0.7,
})); }));
} catch (error) { } catch (error) {
console.error('Error fetching broker profiles for sitemap:', error); console.error('Error fetching broker profiles for sitemap:', error);
return []; return [];
} }
} }
} }

View File

@@ -19,7 +19,7 @@ export class UserService {
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>, @Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
private fileService: FileService, private fileService: FileService,
private geoService: GeoService, private geoService: GeoService,
) {} ) { }
private getWhereConditions(criteria: UserListingCriteria): SQL[] { private getWhereConditions(criteria: UserListingCriteria): SQL[] {
const whereConditions: SQL[] = []; const whereConditions: SQL[] = [];
@@ -158,4 +158,38 @@ export class UserService {
throw error; 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);
}
} }

View File

@@ -1,13 +1,13 @@
FROM node:22-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
# GANZEN dist-Ordner kopieren, nicht nur bizmatch # GANZEN dist-Ordner kopieren, nicht nur bizmatch
COPY dist ./dist COPY dist ./dist
COPY package*.json ./ COPY package*.json ./
RUN npm ci --omit=dev RUN npm ci --omit=dev
EXPOSE 4200 EXPOSE 4200
CMD ["node", "dist/bizmatch/server/server.mjs"] CMD ["node", "dist/bizmatch/server/server.mjs"]

View File

@@ -1,10 +1,10 @@
services: services:
bizmatch-ssr: bizmatch-ssr:
build: . build: .
image: bizmatch-ssr image: bizmatch-ssr
container_name: bizmatch-ssr container_name: bizmatch-ssr
restart: unless-stopped restart: unless-stopped
ports: ports:
- '4200:4200' - '4200:4200'
environment: environment:
NODE_ENV: DEVELOPMENT NODE_ENV: DEVELOPMENT

View File

@@ -1,193 +1,194 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { LogoutComponent } from './components/logout/logout.component'; // Core components (eagerly loaded - needed for initial navigation)
import { NotFoundComponent } from './components/not-found/not-found.component'; import { LogoutComponent } from './components/logout/logout.component';
import { TestSsrComponent } from './components/test-ssr/test-ssr.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 { EmailAuthorizedComponent } from './components/email-authorized/email-authorized.component';
import { EmailVerificationComponent } from './components/email-verification/email-verification.component'; import { EmailVerificationComponent } from './components/email-verification/email-verification.component';
import { LoginRegisterComponent } from './components/login-register/login-register.component'; import { LoginRegisterComponent } from './components/login-register/login-register.component';
import { AuthGuard } from './guards/auth.guard';
import { ListingCategoryGuard } from './guards/listing-category.guard'; // Guards
import { UserListComponent } from './pages/admin/user-list/user-list.component'; import { AuthGuard } from './guards/auth.guard';
import { DetailsBusinessListingComponent } from './pages/details/details-business-listing/details-business-listing.component'; import { ListingCategoryGuard } from './guards/listing-category.guard';
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 (eagerly loaded - high traffic)
import { HomeComponent } from './pages/home/home.component'; import { DetailsBusinessListingComponent } from './pages/details/details-business-listing/details-business-listing.component';
import { BrokerListingsComponent } from './pages/listings/broker-listings/broker-listings.component'; import { DetailsCommercialPropertyListingComponent } from './pages/details/details-commercial-property-listing/details-commercial-property-listing.component';
import { BusinessListingsComponent } from './pages/listings/business-listings/business-listings.component'; import { DetailsUserComponent } from './pages/details/details-user/details-user.component';
import { CommercialPropertyListingsComponent } from './pages/listings/commercial-property-listings/commercial-property-listings.component'; import { HomeComponent } from './pages/home/home.component';
import { AccountComponent } from './pages/subscription/account/account.component'; import { BrokerListingsComponent } from './pages/listings/broker-listings/broker-listings.component';
import { EditBusinessListingComponent } from './pages/subscription/edit-business-listing/edit-business-listing.component'; import { BusinessListingsComponent } from './pages/listings/business-listings/business-listings.component';
import { EditCommercialPropertyListingComponent } from './pages/subscription/edit-commercial-property-listing/edit-commercial-property-listing.component'; import { CommercialPropertyListingsComponent } from './pages/listings/commercial-property-listings/commercial-property-listings.component';
import { EmailUsComponent } from './pages/subscription/email-us/email-us.component'; import { SuccessComponent } from './pages/success/success.component';
import { FavoritesComponent } from './pages/subscription/favorites/favorites.component'; import { TermsOfUseComponent } from './pages/legal/terms-of-use.component';
import { MyListingComponent } from './pages/subscription/my-listing/my-listing.component'; import { PrivacyStatementComponent } from './pages/legal/privacy-statement.component';
import { SuccessComponent } from './pages/success/success.component';
import { TermsOfUseComponent } from './pages/legal/terms-of-use.component'; // Note: Account, Edit, Admin, Favorites, MyListing, and EmailUs components are now lazy-loaded below
import { PrivacyStatementComponent } from './pages/legal/privacy-statement.component';
export const routes: Routes = [
export const routes: Routes = [ {
{ path: 'test-ssr',
path: 'test-ssr', component: TestSsrComponent,
component: TestSsrComponent, },
}, {
{ path: 'businessListings',
path: 'businessListings', component: BusinessListingsComponent,
component: BusinessListingsComponent, runGuardsAndResolvers: 'always',
runGuardsAndResolvers: 'always', },
}, {
{ path: 'commercialPropertyListings',
path: 'commercialPropertyListings', component: CommercialPropertyListingsComponent,
component: CommercialPropertyListingsComponent, runGuardsAndResolvers: 'always',
runGuardsAndResolvers: 'always', },
}, {
{ path: 'brokerListings',
path: 'brokerListings', component: BrokerListingsComponent,
component: BrokerListingsComponent, runGuardsAndResolvers: 'always',
runGuardsAndResolvers: 'always', },
}, {
{ path: 'home',
path: 'home', component: HomeComponent,
component: HomeComponent, },
}, // #########
// ######### // Listings Details - New SEO-friendly slug-based URLs
// Listings Details - New SEO-friendly slug-based URLs {
{ path: 'business/:slug',
path: 'business/:slug', component: DetailsBusinessListingComponent,
component: DetailsBusinessListingComponent, },
}, {
{ path: 'commercial-property/:slug',
path: 'commercial-property/:slug', component: DetailsCommercialPropertyListingComponent,
component: DetailsCommercialPropertyListingComponent, },
}, // Backward compatibility redirects for old UUID-based URLs
// Backward compatibility redirects for old UUID-based URLs {
{ path: 'details-business-listing/:id',
path: 'details-business-listing/:id', redirectTo: 'business/:id',
redirectTo: 'business/:id', pathMatch: 'full',
pathMatch: 'full', },
}, {
{ path: 'details-commercial-property-listing/:id',
path: 'details-commercial-property-listing/:id', redirectTo: 'commercial-property/:id',
redirectTo: 'commercial-property/:id', pathMatch: 'full',
pathMatch: 'full', },
}, {
{ path: 'listing/:id',
path: 'listing/:id', canActivate: [ListingCategoryGuard],
canActivate: [ListingCategoryGuard], component: NotFoundComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet
component: NotFoundComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet },
}, // {
// { // path: 'login/:page',
// path: 'login/:page', // component: LoginComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet
// component: LoginComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet // },
// }, {
{ path: 'login/:page',
path: 'login/:page', component: LoginRegisterComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet
component: LoginRegisterComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet },
}, {
{ path: 'login',
path: 'login', component: LoginRegisterComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet
component: LoginRegisterComponent, // Dummy-Komponente, wird nie angezeigt, da der Guard weiterleitet },
}, {
{ path: 'notfound',
path: 'notfound', component: NotFoundComponent,
component: NotFoundComponent, },
}, // #########
// ######### // User Details
// User Details {
{ path: 'details-user/:id',
path: 'details-user/:id', component: DetailsUserComponent,
component: DetailsUserComponent, },
}, // #########
// ######### // User edit (lazy-loaded)
// User edit {
{ path: 'account',
path: 'account', loadComponent: () => import('./pages/subscription/account/account.component').then(m => m.AccountComponent),
component: AccountComponent, canActivate: [AuthGuard],
canActivate: [AuthGuard], },
}, {
{ path: 'account/:id',
path: 'account/:id', loadComponent: () => import('./pages/subscription/account/account.component').then(m => m.AccountComponent),
component: AccountComponent, canActivate: [AuthGuard],
canActivate: [AuthGuard], },
}, // #########
// ######### // Create, Update Listings (lazy-loaded)
// Create, Update Listings {
{ path: 'editBusinessListing/:id',
path: 'editBusinessListing/:id', loadComponent: () => import('./pages/subscription/edit-business-listing/edit-business-listing.component').then(m => m.EditBusinessListingComponent),
component: EditBusinessListingComponent, canActivate: [AuthGuard],
canActivate: [AuthGuard], },
}, {
{ path: 'createBusinessListing',
path: 'createBusinessListing', loadComponent: () => import('./pages/subscription/edit-business-listing/edit-business-listing.component').then(m => m.EditBusinessListingComponent),
component: EditBusinessListingComponent, canActivate: [AuthGuard],
canActivate: [AuthGuard], },
}, {
{ path: 'editCommercialPropertyListing/:id',
path: 'editCommercialPropertyListing/:id', loadComponent: () => import('./pages/subscription/edit-commercial-property-listing/edit-commercial-property-listing.component').then(m => m.EditCommercialPropertyListingComponent),
component: EditCommercialPropertyListingComponent, canActivate: [AuthGuard],
canActivate: [AuthGuard], },
}, {
{ path: 'createCommercialPropertyListing',
path: 'createCommercialPropertyListing', loadComponent: () => import('./pages/subscription/edit-commercial-property-listing/edit-commercial-property-listing.component').then(m => m.EditCommercialPropertyListingComponent),
component: EditCommercialPropertyListingComponent, canActivate: [AuthGuard],
canActivate: [AuthGuard], },
}, // #########
// ######### // My Listings (lazy-loaded)
// My Listings {
{ path: 'myListings',
path: 'myListings', loadComponent: () => import('./pages/subscription/my-listing/my-listing.component').then(m => m.MyListingComponent),
component: MyListingComponent, canActivate: [AuthGuard],
canActivate: [AuthGuard], },
}, // #########
// ######### // My Favorites (lazy-loaded)
// My Favorites {
{ path: 'myFavorites',
path: 'myFavorites', loadComponent: () => import('./pages/subscription/favorites/favorites.component').then(m => m.FavoritesComponent),
component: FavoritesComponent, canActivate: [AuthGuard],
canActivate: [AuthGuard], },
}, // #########
// ######### // Email Us (lazy-loaded)
// EMAil Us {
{ path: 'emailUs',
path: 'emailUs', loadComponent: () => import('./pages/subscription/email-us/email-us.component').then(m => m.EmailUsComponent),
component: EmailUsComponent, // canActivate: [AuthGuard],
// canActivate: [AuthGuard], },
}, // #########
// ######### // Logout
// Logout {
{ path: 'logout',
path: 'logout', component: LogoutComponent,
component: LogoutComponent, canActivate: [AuthGuard],
canActivate: [AuthGuard], },
}, // #########
// ######### // Email Verification
// Email Verification {
{ path: 'emailVerification',
path: 'emailVerification', component: EmailVerificationComponent,
component: EmailVerificationComponent, },
}, {
{ path: 'email-authorized',
path: 'email-authorized', component: EmailAuthorizedComponent,
component: EmailAuthorizedComponent, },
}, {
{ path: 'success',
path: 'success', component: SuccessComponent,
component: SuccessComponent, },
}, // #########
{ // Admin Pages (lazy-loaded)
path: 'admin/users', {
component: UserListComponent, path: 'admin/users',
canActivate: [AuthGuard], loadComponent: () => import('./pages/admin/user-list/user-list.component').then(m => m.UserListComponent),
}, canActivate: [AuthGuard],
// ######### },
// Legal Pages // #########
{ // Legal Pages
path: 'terms-of-use', {
component: TermsOfUseComponent, path: 'terms-of-use',
}, component: TermsOfUseComponent,
{ },
path: 'privacy-statement', {
component: PrivacyStatementComponent, path: 'privacy-statement',
}, component: PrivacyStatementComponent,
{ path: '**', redirectTo: 'home' }, },
]; { path: '**', redirectTo: 'home' },
];

View File

@@ -39,6 +39,9 @@
<span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button> Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span> </span>
<span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
</div> </div>
@if(criteria.criteriaType==='commercialPropertyListings') { @if(criteria.criteriaType==='commercialPropertyListings') {
<div class="grid grid-cols-1 gap-6"> <div class="grid grid-cols-1 gap-6">
@@ -113,6 +116,17 @@
placeholder="Select categories" placeholder="Select categories"
></ng-select> ></ng-select>
</div> </div>
<div>
<label for="brokername" class="block mb-2 text-sm font-medium text-neutral-900">Broker Name / Company Name</label>
<input
type="text"
id="brokername"
[ngModel]="criteria.brokerName"
(ngModelChange)="updateCriteria({ brokerName: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. Brokers Invest"
/>
</div>
</div> </div>
</div> </div>
} }
@@ -146,6 +160,9 @@
<span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button> Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span> </span>
<span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
</div> </div>
@if(criteria.criteriaType==='commercialPropertyListings') { @if(criteria.criteriaType==='commercialPropertyListings') {
<div class="space-y-4"> <div class="space-y-4">
@@ -217,6 +234,17 @@
placeholder="e.g. Office Space" placeholder="e.g. Office Space"
/> />
</div> </div>
<div>
<label for="brokername-embedded" class="block mb-2 text-sm font-medium text-neutral-900">Broker Name / Company Name</label>
<input
type="text"
id="brokername-embedded"
[ngModel]="criteria.brokerName"
(ngModelChange)="updateCriteria({ brokerName: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. Brokers Invest"
/>
</div>
</div> </div>
} }
</div> </div>

View File

@@ -48,7 +48,7 @@ export class SearchModalCommercialComponent implements OnInit, OnDestroy {
private filterStateService: FilterStateService, private filterStateService: FilterStateService,
private listingService: ListingsService, private listingService: ListingsService,
private searchService: SearchService, private searchService: SearchService,
) {} ) { }
ngOnInit(): void { ngOnInit(): void {
// Load counties // Load counties
@@ -143,6 +143,9 @@ export class SearchModalCommercialComponent implements OnInit, OnDestroy {
case 'title': case 'title':
updates.title = null; updates.title = null;
break; break;
case 'brokerName':
updates.brokerName = null;
break;
} }
this.updateCriteria(updates); this.updateCriteria(updates);
@@ -280,6 +283,7 @@ export class SearchModalCommercialComponent implements OnInit, OnDestroy {
minPrice: null, minPrice: null,
maxPrice: null, maxPrice: null,
title: null, title: null,
brokerName: null,
prompt: null, prompt: null,
page: 1, page: 1,
start: 0, start: 0,
@@ -290,7 +294,15 @@ export class SearchModalCommercialComponent implements OnInit, OnDestroy {
hasActiveFilters(): boolean { hasActiveFilters(): boolean {
if (!this.criteria) return false; if (!this.criteria) return false;
return !!(this.criteria.state || this.criteria.city || this.criteria.minPrice || this.criteria.maxPrice || this.criteria.types?.length || this.criteria.title); return !!(
this.criteria.state ||
this.criteria.city ||
this.criteria.minPrice ||
this.criteria.maxPrice ||
this.criteria.types?.length ||
this.criteria.title ||
this.criteria.brokerName
);
} }
trackByFn(item: GeoResult): any { trackByFn(item: GeoResult): any {

View File

@@ -13,26 +13,29 @@
<div class="p-6 flex flex-col lg:flex-row"> <div class="p-6 flex flex-col lg:flex-row">
<!-- Left column --> <!-- Left column -->
<div class="w-full lg:w-1/2 pr-0 lg:pr-6"> <div class="w-full lg:w-1/2 pr-0 lg:pr-6">
<h1 class="text-2xl font-bold mb-4">{{ listing.title }}</h1> <h1 class="text-2xl font-bold mb-4 break-words">{{ listing.title }}</h1>
<p class="mb-4" [innerHTML]="description"></p> <p class="mb-4 break-words" [innerHTML]="description"></p>
<div class="space-y-2"> <div class="space-y-2">
<div *ngFor="let detail of listingDetails; let i = index" class="flex flex-col sm:flex-row" <div *ngFor="let detail of listingDetails; let i = index" class="flex flex-col sm:flex-row"
[ngClass]="{ 'bg-neutral-100': i % 2 === 0 }"> [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-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> *ngIf="detail.isHtml && !detail.isListingBy"></div>
<div class="w-full sm:w-2/3 p-2 flex space-x-2" *ngIf="detail.isListingBy && listingUser"> <div class="w-full sm:w-2/3 p-2 flex space-x-2" *ngIf="detail.isListingBy && listingUser">
<a routerLink="/details-user/{{ listingUser.id }}" <a routerLink="/details-user/{{ listingUser.id }}"
class="text-primary-600 dark:text-primary-500 hover:underline">{{ listingUser.firstname }} {{ class="text-primary-600 dark:text-primary-500 hover:underline">{{ listingUser.firstname }} {{
listingUser.lastname }}</a> listingUser.lastname }}</a>
<img *ngIf="listing.imageName" <div class="relative w-[100px] h-[30px] mr-5 lg:mb-0" *ngIf="listing.imageName">
ngSrc="{{ env.imageBaseUrl }}/pictures/logo/{{ listing.imageName }}.avif?_ts={{ ts }}" <img ngSrc="{{ env.imageBaseUrl }}/pictures/logo/{{ listing.imageName }}.avif?_ts={{ ts }}" fill
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 }}" /> class="object-contain"
alt="Business logo for {{ listingUser.firstname }} {{ listingUser.lastname }}" />
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -48,7 +51,7 @@
} @if(user){ } @if(user){
<div class="inline"> <div class="inline">
<button class="share share-save text-white font-bold text-xs py-1.5 px-2 inline-flex items-center" <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> <i class="fa-regular fa-heart"></i>
@if(listing.favoritesForUser.includes(user.email)){ @if(listing.favoritesForUser.includes(user.email)){
<span class="ml-2">Saved ...</span> <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> <h2 class="text-2xl font-bold mb-6 text-gray-900">Frequently Asked Questions</h2>
<div class="space-y-4"> <div class="space-y-4">
@for (faq of businessFAQs; track $index) { @for (faq of businessFAQs; track $index) {
<details class="group border border-gray-200 rounded-lg overflow-hidden hover:border-primary-300 transition-colors"> <details
<summary class="flex items-center justify-between cursor-pointer p-4 bg-gray-50 hover:bg-gray-100 transition-colors"> 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> <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> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg> </svg>
</summary> </summary>
@@ -157,7 +163,8 @@
</div> </div>
<div class="mt-6 p-4 bg-primary-50 border-l-4 border-primary-500 rounded"> <div class="mt-6 p-4 bg-primary-50 border-l-4 border-primary-500 rounded">
<p class="text-sm text-gray-700"> <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> </p>
</div> </div>
</div> </div>

View File

@@ -379,13 +379,27 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent {
} }
return result; return result;
} }
async save() { async toggleFavorite() {
await this.listingsService.addToFavorites(this.listing.id, 'business'); try {
this.listing.favoritesForUser.push(this.user.email); const isFavorited = this.listing.favoritesForUser.includes(this.user.email);
this.auditService.createEvent(this.listing.id, 'favorite', this.user?.email);
} if (isFavorited) {
isAlreadyFavorite() { // Remove from favorites
return this.listing.favoritesForUser.includes(this.user.email); 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() { async showShareByEMail() {
const result = await this.emailService.showShareByEMail({ const result = await this.emailService.showShareByEMail({

View File

@@ -5,14 +5,14 @@
<div class="bg-white drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg rounded-lg overflow-hidden"> <div class="bg-white drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg rounded-lg overflow-hidden">
@if(listing){ @if(listing){
<div class="p-6 relative"> <div class="p-6 relative">
<h1 class="text-3xl font-bold mb-4">{{ listing?.title }}</h1> <h1 class="text-3xl font-bold mb-4 break-words">{{ listing?.title }}</h1>
<button (click)="historyService.goBack()" <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"> 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">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
<div class="flex flex-col lg:flex-row"> <div class="flex flex-col lg:flex-row">
<div class="w-full lg:w-1/2 pr-0 lg:pr-4"> <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 class="space-y-2">
<div *ngFor="let detail of propertyDetails; let i = index" class="flex flex-col sm:flex-row" <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> <div class="w-full sm:w-1/3 font-semibold p-2">{{ detail.label }}</div>
<!-- Standard Text --> <!-- 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) --> <!-- 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> *ngIf="detail.isHtml && !detail.isListingBy"></div>
<!-- Speziell für Listing By mit RouterLink --> <!-- Speziell für Listing By mit RouterLink -->
@@ -31,9 +32,11 @@
<a [routerLink]="['/details-user', detail.user.id]" <a [routerLink]="['/details-user', detail.user.id]"
class="text-primary-600 dark:text-primary-500 hover:underline"> {{ detail.user.firstname }} {{ class="text-primary-600 dark:text-primary-500 hover:underline"> {{ detail.user.firstname }} {{
detail.user.lastname }} </a> detail.user.lastname }} </a>
<img *ngIf="detail.user.hasCompanyLogo" <div class="relative w-[100px] h-[30px] mr-5 lg:mb-0" *ngIf="detail.user.hasCompanyLogo">
[ngSrc]="detail.imageBaseUrl + '/pictures/logo/' + detail.imagePath + '.avif?_ts=' + detail.ts" <img [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 }}" /> fill class="object-contain"
alt="Company logo for {{ detail.user.firstname }} {{ detail.user.lastname }}" />
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -49,7 +52,7 @@
} @if(user){ } @if(user){
<div class="inline"> <div class="inline">
<button class="share share-save text-white font-bold text-xs py-1.5 px-2 inline-flex items-center" <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> <i class="fa-regular fa-heart"></i>
@if(listing.favoritesForUser.includes(user.email)){ @if(listing.favoritesForUser.includes(user.email)){
<span class="ml-2">Saved ...</span> <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> <h2 class="text-2xl font-bold mb-6 text-gray-900">Frequently Asked Questions</h2>
<div class="space-y-4"> <div class="space-y-4">
@for (faq of propertyFAQs; track $index) { @for (faq of propertyFAQs; track $index) {
<details class="group border border-gray-200 rounded-lg overflow-hidden hover:border-primary-300 transition-colors"> <details
<summary class="flex items-center justify-between cursor-pointer p-4 bg-gray-50 hover:bg-gray-100 transition-colors"> 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> <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> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg> </svg>
</summary> </summary>
@@ -171,7 +177,8 @@
</div> </div>
<div class="mt-6 p-4 bg-primary-50 border-l-4 border-primary-500 rounded"> <div class="mt-6 p-4 bg-primary-50 border-l-4 border-primary-500 rounded">
<p class="text-sm text-gray-700"> <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> </p>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { Component, NgZone } from '@angular/core'; import { ChangeDetectorRef, Component, NgZone } from '@angular/core';
import { NgOptimizedImage } from '@angular/common'; import { NgOptimizedImage } from '@angular/common';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
@@ -92,6 +92,7 @@ export class DetailsCommercialPropertyListingComponent extends BaseDetailsCompon
private emailService: EMailService, private emailService: EMailService,
public authService: AuthService, public authService: AuthService,
private seoService: SeoService, private seoService: SeoService,
private cdref: ChangeDetectorRef,
) { ) {
super(); super();
this.mailinfo = { sender: { name: '', email: '', phoneNumber: '', state: '', comments: '' }, email: '', url: environment.mailinfoUrl }; this.mailinfo = { sender: { name: '', email: '', phoneNumber: '', state: '', comments: '' }, email: '', url: environment.mailinfoUrl };
@@ -314,13 +315,27 @@ export class DetailsCommercialPropertyListingComponent extends BaseDetailsCompon
getImageIndices(): number[] { getImageIndices(): number[] {
return this.listing && this.listing.imageOrder ? this.listing.imageOrder.slice(1).map((e, i) => i + 1) : []; return this.listing && this.listing.imageOrder ? this.listing.imageOrder.slice(1).map((e, i) => i + 1) : [];
} }
async save() { async toggleFavorite() {
await this.listingsService.addToFavorites(this.listing.id, 'commercialProperty'); try {
this.listing.favoritesForUser.push(this.user.email); const isFavorited = this.listing.favoritesForUser.includes(this.user.email);
this.auditService.createEvent(this.listing.id, 'favorite', this.user?.email);
} if (isFavorited) {
isAlreadyFavorite() { // Remove from favorites
return this.listing.favoritesForUser.includes(this.user.email); 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() { async showShareByEMail() {
const result = await this.emailService.showShareByEMail({ const result = await this.emailService.showShareByEMail({

View File

@@ -11,9 +11,12 @@
<div class="flex items-center space-x-4"> <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" /> --> <!-- <img src="https://placehold.co/80x80" alt="Profile picture of Avery Brown smiling" class="w-20 h-20 rounded-full" /> -->
@if(user.hasProfile){ @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 { } @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> <div>
<h1 class="text-2xl font-bold flex items-center"> <h1 class="text-2xl font-bold flex items-center">
@@ -32,25 +35,86 @@
</p> </p>
</div> </div>
@if(user.hasCompanyLogo){ @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" /> --> <!-- <img src="https://placehold.co/45x60" class="w-11 h-14" /> -->
</div> </div>
<button <button (click)="historyService.goBack()"
(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">
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"
>
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
</div> </div>
<!-- Description --> <!-- 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 --> <!-- Company Profile -->
<div class="p-4"> <div class="p-4">
<h2 class="text-xl font-semibold mb-4">Company Profile</h2> <h2 class="text-xl font-semibold mb-4">Company Profile</h2>
<p class="text-neutral-700 mb-4" [innerHTML]="companyOverview"></p> <p class="text-neutral-700 mb-4 break-words" [innerHTML]="companyOverview"></p>
<!-- Profile Details --> <!-- Profile Details -->
<div class="space-y-2"> <div class="space-y-2">
@@ -82,7 +146,7 @@
<!-- Services --> <!-- Services -->
<div class="mt-6"> <div class="mt-6">
<h3 class="font-semibold mb-2">Services we offer</h3> <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> </div>
<!-- Areas Served --> <!-- Areas Served -->
@@ -90,7 +154,8 @@
<h3 class="font-semibold mb-2">Areas (Counties) we serve</h3> <h3 class="font-semibold mb-2">Areas (Counties) we serve</h3>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
@for (area of user.areasServed; track area) { @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>
</div> </div>
@@ -99,7 +164,8 @@
<div class="mt-6"> <div class="mt-6">
<h3 class="font-semibold mb-2">Licensed In</h3> <h3 class="font-semibold mb-2">Licensed In</h3>
@for (license of user.licensedIn; track license) { @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> </div>
} }
@@ -112,7 +178,8 @@
<h2 class="text-xl font-semibold mb-4">My Business Listings For Sale</h2> <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"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@for (listing of businessListings; track listing) { @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"> <div class="flex items-center mb-2">
<i [class]="selectOptions.getIconAndTextColorType(listing.type)" class="mr-2"></i> <i [class]="selectOptions.getIconAndTextColorType(listing.type)" class="mr-2"></i>
<span class="font-medium">{{ selectOptions.getBusiness(listing.type) }}</span> <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> <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"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@for (listing of commercialPropListings; track listing) { @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"> <div class="flex items-center space-x-4">
@if (listing.imageOrder?.length>0){ @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 { } @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> <div>
<p class="font-medium">{{ selectOptions.getCommercialProperty(listing.type) }}</p> <p class="font-medium">{{ selectOptions.getCommercialProperty(listing.type) }}</p>
@@ -142,10 +214,8 @@
</div> </div>
} }
</div> </div>
} @if( user?.email===keycloakUser?.email || (authService.isAdmin() | async)){
<button class="mt-4 bg-primary-500 text-white px-4 py-2 rounded hover:bg-primary-600" [routerLink]="['/account', user.id]">Edit</button>
} }
</div> </div>
</div> </div>
} }
</div> </div>

View File

@@ -1,13 +1,16 @@
import { Component } from '@angular/core'; import { ChangeDetectorRef, Component } from '@angular/core';
import { NgOptimizedImage } from '@angular/common'; import { NgOptimizedImage } from '@angular/common';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { BusinessListing, CommercialPropertyListing, User } from '../../../../../../bizmatch-server/src/models/db.model'; import { BusinessListing, CommercialPropertyListing, User, ShareByEMail, EventTypeEnum } from '../../../../../../bizmatch-server/src/models/db.model';
import { KeycloakUser, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model'; import { KeycloakUser, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component'; import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
import { AuthService } from '../../../services/auth.service'; import { AuthService } from '../../../services/auth.service';
import { AuditService } from '../../../services/audit.service';
import { EMailService } from '../../../components/email/email.service';
import { MessageService } from '../../../components/message/message.service';
import { HistoryService } from '../../../services/history.service'; import { HistoryService } from '../../../services/history.service';
import { ImageService } from '../../../services/image.service'; import { ImageService } from '../../../services/image.service';
import { ListingsService } from '../../../services/listings.service'; import { ListingsService } from '../../../services/listings.service';
@@ -15,11 +18,12 @@ import { SelectOptionsService } from '../../../services/select-options.service';
import { UserService } from '../../../services/user.service'; import { UserService } from '../../../services/user.service';
import { SharedModule } from '../../../shared/shared/shared.module'; import { SharedModule } from '../../../shared/shared/shared.module';
import { formatPhoneNumber, map2User } from '../../../utils/utils'; import { formatPhoneNumber, map2User } from '../../../utils/utils';
import { ShareButton } from 'ngx-sharebuttons/button';
@Component({ @Component({
selector: 'app-details-user', selector: 'app-details-user',
standalone: true, standalone: true,
imports: [SharedModule, BreadcrumbsComponent, NgOptimizedImage], imports: [SharedModule, BreadcrumbsComponent, NgOptimizedImage, ShareButton],
templateUrl: './details-user.component.html', templateUrl: './details-user.component.html',
styleUrl: '../details.scss', styleUrl: '../details.scss',
}) })
@@ -47,13 +51,16 @@ export class DetailsUserComponent {
private router: Router, private router: Router,
private userService: UserService, private userService: UserService,
private listingsService: ListingsService, private listingsService: ListingsService,
public selectOptions: SelectOptionsService, public selectOptions: SelectOptionsService,
private sanitizer: DomSanitizer, private sanitizer: DomSanitizer,
private imageService: ImageService, private imageService: ImageService,
public historyService: HistoryService, public historyService: HistoryService,
public authService: AuthService, public authService: AuthService,
) {} private auditService: AuditService,
private emailService: EMailService,
private messageService: MessageService,
private cdref: ChangeDetectorRef,
) { }
async ngOnInit() { async ngOnInit() {
this.user = await this.userService.getById(this.id); this.user = await this.userService.getById(this.id);
@@ -66,4 +73,97 @@ export class DetailsUserComponent {
this.companyOverview = this.sanitizer.bypassSecurityTrustHtml(this.user.companyOverview ? this.user.companyOverview : ''); this.companyOverview = this.sanitizer.bypassSecurityTrustHtml(this.user.companyOverview ? this.user.companyOverview : '');
this.offeredServices = this.sanitizer.bypassSecurityTrustHtml(this.user.offeredServices ? this.user.offeredServices : ''); this.offeredServices = this.sanitizer.bypassSecurityTrustHtml(this.user.offeredServices ? this.user.offeredServices : '');
} }
/**
* 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');
}
} }

View File

@@ -1,195 +1,253 @@
<header class="w-full flex justify-between items-center p-4 bg-white top-0 z-10 h-16 md:h-20"> <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="Logo" class="h-8 md:h-10 w-auto" />
<div class="hidden md:flex items-center space-x-4"> <div class="hidden md:flex items-center space-x-4">
@if(user){ @if(user){
<a routerLink="/account" class="text-primary-600 border border-primary-600 px-3 py-2 rounded">Account</a> <a routerLink="/account" class="text-primary-600 border border-primary-600 px-3 py-2 rounded">Account</a>
} @else { } @else {
<!-- <a routerLink="/pricing" class="text-neutral-800">Pricing</a> --> <!-- <a routerLink="/pricing" class="text-neutral-800">Pricing</a> -->
<a routerLink="/login" [queryParams]="{ mode: 'login' }" <a routerLink="/login" [queryParams]="{ mode: 'login' }" class="text-primary-600 border border-primary-600 px-3 py-2 rounded">Log In</a>
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: 'register' }" class="text-white bg-primary-600 px-4 py-2 rounded">Sign <!-- <a routerLink="/login" class="text-primary-500 hover:underline">Login/Register</a> -->
Up</a> }
<!-- <a routerLink="/login" class="text-primary-500 hover:underline">Login/Register</a> --> </div>
} <button
</div> (click)="toggleMenu()"
<button (click)="toggleMenu()" class="md:hidden text-neutral-600"> class="md:hidden text-neutral-600"
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> aria-label="Open navigation menu"
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16m-7 6h7"></path> [attr.aria-expanded]="isMenuOpen"
</svg> >
</button> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
</header> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16m-7 6h7"></path>
</svg>
<div *ngIf="isMenuOpen" class="fixed inset-0 bg-neutral-800 bg-opacity-75 z-20"> </button>
<div class="flex flex-col items-center justify-center h-full"> </header>
<!-- <a href="#" class="text-white text-xl py-2">Pricing</a> -->
@if(user){ <div *ngIf="isMenuOpen" class="fixed inset-0 bg-neutral-800 bg-opacity-75 z-20">
<a routerLink="/account" class="text-white text-xl py-2">Account</a> <div class="flex flex-col items-center justify-center h-full">
} @else { <!-- <a href="#" class="text-white text-xl py-2">Pricing</a> -->
<a routerLink="/login" [queryParams]="{ mode: 'login' }" class="text-white text-xl py-2">Log In</a> @if(user){
<a routerLink="/login" [queryParams]="{ mode: 'register' }" class="text-white text-xl py-2">Sign Up</a> <a routerLink="/account" class="text-white text-xl py-2">Account</a>
} } @else {
<button (click)="toggleMenu()" class="text-white mt-4"> <a routerLink="/login" [queryParams]="{ mode: 'login' }" class="text-white text-xl py-2">Log In</a>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <a routerLink="/login" [queryParams]="{ mode: 'register' }" class="text-white text-xl py-2">Sign Up</a>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path> }
</svg> <button
</button> (click)="toggleMenu()"
</div> class="text-white mt-4"
</div> aria-label="Close navigation menu"
>
<!-- ==== ANPASSUNGEN START ==== --> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<!-- 1. px-4 für <main> (vorher px-2 sm:px-4) --> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path>
<main class="flex flex-col items-center justify-center px-4 w-full flex-grow"> </svg>
<div </button>
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>
<div class="flex justify-center w-full"> </div>
<!-- 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"> <!-- ==== ANPASSUNGEN START ==== -->
<!-- Hero-Container --> <!-- 1. px-4 für <main> (vorher px-2 sm:px-4) -->
<section class="relative"> <main class="flex flex-col items-center justify-center px-4 w-full flex-grow">
<!-- Dein Hintergrundbild liegt hier per CSS oder absolutem <img> --> <div
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"
<!-- 1) Overlay: sorgt für Kontrast auf hellem Himmel --> >
<div aria-hidden="true" class="pointer-events-none absolute inset-0"></div> <!-- Optimized Background Image -->
<picture class="absolute inset-0 w-full h-full z-0 pointer-events-none">
<!-- 2) Textblock --> <source srcset="/assets/images/flags_bg.avif" type="image/avif">
<div class="relative z-10 mx-auto max-w-4xl px-6 sm:px-6 py-4 sm:py-16 text-center text-white"> <img
<h1 width="2500"
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)]"> height="1285"
Buy & Sell Businesses and Commercial Properties</h1> fetchpriority="high"
loading="eager"
<p src="/assets/images/flags_bg.jpg"
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)]"> alt=""
Find profitable businesses for sale, commercial real estate, and franchise opportunities across the United class="w-full h-full object-cover"
States</p> >
</div> </picture>
</section>
<!-- Restliche Anpassungen (Innenabstände, Button-Paddings etc.) bleiben wie im vorherigen Schritt --> <!-- Gradient Overlay -->
<div <div class="absolute inset-0 bg-gradient-to-b from-black/35 via-black/15 to-transparent z-0 pointer-events-none"></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="flex justify-center w-full relative z-10">
@if(!aiSearch){ <!-- 3. Für Mobile: m-2 statt max-w-xs; ab sm: wieder max-width und kein Margin -->
<div <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">
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"> <!-- Hero-Container -->
<ul class="flex flex-wrap -mb-px w-full"> <section class="relative">
<li class="w-[33%]"> <!-- Dein Hintergrundbild liegt hier per CSS oder absolutem <img> -->
<a (click)="changeTab('business')" [ngClass]="
activeTabAction === 'business' <!-- 1) Overlay: sorgt für Kontrast auf hellem Himmel (Previous overlay removed, using new global overlay) -->
? ['text-primary-600', 'border-primary-600', 'active', 'dark:text-primary-500', 'dark:border-primary-500'] <!-- <div aria-hidden="true" class="pointer-events-none absolute inset-0"></div> -->
: ['border-transparent', 'hover:text-neutral-600', 'hover:border-neutral-300', 'dark:hover:text-neutral-300']
" <!-- 2) Textblock -->
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"> <div class="relative z-10 mx-auto max-w-4xl px-6 sm:px-6 py-4 sm:py-16 text-center text-white">
<img src="/assets/images/business_logo.png" alt="Search businesses for sale" <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>
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> <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)]">
</a> Buy profitable businesses for sale or sell your business to qualified buyers. Browse commercial real estate and franchise opportunities across the United States.
</li> </p>
@if ((numberOfCommercial$ | async) > 0) { </div>
<li class="w-[33%]"> </section>
<a (click)="changeTab('commercialProperty')" [ngClass]=" <!-- Restliche Anpassungen (Innenabstände, Button-Paddings etc.) bleiben wie im vorherigen Schritt -->
activeTabAction === 'commercialProperty' <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 }">
? ['text-primary-600', 'border-primary-600', 'active', 'dark:text-primary-500', 'dark:border-primary-500'] @if(!aiSearch){
: ['border-transparent', 'hover:text-neutral-600', 'hover:border-neutral-300', 'dark:hover:text-neutral-300'] <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" role="tablist">
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"> <li class="w-[33%]" role="presentation">
<img src="/assets/images/properties_logo.png" alt="Search commercial properties for sale" <button
class="tab-icon w-6 h-6 md:w-7 md:h-7 mr-1 md:mr-2 object-contain" width="28" height="28" /> type="button"
<span>Properties</span> role="tab"
</a> [attr.aria-selected]="activeTabAction === 'business'"
</li> (click)="changeTab('business')"
} [ngClass]="
<li class="w-[33%]"> activeTabAction === 'business'
<a (click)="changeTab('broker')" [ngClass]=" ? ['text-primary-600', 'border-primary-600', 'active', 'dark:text-primary-500', 'dark:border-primary-500']
activeTabAction === 'broker' : ['border-transparent', 'hover:text-neutral-600', 'hover:border-neutral-300', 'dark:hover:text-neutral-300']
? ['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'] 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"
" >
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="" 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" />
<img src="/assets/images/icon_professionals.png" alt="Search business professionals and brokers" <span>Businesses</span>
class="tab-icon w-6 h-6 md:w-7 md:h-7 mr-1 md:mr-2 object-contain bg-transparent" </button>
style="mix-blend-mode: darken;" /> </li>
<span>Professionals</span> @if ((numberOfCommercial$ | async) > 0) {
</a> <li class="w-[33%]" role="presentation">
</li> <button
</ul> type="button"
</div> role="tab"
} @if(criteria && !aiSearch){ [attr.aria-selected]="activeTabAction === 'commercialProperty'"
<div (click)="changeTab('commercialProperty')"
class="w-full max-w-3xl mx-auto bg-white rounded-lg flex flex-col md:flex-row md:border md:border-neutral-300"> [ngClass]="
<div class="md:flex-none md:w-48 flex-1 md:border-r border-neutral-300 overflow-hidden mb-2 md:mb-0"> activeTabAction === 'commercialProperty'
<div class="relative max-sm:border border-neutral-300 rounded-md"> ? ['text-primary-600', 'border-primary-600', 'active', 'dark:text-primary-500', 'dark:border-primary-500']
<select : ['border-transparent', 'hover:text-neutral-600', 'hover:border-neutral-300', 'dark:hover:text-neutral-300']
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)" 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"
[ngClass]="{ 'placeholder-selected': criteria.types.length === 0 }"> >
<option [value]="[]">{{ getPlaceholderLabel() }}</option> <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" />
@for(type of getTypes(); track type){ <span>Properties</span>
<option [value]="type.value">{{ type.name }}</option> </button>
} </li>
</select> }
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-neutral-700"> <li class="w-[33%]" role="presentation">
<i class="fas fa-chevron-down text-xs"></i> <button
</div> type="button"
</div> role="tab"
</div> [attr.aria-selected]="activeTabAction === 'broker'"
(click)="changeTab('broker')"
<div class="md:flex-auto md:w-36 flex-grow md:border-r border-neutral-300 mb-2 md:mb-0"> [ngClass]="
<div class="relative max-sm:border border-neutral-300 rounded-md"> activeTabAction === 'broker'
<ng-select class="custom md:border-none rounded-md md:rounded-none" [multiple]="false" ? ['text-primary-600', 'border-primary-600', 'active', 'dark:text-primary-500', 'dark:border-primary-500']
[hideSelected]="true" [trackByFn]="trackByFn" [minTermLength]="2" [loading]="cityLoading" : ['border-transparent', 'hover:text-neutral-600', 'hover:border-neutral-300', 'dark:hover:text-neutral-300']
typeToSearchText="Please enter 2 or more characters" [typeahead]="cityInput$" [ngModel]="cityOrState" "
(ngModelChange)="setCityOrState($event)" placeholder="Enter City or State ..." groupBy="type"> 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"
@for (city of cities$ | async; track city.id) { @let state = city.type==='city'?city.content.state:''; >
@let separator = city.type==='city'?' - ':''; <img
<ng-option [value]="city">{{ city.content.name }}{{ separator }}{{ state }}</ng-option> src="/assets/images/icon_professionals.png"
} alt=""
</ng-select> aria-hidden="true"
</div> class="tab-icon w-6 h-6 md:w-7 md:h-7 mr-1 md:mr-2 object-contain"
</div> style="mix-blend-mode: darken"
@if (criteria.radius && !aiSearch){ width="28" height="28"
<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"> <span>Professionals</span>
<select </button>
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]" </li>
(ngModelChange)="onRadiusChange($event)" [ngModel]="criteria.radius" </ul>
[ngClass]="{ 'placeholder-selected': !criteria.radius }"> </div>
<option [value]="null">City Radius</option> } @if(criteria && !aiSearch){
@for(dist of selectOptions.distances; track dist){ <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">
<option [value]="dist.value">{{ dist.name }}</option> <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">
</select> <label for="type-filter" class="sr-only">Filter by type</label>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-neutral-700"> <select
<i class="fas fa-chevron-down text-xs"></i> id="type-filter"
</div> aria-label="Filter by type"
</div>
</div> 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"
<div class="bg-primary-500 hover:bg-primary-600 max-sm:rounded-md search-button"> (ngModelChange)="onTypesChange($event)"
@if( numberOfResults$){ [ngClass]="{ 'placeholder-selected': criteria.types.length === 0 }"
<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" <option [value]="[]">{{ getPlaceholderLabel() }}</option>
(click)="search()"> @for(type of getTypes(); track type){
<i class="fas fa-search"></i> <option [value]="type.value">{{ type.name }}</option>
<span>Search {{ numberOfResults$ | async }}</span> }
</button> </select>
}@else { <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-neutral-700">
<button <i class="fas fa-chevron-down text-xs"></i>
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" </div>
(click)="search()"> </div>
<i class="fas fa-search"></i> </div>
<span>Search</span>
</button> <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">
</div> <label for="location-search" class="sr-only">Search by city or state</label>
</div> <ng-select
} class="custom md:border-none rounded-md md:rounded-none"
</div> [multiple]="false"
</div> [hideSelected]="true"
</div> [trackByFn]="trackByFn"
</div> [minTermLength]="2"
[loading]="cityLoading"
<!-- FAQ Section for SEO/AEO --> typeToSearchText="Please enter 2 or more characters"
<div class="w-full px-4 mt-12 max-w-4xl mx-auto"> [typeahead]="cityInput$"
<app-faq [faqItems]="faqItems"></app-faq> [ngModel]="cityOrState"
</div> (ngModelChange)="setCityOrState($event)"
</main> placeholder="Enter City or State ..."
<!-- ==== ANPASSUNGEN ENDE ==== --> groupBy="type"
labelForId="location-search"
aria-label="Search by city or state"
>
@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>
</div>
</div>
@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 }"
>
<option [value]="null">City Radius</option>
@for(dist of selectOptions.distances; track dist){
<option [value]="dist.value">{{ dist.name }}</option>
}
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-neutral-700">
<i class="fas fa-chevron-down text-xs"></i>
</div>
</div>
</div>
}
<div class="bg-primary-500 hover:bg-primary-600 max-sm:rounded-md search-button">
@if( numberOfResults$){
<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 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>
}
</div>
</div>
}
</div>
</div>
</div>
</div>
<!-- FAQ Section for SEO/AEO -->
<!-- <div class="w-full px-4 mt-12 max-w-4xl mx-auto">
<app-faq [faqItems]="faqItems"></app-faq>
</div> -->
</main>
<!-- ==== ANPASSUNGEN ENDE ==== -->

View File

@@ -1,267 +1,252 @@
.bg-cover-custom {
position: relative; select:not([size]) {
// Prioritize AVIF format (69KB) over JPG (26MB) background-image: unset;
background-image: url('/assets/images/flags_bg.avif'); }
background-size: cover; [type='text'],
background-position: center; [type='email'],
border-radius: 20px; [type='url'],
[type='password'],
// Fallback for browsers that don't support AVIF [type='number'],
@supports not (background-image: url('/assets/images/flags_bg.avif')) { [type='date'],
background-image: url('/assets/images/flags_bg.jpg'); [type='datetime-local'],
} [type='month'],
[type='search'],
// Add gradient overlay for better text contrast [type='tel'],
&::before { [type='time'],
content: ''; [type='week'],
position: absolute; [multiple],
top: 0; textarea,
left: 0; select {
right: 0; border: unset;
bottom: 0; }
background: linear-gradient( .toggle-checkbox:checked {
180deg, right: 0;
rgba(0, 0, 0, 0.35) 0%, border-color: rgb(125 211 252);
rgba(0, 0, 0, 0.15) 40%, }
rgba(0, 0, 0, 0.05) 70%, .toggle-checkbox:checked + .toggle-label {
rgba(0, 0, 0, 0) 100% background-color: rgb(125 211 252);
); }
border-radius: 20px; :host ::ng-deep .ng-select.ng-select-single .ng-select-container {
pointer-events: none; min-height: 52px;
z-index: 1; border: none;
} background-color: transparent;
.ng-value-container .ng-input {
// Ensure content stays above overlay top: 12px;
> * { }
position: relative; span.ng-arrow-wrapper {
z-index: 2; display: none;
} }
} }
select:not([size]) { select {
background-image: unset; color: #000; /* Standard-Textfarbe für das Dropdown */
} // background-color: #fff; /* Hintergrundfarbe für das Dropdown */
[type='text'], }
[type='email'],
[type='url'], select option {
[type='password'], color: #000; /* Textfarbe für Dropdown-Optionen */
[type='number'], }
[type='date'],
[type='datetime-local'], select.placeholder-selected {
[type='month'], color: #999; /* Farbe für den Platzhalter */
[type='search'], }
[type='tel'], input::placeholder {
[type='time'], color: #555; /* Dunkleres Grau */
[type='week'], opacity: 1; /* Stellt sicher, dass die Deckkraft 100% ist */
[multiple], }
textarea,
select { /* Stellt sicher, dass die Optionen im Dropdown immer schwarz sind */
border: unset; select:focus option,
} select:hover option {
.toggle-checkbox:checked { color: #000 !important;
right: 0; }
border-color: rgb(125 211 252); input[type='text'][name='aiSearchText'] {
} padding: 14px; /* Innerer Abstand */
.toggle-checkbox:checked + .toggle-label { font-size: 16px; /* Schriftgröße anpassen */
background-color: rgb(125 211 252); box-sizing: border-box; /* Padding und Border in die Höhe und Breite einrechnen */
} height: 48px;
:host ::ng-deep .ng-select.ng-select-single .ng-select-container { }
min-height: 52px;
border: none; // Enhanced Search Button Styling
background-color: transparent; .search-button {
.ng-value-container .ng-input { position: relative;
top: 12px; overflow: hidden;
} transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
span.ng-arrow-wrapper {
display: none; &:hover {
} box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
} filter: brightness(1.05);
select { }
color: #000; /* Standard-Textfarbe für das Dropdown */
// background-color: #fff; /* Hintergrundfarbe für das Dropdown */ &:active {
} transform: scale(0.98);
}
select option {
color: #000; /* Textfarbe für Dropdown-Optionen */ // Ripple effect
} &::after {
content: '';
select.placeholder-selected { position: absolute;
color: #999; /* Farbe für den Platzhalter */ top: 50%;
} left: 50%;
input::placeholder { width: 0;
color: #555; /* Dunkleres Grau */ height: 0;
opacity: 1; /* Stellt sicher, dass die Deckkraft 100% ist */ border-radius: 50%;
} background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
/* Stellt sicher, dass die Optionen im Dropdown immer schwarz sind */ transition:
select:focus option, width 0.6s,
select:hover option { height 0.6s;
color: #000 !important; pointer-events: none;
} }
input[type='text'][name='aiSearchText'] {
padding: 14px; /* Innerer Abstand */ &:active::after {
font-size: 16px; /* Schriftgröße anpassen */ width: 300px;
box-sizing: border-box; /* Padding und Border in die Höhe und Breite einrechnen */ height: 300px;
height: 48px; }
} }
// Enhanced Search Button Styling // Tab Icon Styling
.search-button { .tab-icon {
position: relative; font-size: 1rem;
overflow: hidden; margin-right: 0.5rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: transform 0.2s ease;
}
&:hover {
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4); .tab-link {
filter: brightness(1.05); transition: all 0.2s ease-in-out;
}
&:hover .tab-icon {
&:active { transform: scale(1.15);
transform: scale(0.98); }
} }
// Ripple effect // Input Field Hover Effects
&::after { select,
content: ''; .ng-select {
position: absolute; transition: all 0.2s ease-in-out;
top: 50%;
left: 50%; &:hover {
width: 0; background-color: rgba(243, 244, 246, 0.8);
height: 0; }
border-radius: 50%;
background: rgba(255, 255, 255, 0.3); &:focus,
transform: translate(-50%, -50%); &:focus-within {
transition: width 0.6s, height 0.6s; background-color: white;
pointer-events: none; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
} }
}
&:active::after {
width: 300px; // Smooth tab transitions
height: 300px; .tab-content {
} animation: fadeInUp 0.3s ease-out;
} }
// Tab Icon Styling @keyframes fadeInUp {
.tab-icon { from {
font-size: 1rem; opacity: 0;
margin-right: 0.5rem; transform: translateY(10px);
transition: transform 0.2s ease; }
} to {
opacity: 1;
.tab-link { transform: translateY(0);
transition: all 0.2s ease-in-out; }
}
&:hover .tab-icon {
transform: scale(1.15); // Trust section container - more prominent
} .trust-section-container {
} box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
transition:
// Input Field Hover Effects box-shadow 0.3s ease,
select, transform 0.3s ease;
.ng-select {
transition: all 0.2s ease-in-out; &:hover {
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12);
&:hover { }
background-color: rgba(243, 244, 246, 0.8); }
}
// Trust badge animations - subtle lowkey style
&:focus, .trust-badge {
&:focus-within { transition: opacity 0.2s ease;
background-color: white;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); &:hover {
} opacity: 0.8;
} }
}
// Smooth tab transitions
.tab-content { .trust-icon {
animation: fadeInUp 0.3s ease-out; transition:
} background-color 0.2s ease,
color 0.2s ease;
@keyframes fadeInUp { }
from {
opacity: 0; .trust-badge:hover .trust-icon {
transform: translateY(10px); background-color: rgb(229, 231, 235); // gray-200
} color: rgb(75, 85, 99); // gray-600
to { }
opacity: 1;
transform: translateY(0); // Stat counter animation - minimal
} .stat-number {
} transition: color 0.2s ease;
// Trust section container - more prominent &:hover {
.trust-section-container { color: rgb(55, 65, 81); // gray-700 darker
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); }
transition: box-shadow 0.3s ease, transform 0.3s ease; }
&:hover { // Search form container enhancement
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12); .search-form-container {
} transition: all 0.3s ease;
} // KEIN backdrop-filter hier!
background-color: rgba(255, 255, 255, 0.95) !important;
// Trust badge animations - subtle lowkey style border: 1px solid rgba(0, 0, 0, 0.1); // Dunklerer Rand für Kontrast
.trust-badge { box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
transition: opacity 0.2s ease;
// Falls Firefox das Element "vergisst", erzwingen wir eine Ebene
&:hover { transform: translateZ(0);
opacity: 0.8; opacity: 1 !important;
} visibility: visible !important;
} }
.trust-icon { // Header button improvements
transition: background-color 0.2s ease, color 0.2s ease; header {
} a {
transition: all 0.2s ease-in-out;
.trust-badge:hover .trust-icon {
background-color: rgb(229, 231, 235); // gray-200 &.text-blue-600.border.border-blue-600 {
color: rgb(75, 85, 99); // gray-600 // Log In button
} &:hover {
background-color: rgba(37, 99, 235, 0.05);
// Stat counter animation - minimal box-shadow: 0 2px 8px rgba(37, 99, 235, 0.15);
.stat-number { }
transition: color 0.2s ease;
&:active {
&:hover { transform: scale(0.98);
color: rgb(55, 65, 81); // gray-700 darker }
} }
}
&.bg-blue-600 {
// Search form container enhancement // Register button
.search-form-container { &:hover {
transition: all 0.3s ease; background-color: rgb(29, 78, 216);
backdrop-filter: blur(10px); box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
filter: brightness(1.05);
&:hover { }
background-color: rgba(255, 255, 255, 0.9);
} &:active {
} transform: scale(0.98);
}
// Header button improvements }
header { }
a { }
transition: all 0.2s ease-in-out;
// Screen reader only - visually hidden but accessible
&.text-blue-600.border.border-blue-600 { .sr-only {
// Log In button position: absolute;
&:hover { width: 1px;
background-color: rgba(37, 99, 235, 0.05); height: 1px;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.15); padding: 0;
} margin: -1px;
overflow: hidden;
&:active { clip: rect(0, 0, 0, 0);
transform: scale(0.98); white-space: nowrap;
} border-width: 0;
} }
&.bg-blue-600 {
// Register button
&:hover {
background-color: rgb(29, 78, 216);
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
filter: brightness(1.05);
}
&:active {
transform: scale(0.98);
}
}
}
}

View File

@@ -1,345 +1,343 @@
import { CommonModule } from '@angular/common'; 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 { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { NgSelectModule } from '@ng-select/ng-select'; import { NgSelectModule } from '@ng-select/ng-select';
import { UntilDestroy } from '@ngneat/until-destroy'; import { UntilDestroy } from '@ngneat/until-destroy';
import { catchError, concat, distinctUntilChanged, Observable, of, Subject, switchMap, tap } from 'rxjs'; 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 { BusinessListingCriteria, CityAndStateResult, CommercialPropertyListingCriteria, GeoResult, KeycloakUser, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
import { FaqComponent, FAQItem } from '../../components/faq/faq.component'; import { FaqComponent, FAQItem } from '../../components/faq/faq.component';
import { ModalService } from '../../components/search-modal/modal.service'; import { ModalService } from '../../components/search-modal/modal.service';
import { TooltipComponent } from '../../components/tooltip/tooltip.component'; import { TooltipComponent } from '../../components/tooltip/tooltip.component';
import { AiService } from '../../services/ai.service'; import { AiService } from '../../services/ai.service';
import { AuthService } from '../../services/auth.service'; import { AuthService } from '../../services/auth.service';
import { FilterStateService } from '../../services/filter-state.service'; import { FilterStateService } from '../../services/filter-state.service';
import { GeoService } from '../../services/geo.service'; import { GeoService } from '../../services/geo.service';
import { ListingsService } from '../../services/listings.service'; import { ListingsService } from '../../services/listings.service';
import { SearchService } from '../../services/search.service'; import { SearchService } from '../../services/search.service';
import { SelectOptionsService } from '../../services/select-options.service'; import { SelectOptionsService } from '../../services/select-options.service';
import { SeoService } from '../../services/seo.service'; import { SeoService } from '../../services/seo.service';
import { UserService } from '../../services/user.service'; import { UserService } from '../../services/user.service';
import { map2User } from '../../utils/utils'; import { map2User } from '../../utils/utils';
@UntilDestroy() @UntilDestroy()
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, RouterModule, NgSelectModule, FaqComponent], imports: [CommonModule, FormsModule, RouterModule, NgSelectModule, FaqComponent],
templateUrl: './home.component.html', templateUrl: './home.component.html',
styleUrl: './home.component.scss', 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']; export class HomeComponent {
activeTabAction: 'business' | 'commercialProperty' | 'broker' = 'business'; placeholders: string[] = ['Property close to Houston less than 10M', 'Franchise business in Austin price less than 500K'];
type: string; activeTabAction: 'business' | 'commercialProperty' | 'broker' = 'business';
maxPrice: string; type: string;
minPrice: string; maxPrice: string;
criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria; minPrice: string;
states = []; criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
isMenuOpen = false; states = [];
user: KeycloakUser; isMenuOpen = false;
prompt: string; user: KeycloakUser;
cities$: Observable<CityAndStateResult[]>; prompt: string;
cityLoading = false; cities$: Observable<CityAndStateResult[]>;
cityInput$ = new Subject<string>(); cityLoading = false;
cityOrState = undefined; cityInput$ = new Subject<string>();
numberOfResults$: Observable<number>; cityOrState = undefined;
numberOfBroker$: Observable<number>; numberOfResults$: Observable<number>;
numberOfCommercial$: Observable<number>; numberOfBroker$: Observable<number>;
aiSearch = false; numberOfCommercial$: Observable<number>;
aiSearchText = ''; aiSearch = false;
aiSearchFailed = false; aiSearchText = '';
loadingAi = false; aiSearchFailed = false;
@ViewChild('aiSearchInput', { static: false }) searchInput!: ElementRef; loadingAi = false;
typingSpeed: number = 100; @ViewChild('aiSearchInput', { static: false }) searchInput!: ElementRef;
pauseTime: number = 2000; typingSpeed: number = 100;
index: number = 0; pauseTime: number = 2000;
charIndex: number = 0; index: number = 0;
typingInterval: any; charIndex: number = 0;
showInput: boolean = true; typingInterval: any;
tooltipTargetBeta = 'tooltipTargetBeta'; showInput: boolean = true;
tooltipTargetBeta = 'tooltipTargetBeta';
// FAQ data optimized for AEO (Answer Engine Optimization) and Featured Snippets
faqItems: FAQItem[] = [ // FAQ data optimized for AEO (Answer Engine Optimization) and Featured Snippets
{ faqItems: FAQItem[] = [
question: 'How do I buy a business on BizMatch?', {
answer: '<p><strong>Buying a business on BizMatch involves 6 simple steps:</strong></p><ol><li><strong>Browse Listings:</strong> Search our marketplace using filters for industry, location, and price range</li><li><strong>Review Details:</strong> Examine financial information, business operations, and growth potential</li><li><strong>Contact Seller:</strong> Reach out directly through our secure messaging platform</li><li><strong>Due Diligence:</strong> Review financial statements, contracts, and legal documents</li><li><strong>Negotiate Terms:</strong> Work with the seller to agree on price and transition details</li><li><strong>Close Deal:</strong> Complete the purchase with legal and financial advisors</li></ol><p>We recommend working with experienced business brokers and conducting thorough due diligence before making any purchase.</p>' question: 'How do I buy a business on BizMatch?',
}, answer: '<p><strong>Buying a business on BizMatch involves 6 simple steps:</strong></p><ol><li><strong>Browse Listings:</strong> Search our marketplace using filters for industry, location, and price range</li><li><strong>Review Details:</strong> Examine financial information, business operations, and growth potential</li><li><strong>Contact Seller:</strong> Reach out directly through our secure messaging platform</li><li><strong>Due Diligence:</strong> Review financial statements, contracts, and legal documents</li><li><strong>Negotiate Terms:</strong> Work with the seller to agree on price and transition details</li><li><strong>Close Deal:</strong> Complete the purchase with legal and financial advisors</li></ol><p>We recommend working with experienced business brokers and conducting thorough due diligence before making any purchase.</p>'
{ },
question: 'How much does it cost to list a business for sale?', {
answer: '<p><strong>BizMatch offers flexible pricing options:</strong></p><ul><li><strong>Free Basic Listing:</strong> Post your business with essential details at no cost</li><li><strong>Premium Listing:</strong> Enhanced visibility with featured placement and priority support</li><li><strong>Broker Packages:</strong> Professional tools for business brokers and agencies</li></ul><p>Contact our team for detailed pricing information tailored to your specific needs.</p>' question: 'How much does it cost to list a business for sale?',
}, answer: '<p><strong>BizMatch offers flexible pricing options:</strong></p><ul><li><strong>Free Basic Listing:</strong> Post your business with essential details at no cost</li><li><strong>Premium Listing:</strong> Enhanced visibility with featured placement and priority support</li><li><strong>Broker Packages:</strong> Professional tools for business brokers and agencies</li></ul><p>Contact our team for detailed pricing information tailored to your specific needs.</p>'
{ },
question: 'What types of businesses can I find on BizMatch?', {
answer: '<p><strong>BizMatch features businesses across all major industries:</strong></p><ul><li><strong>Food & Hospitality:</strong> Restaurants, cafes, bars, hotels, catering services</li><li><strong>Retail:</strong> Stores, boutiques, online shops, franchises</li><li><strong>Service Businesses:</strong> Consulting firms, cleaning services, healthcare practices</li><li><strong>Manufacturing:</strong> Production facilities, distribution centers, warehouses</li><li><strong>E-commerce:</strong> Online businesses, digital products, subscription services</li><li><strong>Commercial Real Estate:</strong> Office buildings, retail spaces, industrial properties</li></ul><p>Our marketplace serves all business sizes from small local operations to large enterprises across the United States.</p>' question: 'What types of businesses can I find on BizMatch?',
}, answer: '<p><strong>BizMatch features businesses across all major industries:</strong></p><ul><li><strong>Food & Hospitality:</strong> Restaurants, cafes, bars, hotels, catering services</li><li><strong>Retail:</strong> Stores, boutiques, online shops, franchises</li><li><strong>Service Businesses:</strong> Consulting firms, cleaning services, healthcare practices</li><li><strong>Manufacturing:</strong> Production facilities, distribution centers, warehouses</li><li><strong>E-commerce:</strong> Online businesses, digital products, subscription services</li><li><strong>Commercial Real Estate:</strong> Office buildings, retail spaces, industrial properties</li></ul><p>Our marketplace serves all business sizes from small local operations to large enterprises across the United States.</p>'
{ },
question: 'How do I know if a business listing is legitimate?', {
answer: '<p><strong>Yes, BizMatch verifies all listings.</strong> Here\'s how we ensure legitimacy:</p><ol><li><strong>Seller Verification:</strong> All users must verify their identity and contact information</li><li><strong>Listing Review:</strong> Our team reviews each listing for completeness and accuracy</li><li><strong>Documentation Check:</strong> We verify business registration and ownership documents</li><li><strong>Transparent Communication:</strong> All conversations are logged through our secure platform</li></ol><p><strong>Additional steps you should take:</strong></p><ul><li>Review financial statements and tax returns</li><li>Visit the business location in person</li><li>Consult with legal and financial advisors</li><li>Work with licensed business brokers when appropriate</li><li>Conduct background checks on sellers</li></ul>' question: 'How do I know if a business listing is legitimate?',
}, answer: '<p><strong>Yes, BizMatch verifies all listings.</strong> Here\'s how we ensure legitimacy:</p><ol><li><strong>Seller Verification:</strong> All users must verify their identity and contact information</li><li><strong>Listing Review:</strong> Our team reviews each listing for completeness and accuracy</li><li><strong>Documentation Check:</strong> We verify business registration and ownership documents</li><li><strong>Transparent Communication:</strong> All conversations are logged through our secure platform</li></ol><p><strong>Additional steps you should take:</strong></p><ul><li>Review financial statements and tax returns</li><li>Visit the business location in person</li><li>Consult with legal and financial advisors</li><li>Work with licensed business brokers when appropriate</li><li>Conduct background checks on sellers</li></ul>'
{ },
question: 'Can I sell commercial property on BizMatch?', {
answer: '<p><strong>Yes!</strong> BizMatch is a full-service marketplace for both businesses and commercial real estate.</p><p><strong>Property types you can list:</strong></p><ul><li>Office buildings and professional spaces</li><li>Retail locations and shopping centers</li><li>Warehouses and distribution facilities</li><li>Industrial properties and manufacturing plants</li><li>Mixed-use developments</li><li>Land for commercial development</li></ul><p>Our platform connects you with qualified buyers, investors, and commercial real estate professionals actively searching for investment opportunities.</p>' question: 'Can I sell commercial property on BizMatch?',
}, answer: '<p><strong>Yes!</strong> BizMatch is a full-service marketplace for both businesses and commercial real estate.</p><p><strong>Property types you can list:</strong></p><ul><li>Office buildings and professional spaces</li><li>Retail locations and shopping centers</li><li>Warehouses and distribution facilities</li><li>Industrial properties and manufacturing plants</li><li>Mixed-use developments</li><li>Land for commercial development</li></ul><p>Our platform connects you with qualified buyers, investors, and commercial real estate professionals actively searching for investment opportunities.</p>'
{ },
question: 'What information should I include when listing my business?', {
answer: '<p><strong>A complete listing should include these essential details:</strong></p><ol><li><strong>Financial Information:</strong> Asking price, annual revenue, cash flow, profit margins</li><li><strong>Business Operations:</strong> Years established, number of employees, hours of operation</li><li><strong>Description:</strong> Detailed overview of products/services, customer base, competitive advantages</li><li><strong>Industry Category:</strong> Specific business type and market segment</li><li><strong>Location Details:</strong> City, state, demographic information</li><li><strong>Assets Included:</strong> Equipment, inventory, real estate, intellectual property</li><li><strong>Visual Content:</strong> High-quality photos of business premises and operations</li><li><strong>Growth Potential:</strong> Expansion opportunities and market trends</li></ol><p><strong>Pro tip:</strong> The more detailed and transparent your listing, the more interest it will generate from serious, qualified buyers.</p>' question: 'What information should I include when listing my business?',
}, answer: '<p><strong>A complete listing should include these essential details:</strong></p><ol><li><strong>Financial Information:</strong> Asking price, annual revenue, cash flow, profit margins</li><li><strong>Business Operations:</strong> Years established, number of employees, hours of operation</li><li><strong>Description:</strong> Detailed overview of products/services, customer base, competitive advantages</li><li><strong>Industry Category:</strong> Specific business type and market segment</li><li><strong>Location Details:</strong> City, state, demographic information</li><li><strong>Assets Included:</strong> Equipment, inventory, real estate, intellectual property</li><li><strong>Visual Content:</strong> High-quality photos of business premises and operations</li><li><strong>Growth Potential:</strong> Expansion opportunities and market trends</li></ol><p><strong>Pro tip:</strong> The more detailed and transparent your listing, the more interest it will generate from serious, qualified buyers.</p>'
{ },
question: 'How long does it take to sell a business?', {
answer: '<p><strong>Most businesses sell within 6 to 12 months.</strong> The timeline varies based on several factors:</p><p><strong>Factors that speed up sales:</strong></p><ul><li>Realistic pricing based on professional valuation</li><li>Complete and organized financial documentation</li><li>Strong business performance and growth trends</li><li>Attractive location and market conditions</li><li>Experienced business broker representation</li><li>Flexible seller terms and financing options</li></ul><p><strong>Timeline breakdown:</strong></p><ol><li><strong>Months 1-2:</strong> Preparation and listing creation</li><li><strong>Months 3-6:</strong> Marketing and buyer qualification</li><li><strong>Months 7-10:</strong> Negotiations and due diligence</li><li><strong>Months 11-12:</strong> Closing and transition</li></ol>' question: 'How long does it take to sell a business?',
}, answer: '<p><strong>Most businesses sell within 6 to 12 months.</strong> The timeline varies based on several factors:</p><p><strong>Factors that speed up sales:</strong></p><ul><li>Realistic pricing based on professional valuation</li><li>Complete and organized financial documentation</li><li>Strong business performance and growth trends</li><li>Attractive location and market conditions</li><li>Experienced business broker representation</li><li>Flexible seller terms and financing options</li></ul><p><strong>Timeline breakdown:</strong></p><ol><li><strong>Months 1-2:</strong> Preparation and listing creation</li><li><strong>Months 3-6:</strong> Marketing and buyer qualification</li><li><strong>Months 7-10:</strong> Negotiations and due diligence</li><li><strong>Months 11-12:</strong> Closing and transition</li></ol>'
{ },
question: 'What is business valuation and why is it important?', {
answer: '<p><strong>Business valuation is the process of determining the economic worth of a company.</strong> It calculates the fair market value based on financial performance, assets, and market conditions.</p><p><strong>Why valuation matters:</strong></p><ul><li><strong>Realistic Pricing:</strong> Attracts serious buyers and prevents extended time on market</li><li><strong>Negotiation Power:</strong> Provides data-driven justification for asking price</li><li><strong>Buyer Confidence:</strong> Professional valuations increase trust and credibility</li><li><strong>Financing Approval:</strong> Banks require valuations for business acquisition loans</li></ul><p><strong>Valuation methods include:</strong></p><ol><li><strong>Asset-Based:</strong> Total value of business assets minus liabilities</li><li><strong>Income-Based:</strong> Projected future earnings and cash flow</li><li><strong>Market-Based:</strong> Comparison to similar business sales</li><li><strong>Multiple of Earnings:</strong> Revenue or profit multiplied by industry-standard factor</li></ol>' question: 'What is business valuation and why is it important?',
}, answer: '<p><strong>Business valuation is the process of determining the economic worth of a company.</strong> It calculates the fair market value based on financial performance, assets, and market conditions.</p><p><strong>Why valuation matters:</strong></p><ul><li><strong>Realistic Pricing:</strong> Attracts serious buyers and prevents extended time on market</li><li><strong>Negotiation Power:</strong> Provides data-driven justification for asking price</li><li><strong>Buyer Confidence:</strong> Professional valuations increase trust and credibility</li><li><strong>Financing Approval:</strong> Banks require valuations for business acquisition loans</li></ul><p><strong>Valuation methods include:</strong></p><ol><li><strong>Asset-Based:</strong> Total value of business assets minus liabilities</li><li><strong>Income-Based:</strong> Projected future earnings and cash flow</li><li><strong>Market-Based:</strong> Comparison to similar business sales</li><li><strong>Multiple of Earnings:</strong> Revenue or profit multiplied by industry-standard factor</li></ol>'
{ },
question: 'Do I need a business broker to buy or sell a business?', {
answer: '<p><strong>No, but brokers are highly recommended.</strong> You can conduct transactions directly through BizMatch, but professional brokers provide significant advantages:</p><p><strong>Benefits of using a business broker:</strong></p><ul><li><strong>Expert Valuation:</strong> Accurate pricing based on market data and analysis</li><li><strong>Marketing Expertise:</strong> Professional listing creation and buyer outreach</li><li><strong>Qualified Buyers:</strong> Pre-screening to ensure financial capability and serious interest</li><li><strong>Negotiation Skills:</strong> Experience handling complex deal structures and terms</li><li><strong>Confidentiality:</strong> Protect sensitive information during the sales process</li><li><strong>Legal Compliance:</strong> Navigate regulations, contracts, and disclosures</li><li><strong>Time Savings:</strong> Handle paperwork, communications, and coordination</li></ul><p>BizMatch connects you with licensed brokers in your area, or you can manage the transaction yourself using our secure platform and resources.</p>' question: 'Do I need a business broker to buy or sell a business?',
}, answer: '<p><strong>No, but brokers are highly recommended.</strong> You can conduct transactions directly through BizMatch, but professional brokers provide significant advantages:</p><p><strong>Benefits of using a business broker:</strong></p><ul><li><strong>Expert Valuation:</strong> Accurate pricing based on market data and analysis</li><li><strong>Marketing Expertise:</strong> Professional listing creation and buyer outreach</li><li><strong>Qualified Buyers:</strong> Pre-screening to ensure financial capability and serious interest</li><li><strong>Negotiation Skills:</strong> Experience handling complex deal structures and terms</li><li><strong>Confidentiality:</strong> Protect sensitive information during the sales process</li><li><strong>Legal Compliance:</strong> Navigate regulations, contracts, and disclosures</li><li><strong>Time Savings:</strong> Handle paperwork, communications, and coordination</li></ul><p>BizMatch connects you with licensed brokers in your area, or you can manage the transaction yourself using our secure platform and resources.</p>'
{ },
question: 'What financing options are available for buying a business?', {
answer: '<p><strong>Business buyers have multiple financing options:</strong></p><ol><li><strong>SBA 7(a) Loans:</strong> Government-backed loans with favorable terms<ul><li>Down payment as low as 10%</li><li>Loan amounts up to $5 million</li><li>Competitive interest rates</li><li>Terms up to 10-25 years</li></ul></li><li><strong>Conventional Bank Financing:</strong> Traditional business acquisition loans<ul><li>Typically require 20-30% down payment</li><li>Based on creditworthiness and business performance</li></ul></li><li><strong>Seller Financing:</strong> Owner provides loan to buyer<ul><li>More flexible terms and requirements</li><li>Often combined with other financing</li><li>Typically 10-30% of purchase price</li></ul></li><li><strong>Investor Partnerships:</strong> Equity financing from partners<ul><li>Shared ownership and profits</li><li>No personal debt obligation</li></ul></li><li><strong>Personal Savings:</strong> Self-funded purchase<ul><li>No interest or loan payments</li><li>Full ownership from day one</li></ul></li></ol><p><strong>Most buyers use a combination of these options</strong> to structure the optimal deal for their situation.</p>' question: 'What financing options are available for buying a business?',
} answer: '<p><strong>Business buyers have multiple financing options:</strong></p><ol><li><strong>SBA 7(a) Loans:</strong> Government-backed loans with favorable terms<ul><li>Down payment as low as 10%</li><li>Loan amounts up to $5 million</li><li>Competitive interest rates</li><li>Terms up to 10-25 years</li></ul></li><li><strong>Conventional Bank Financing:</strong> Traditional business acquisition loans<ul><li>Typically require 20-30% down payment</li><li>Based on creditworthiness and business performance</li></ul></li><li><strong>Seller Financing:</strong> Owner provides loan to buyer<ul><li>More flexible terms and requirements</li><li>Often combined with other financing</li><li>Typically 10-30% of purchase price</li></ul></li><li><strong>Investor Partnerships:</strong> Equity financing from partners<ul><li>Shared ownership and profits</li><li>No personal debt obligation</li></ul></li><li><strong>Personal Savings:</strong> Self-funded purchase<ul><li>No interest or loan payments</li><li>Full ownership from day one</li></ul></li></ol><p><strong>Most buyers use a combination of these options</strong> to structure the optimal deal for their situation.</p>'
]; }
];
constructor(
private router: Router, constructor(
private modalService: ModalService, private router: Router,
private searchService: SearchService, private modalService: ModalService,
private activatedRoute: ActivatedRoute, private searchService: SearchService,
public selectOptions: SelectOptionsService, private activatedRoute: ActivatedRoute,
private geoService: GeoService, public selectOptions: SelectOptionsService,
public cdRef: ChangeDetectorRef, private geoService: GeoService,
private listingService: ListingsService, public cdRef: ChangeDetectorRef,
private userService: UserService, private listingService: ListingsService,
private aiService: AiService, private userService: UserService,
private authService: AuthService, private aiService: AiService,
private filterStateService: FilterStateService, private authService: AuthService,
private seoService: SeoService, private filterStateService: FilterStateService,
) { } private seoService: SeoService,
) { }
async ngOnInit() {
// Flowbite is now initialized once in AppComponent async ngOnInit() {
// Flowbite is now initialized once in AppComponent
// Set SEO meta tags for home page
this.seoService.updateMetaTags({ // Set SEO meta tags for home page
title: 'BizMatch - Buy & Sell Businesses and Commercial Properties', this.seoService.updateMetaTags({
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.', title: 'BizMatch - Buy & Sell Businesses and Commercial Properties',
keywords: 'business for sale, businesses for sale, buy business, sell business, commercial property, commercial real estate, franchise opportunities, business broker, business marketplace', description: 'Buy and sell businesses, commercial properties, and franchises. Browse thousands of verified listings across the United States.',
type: 'website' 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
const organizationSchema = this.seoService.generateOrganizationSchema(); // Add Organization schema for brand identity
const faqSchema = this.seoService.generateFAQPageSchema( // NOTE: FAQ schema removed because FAQ section is hidden (violates Google's visible content requirement)
this.faqItems.map(item => ({ // FAQ content is preserved in component for future use when FAQ section is made visible
question: item.question, const organizationSchema = this.seoService.generateOrganizationSchema();
answer: item.answer
})) // Add HowTo schema for buying a business
); const howToSchema = this.seoService.generateHowToSchema({
name: 'How to Buy a Business on BizMatch',
// Add HowTo schema for buying a business description: 'Step-by-step guide to finding and purchasing your ideal business through BizMatch marketplace',
const howToSchema = this.seoService.generateHowToSchema({ totalTime: 'PT45M',
name: 'How to Buy a Business on BizMatch', steps: [
description: 'Step-by-step guide to finding and purchasing your ideal business through BizMatch marketplace', {
totalTime: 'PT45M', name: 'Browse Business Listings',
steps: [ 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: '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: '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: '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: '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: '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.'
{ }
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();
// Add SearchBox schema for Sitelinks Search // Inject schemas (FAQ schema excluded - content not visible to users)
const searchBoxSchema = this.seoService.generateSearchBoxSchema(); this.seoService.injectMultipleSchemas([organizationSchema, howToSchema, searchBoxSchema]);
this.seoService.injectMultipleSchemas([organizationSchema, faqSchema, howToSchema, searchBoxSchema]); // Clear all filters and sort options on initial load
this.filterStateService.resetCriteria('businessListings');
// Clear all filters and sort options on initial load this.filterStateService.resetCriteria('commercialPropertyListings');
this.filterStateService.resetCriteria('businessListings'); this.filterStateService.resetCriteria('brokerListings');
this.filterStateService.resetCriteria('commercialPropertyListings'); this.filterStateService.updateSortBy('businessListings', null);
this.filterStateService.resetCriteria('brokerListings'); this.filterStateService.updateSortBy('commercialPropertyListings', null);
this.filterStateService.updateSortBy('businessListings', null); this.filterStateService.updateSortBy('brokerListings', null);
this.filterStateService.updateSortBy('commercialPropertyListings', null);
this.filterStateService.updateSortBy('brokerListings', null); // Initialize criteria for the default tab
this.criteria = this.filterStateService.getCriteria('businessListings');
// Initialize criteria for the default tab
this.criteria = this.filterStateService.getCriteria('businessListings'); this.numberOfBroker$ = this.userService.getNumberOfBroker(this.filterStateService.getCriteria('brokerListings') as UserListingCriteria);
this.numberOfCommercial$ = this.listingService.getNumberOfListings('commercialProperty');
this.numberOfBroker$ = this.userService.getNumberOfBroker(this.filterStateService.getCriteria('brokerListings') as UserListingCriteria); const token = await this.authService.getToken();
this.numberOfCommercial$ = this.listingService.getNumberOfListings('commercialProperty'); this.user = map2User(token);
const token = await this.authService.getToken(); this.loadCities();
this.user = map2User(token); this.setTotalNumberOfResults();
this.loadCities(); }
this.setTotalNumberOfResults();
} changeTab(tabname: 'business' | 'commercialProperty' | 'broker') {
this.activeTabAction = tabname;
changeTab(tabname: 'business' | 'commercialProperty' | 'broker') { this.cityOrState = null;
this.activeTabAction = tabname; const tabToListingType = {
this.cityOrState = null; business: 'businessListings',
const tabToListingType = { commercialProperty: 'commercialPropertyListings',
business: 'businessListings', broker: 'brokerListings',
commercialProperty: 'commercialPropertyListings', };
broker: 'brokerListings', this.criteria = this.filterStateService.getCriteria(tabToListingType[tabname] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings');
}; this.setTotalNumberOfResults();
this.criteria = this.filterStateService.getCriteria(tabToListingType[tabname] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings'); }
this.setTotalNumberOfResults();
} search() {
this.router.navigate([`${this.activeTabAction}Listings`]);
search() { }
this.router.navigate([`${this.activeTabAction}Listings`]);
} toggleMenu() {
this.isMenuOpen = !this.isMenuOpen;
toggleMenu() { }
this.isMenuOpen = !this.isMenuOpen;
} onTypesChange(value) {
const tabToListingType = {
onTypesChange(value) { business: 'businessListings',
const tabToListingType = { commercialProperty: 'commercialPropertyListings',
business: 'businessListings', broker: 'brokerListings',
commercialProperty: 'commercialPropertyListings', };
broker: 'brokerListings', const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
}; this.filterStateService.updateCriteria(listingType, { types: value === '' ? [] : [value] });
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings'; this.criteria = this.filterStateService.getCriteria(listingType);
this.filterStateService.updateCriteria(listingType, { types: value === '' ? [] : [value] }); this.setTotalNumberOfResults();
this.criteria = this.filterStateService.getCriteria(listingType); }
this.setTotalNumberOfResults();
} onRadiusChange(value) {
const tabToListingType = {
onRadiusChange(value) { business: 'businessListings',
const tabToListingType = { commercialProperty: 'commercialPropertyListings',
business: 'businessListings', broker: 'brokerListings',
commercialProperty: 'commercialPropertyListings', };
broker: 'brokerListings', const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
}; this.filterStateService.updateCriteria(listingType, { radius: value === 'null' ? null : parseInt(value) });
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings'; this.criteria = this.filterStateService.getCriteria(listingType);
this.filterStateService.updateCriteria(listingType, { radius: value === 'null' ? null : parseInt(value) }); this.setTotalNumberOfResults();
this.criteria = this.filterStateService.getCriteria(listingType); }
this.setTotalNumberOfResults();
} async openModal() {
const tabToListingType = {
async openModal() { business: 'businessListings',
const tabToListingType = { commercialProperty: 'commercialPropertyListings',
business: 'businessListings', broker: 'brokerListings',
commercialProperty: 'commercialPropertyListings', };
broker: 'brokerListings', const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
}; const accepted = await this.modalService.showModal(this.criteria);
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings'; if (accepted) {
const accepted = await this.modalService.showModal(this.criteria); this.router.navigate([`${this.activeTabAction}Listings`]);
if (accepted) { }
this.router.navigate([`${this.activeTabAction}Listings`]); }
}
} private loadCities() {
this.cities$ = concat(
private loadCities() { of([]),
this.cities$ = concat( this.cityInput$.pipe(
of([]), distinctUntilChanged(),
this.cityInput$.pipe( tap(() => (this.cityLoading = true)),
distinctUntilChanged(), switchMap(term =>
tap(() => (this.cityLoading = true)), this.geoService.findCitiesAndStatesStartingWith(term).pipe(
switchMap(term => catchError(() => of([])),
this.geoService.findCitiesAndStatesStartingWith(term).pipe( tap(() => (this.cityLoading = false)),
catchError(() => of([])), ),
tap(() => (this.cityLoading = false)), ),
), ),
), );
), }
);
} trackByFn(item: GeoResult) {
return item.id;
trackByFn(item: GeoResult) { }
return item.id;
} setCityOrState(cityOrState: CityAndStateResult) {
const tabToListingType = {
setCityOrState(cityOrState: CityAndStateResult) { business: 'businessListings',
const tabToListingType = { commercialProperty: 'commercialPropertyListings',
business: 'businessListings', broker: 'brokerListings',
commercialProperty: 'commercialPropertyListings', };
broker: 'brokerListings', const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
};
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings'; if (cityOrState) {
if (cityOrState.type === 'state') {
if (cityOrState) { this.filterStateService.updateCriteria(listingType, { state: cityOrState.content.state_code, city: null, radius: null, searchType: 'exact' });
if (cityOrState.type === 'state') { } else {
this.filterStateService.updateCriteria(listingType, { state: cityOrState.content.state_code, city: null, radius: null, searchType: 'exact' }); this.filterStateService.updateCriteria(listingType, {
} else { city: cityOrState.content as GeoResult,
this.filterStateService.updateCriteria(listingType, { state: cityOrState.content.state,
city: cityOrState.content as GeoResult, searchType: 'radius',
state: cityOrState.content.state, radius: 20,
searchType: 'radius', });
radius: 20, }
}); } else {
} this.filterStateService.updateCriteria(listingType, { state: null, city: null, radius: null, searchType: 'exact' });
} else { }
this.filterStateService.updateCriteria(listingType, { state: null, city: null, radius: null, searchType: 'exact' }); this.criteria = this.filterStateService.getCriteria(listingType);
} this.setTotalNumberOfResults();
this.criteria = this.filterStateService.getCriteria(listingType); }
this.setTotalNumberOfResults();
} getTypes() {
if (this.criteria.criteriaType === 'businessListings') {
getTypes() { return this.selectOptions.typesOfBusiness;
if (this.criteria.criteriaType === 'businessListings') { } else if (this.criteria.criteriaType === 'commercialPropertyListings') {
return this.selectOptions.typesOfBusiness; return this.selectOptions.typesOfCommercialProperty;
} else if (this.criteria.criteriaType === 'commercialPropertyListings') { } else {
return this.selectOptions.typesOfCommercialProperty; return this.selectOptions.customerSubTypes;
} else { }
return this.selectOptions.customerSubTypes; }
}
} getPlaceholderLabel() {
if (this.criteria.criteriaType === 'businessListings') {
getPlaceholderLabel() { return 'Business Type';
if (this.criteria.criteriaType === 'businessListings') { } else if (this.criteria.criteriaType === 'commercialPropertyListings') {
return 'Business Type'; return 'Property Type';
} else if (this.criteria.criteriaType === 'commercialPropertyListings') { } else {
return 'Property Type'; return 'Professional Type';
} else { }
return 'Professional Type'; }
}
} setTotalNumberOfResults() {
if (this.criteria) {
setTotalNumberOfResults() { console.log(`Getting total number of results for ${this.criteria.criteriaType}`);
if (this.criteria) { const tabToListingType = {
console.log(`Getting total number of results for ${this.criteria.criteriaType}`); business: 'businessListings',
const tabToListingType = { commercialProperty: 'commercialPropertyListings',
business: 'businessListings', broker: 'brokerListings',
commercialProperty: 'commercialPropertyListings', };
broker: 'brokerListings', const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
};
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings'; if (this.criteria.criteriaType === 'businessListings' || this.criteria.criteriaType === 'commercialPropertyListings') {
this.numberOfResults$ = this.listingService.getNumberOfListings(this.criteria.criteriaType === 'businessListings' ? 'business' : 'commercialProperty');
if (this.criteria.criteriaType === 'businessListings' || this.criteria.criteriaType === 'commercialPropertyListings') { } else if (this.criteria.criteriaType === 'brokerListings') {
this.numberOfResults$ = this.listingService.getNumberOfListings(this.criteria.criteriaType === 'businessListings' ? 'business' : 'commercialProperty'); this.numberOfResults$ = this.userService.getNumberOfBroker(this.filterStateService.getCriteria('brokerListings') as UserListingCriteria);
} else if (this.criteria.criteriaType === 'brokerListings') { } else {
this.numberOfResults$ = this.userService.getNumberOfBroker(this.filterStateService.getCriteria('brokerListings') as UserListingCriteria); this.numberOfResults$ = of();
} else { }
this.numberOfResults$ = of(); }
} }
}
} ngOnDestroy(): void {
clearTimeout(this.typingInterval);
ngOnDestroy(): void { }
clearTimeout(this.typingInterval); }
}
}

View File

@@ -1,201 +1,201 @@
<div class="container mx-auto px-4 py-8 max-w-4xl"> <div class="container mx-auto px-4 py-8 max-w-4xl">
<div class="bg-white rounded-lg drop-shadow-custom-bg p-6 md:p-8 relative"> <div class="bg-white rounded-lg drop-shadow-custom-bg p-6 md:p-8 relative">
<button <button
(click)="goBack()" (click)="goBack()"
class="absolute top-4 right-4 md:top-6 md:right-6 w-10 h-10 flex items-center justify-center rounded-full bg-red-600 hover:bg-red-700 text-white transition-colors duration-200" class="absolute top-4 right-4 md:top-6 md:right-6 w-10 h-10 flex items-center justify-center rounded-full bg-red-600 hover:bg-red-700 text-white transition-colors duration-200"
aria-label="Go back" aria-label="Go back"
> >
<i class="fas fa-arrow-left text-lg"></i> <i class="fas fa-arrow-left text-lg"></i>
</button> </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"> <section id="content" role="main">
<article class="post page"> <article class="post page">
<section class="entry-content"> <section class="entry-content">
<div class="container"> <div class="container">
<p class="font-bold mb-4">Privacy Policy</p> <p class="font-bold mb-4">Privacy Policy</p>
<p class="mb-4">We are committed to protecting your privacy. We have established this statement as a testament to our commitment to your privacy.</p> <p class="mb-4">We are committed to protecting your privacy. We have established this statement as a testament to our commitment to your privacy.</p>
<p class="mb-4">This Privacy Policy relates to the use of any personal information you provide to us through this websites.</p> <p class="mb-4">This Privacy Policy relates to the use of any personal information you provide to us through this websites.</p>
<p class="mb-4"> <p class="mb-4">
By accepting the Privacy Policy during registration or the sending of an enquiry, you expressly consent to our collection, storage, use and disclosure of your personal information as described in this Privacy By accepting the Privacy Policy during registration or the sending of an enquiry, you expressly consent to our collection, storage, use and disclosure of your personal information as described in this Privacy
Policy. Policy.
</p> </p>
<p class="mb-4"> <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. continuing to deal with us, you accept this Privacy Policy.
</p> </p>
<p class="font-bold mb-4 mt-6">Collection of personal information</p> <p class="font-bold mb-4 mt-6">Collection of personal information</p>
<p class="mb-4">Anyone can browse our websites without revealing any personally identifiable information.</p> <p class="mb-4">Anyone can browse our websites without revealing any personally identifiable information.</p>
<p class="mb-4">However, should you wish to contact a business for sale, a franchise opportunity or an intermediary, we will require you to provide some personal information.</p> <p class="mb-4">However, should you wish to contact a business for sale, a franchise opportunity or an intermediary, we will require you to provide some personal information.</p>
<p class="mb-4">Should you wish to advertise your services, your business (es) or your franchise opportunity, we will require you to provide some personal information.</p> <p class="mb-4">Should you wish to advertise your services, your business (es) or your franchise opportunity, we will require you to provide some personal information.</p>
<p class="mb-4">By providing personal information, you are consenting to the transfer and storage of that information on our servers located in the United States.</p> <p class="mb-4">By providing personal information, you are consenting to the transfer and storage of that information on our servers located in the United States.</p>
<p class="mb-4">We may collect and store the following personal information:</p> <p class="mb-4">We may collect and store the following personal information:</p>
<p class="mb-4"> <p class="mb-4">
Your name, email address, physical address, telephone numbers, and (depending on the service used), your business information, financial information, such as credit / payment card details;<br /> Your name, email address, physical address, telephone numbers, and (depending on the service used), your business information, financial information, such as credit / payment card details;<br />
transactional information based on your activities on the site; information that you disclose in a forum on any of our websites, feedback, correspondence through our websites, and correspondence sent to transactional information based on your activities on the site; information that you disclose in a forum on any of our websites, feedback, correspondence through our websites, and correspondence sent to
us;<br /> us;<br />
other information from your interaction with our websites, services, content and advertising, including computer and connection information, statistics on page views, traffic to and from the sites, ad data, other information from your interaction with our websites, services, content and advertising, including computer and connection information, statistics on page views, traffic to and from the sites, ad data,
IP address and standard web log information;<br /> IP address and standard web log information;<br />
supplemental information from third parties (for example, if you incur a debt, we will generally conduct a credit check by obtaining additional information about you from a credit bureau, as permitted by law; supplemental information from third parties (for example, if you incur a debt, we will generally conduct a credit check by obtaining additional information about you from a credit bureau, as permitted by law;
or if the information you provide cannot be verified,<br /> or if the information you provide cannot be verified,<br />
we may ask you to send us additional information, or to answer additional questions online to help verify your information). we may ask you to send us additional information, or to answer additional questions online to help verify your information).
</p> </p>
<p class="font-bold mb-4 mt-6">How we use your information</p> <p class="font-bold mb-4 mt-6">How we use your information</p>
<p class="mb-4"> <p class="mb-4">
The primary reason we collect your personal information is to improve the services we deliver to you through our website. By registering or sending an enquiry through our website, you agree that we may use The primary reason we collect your personal information is to improve the services we deliver to you through our website. By registering or sending an enquiry through our website, you agree that we may use
your personal information to:<br /> your personal information to:<br />
provide the services and customer support you request;<br /> provide the services and customer support you request;<br />
connect you with relevant parties:<br /> connect you with relevant parties:<br />
If you are a buyer we will pass some or all of your details on to the seller / intermediary along with any message you have typed. This allows the seller to contact you in order to pursue a possible sale of a If you are a buyer we will pass some or all of your details on to the seller / intermediary along with any message you have typed. This allows the seller to contact you in order to pursue a possible sale of a
business;<br /> business;<br />
If you are a seller / intermediary, we will disclose your details where you have given us permission to do so;<br /> If you are a seller / intermediary, we will disclose your details where you have given us permission to do so;<br />
resolve disputes, collect fees, and troubleshoot problems;<br /> resolve disputes, collect fees, and troubleshoot problems;<br />
prevent potentially prohibited or illegal activities, and enforce our Terms and Conditions;<br /> prevent potentially prohibited or illegal activities, and enforce our Terms and Conditions;<br />
customize, measure and improve our services, conduct internal market research, provide content and advertising;<br /> customize, measure and improve our services, conduct internal market research, provide content and advertising;<br />
tell you about other Biz-Match products and services, target marketing, send you service updates, and promotional offers based on your communication preferences. tell you about other Biz-Match products and services, target marketing, send you service updates, and promotional offers based on your communication preferences.
</p> </p>
<p class="font-bold mb-4 mt-6">Our disclosure of your information</p> <p class="font-bold mb-4 mt-6">Our disclosure of your information</p>
<p class="mb-4"> <p class="mb-4">
We may disclose personal information to respond to legal requirements, enforce our policies, respond to claims that a listing or other content infringes the rights of others, or protect anyone's rights, We may disclose personal information to respond to legal requirements, enforce our policies, respond to claims that a listing or other content infringes the rights of others, or protect anyone's rights,
property, or safety. property, or safety.
</p> </p>
<p class="mb-4"> <p class="mb-4">
We may also share your personal information with<br /> We may also share your personal information with<br />
When you select to register an account as a business buyer, you provide your personal details and we will pass this on to a seller of a business or franchise when you request more information. When you select to register an account as a business buyer, you provide your personal details and we will pass this on to a seller of a business or franchise when you request more information.
</p> </p>
<p class="mb-4"> <p class="mb-4">
When you select to register an account as a business broker or seller on the site, we provide a public platform on which to establish your business profile. This profile consists of pertinent facts about your When you select to register an account as a business broker or seller on the site, we provide a public platform on which to establish your business profile. This profile consists of pertinent facts about your
business along with your personal information; namely, the contact information you provide to facilitate contact between you and other users' of the site. Direct email addresses and telephone numbers will not business along with your personal information; namely, the contact information you provide to facilitate contact between you and other users' of the site. Direct email addresses and telephone numbers will not
be publicly displayed unless you specifically include it on your profile. be publicly displayed unless you specifically include it on your profile.
</p> </p>
<p class="mb-4"> <p class="mb-4">
The information a user includes within the forums provided on the site is publicly available to other users' of the site. Please be aware that any personal information you elect to provide in a public forum The information a user includes within the forums provided on the site is publicly available to other users' of the site. Please be aware that any personal information you elect to provide in a public forum
may be used to send you unsolicited messages; we are not responsible for the personal information a user elects to disclose within their public profile, or in the private communications that users' engage in may be used to send you unsolicited messages; we are not responsible for the personal information a user elects to disclose within their public profile, or in the private communications that users' engage in
on the site. on the site.
</p> </p>
<p class="mb-4"> <p class="mb-4">
We post testimonials on the site obtained from users'. These testimonials may include the name, city, state or region and business of the user. We obtain permission from our users' prior to posting their We post testimonials on the site obtained from users'. These testimonials may include the name, city, state or region and business of the user. We obtain permission from our users' prior to posting their
testimonials on the site. We are not responsible for any personal information a user selects to include within their testimonial. testimonials on the site. We are not responsible for any personal information a user selects to include within their testimonial.
</p> </p>
<p class="mb-4"> <p class="mb-4">
When you elect to email a friend about the site, or a particular business, we request the third party's email address to send this one time email. We do not share this information with any third parties for When you elect to email a friend about the site, or a particular business, we request the third party's email address to send this one time email. We do not share this information with any third parties for
their promotional purposes and only store the information to gauge the effectiveness of our referral program. their promotional purposes and only store the information to gauge the effectiveness of our referral program.
</p> </p>
<p class="mb-4">We may share your personal information with our service providers where necessary. We employ the services of a payment processor to fulfil payment for services purchased on the site.</p> <p class="mb-4">We may share your personal information with our service providers where necessary. We employ the services of a payment processor to fulfil payment for services purchased on the site.</p>
<p class="mb-4"> <p class="mb-4">
We works with a number of partners or affiliates, where we provide marketing services for these companies. These third party agents collect your personal information to facilitate your service request and the We works with a number of partners or affiliates, where we provide marketing services for these companies. These third party agents collect your personal information to facilitate your service request and the
information submitted here is governed by their privacy policy. information submitted here is governed by their privacy policy.
</p> </p>
<p class="font-bold mb-4 mt-6">Masking Policy</p> <p class="font-bold mb-4 mt-6">Masking Policy</p>
<p class="mb-4"> <p class="mb-4">
In some cases, where the third party agent collects your information, the affiliate portal may appear within a BizMatch.net frame. It is presented as a BizMatch.net page for a streamlined user interface In some cases, where the third party agent collects your information, the affiliate portal may appear within a BizMatch.net frame. It is presented as a BizMatch.net page for a streamlined user interface
however the data collected on such pages is governed by the third party agent's privacy policy. however the data collected on such pages is governed by the third party agent's privacy policy.
</p> </p>
<p class="font-bold mb-4 mt-6">Legal Disclosure</p> <p class="font-bold mb-4 mt-6">Legal Disclosure</p>
<p class="mb-4"> <p class="mb-4">
In certain circumstances, we may be legally required to disclose information collected on the site to law enforcement, government agencies or other third parties. We reserve the right to disclose information In certain circumstances, we may be legally required to disclose information collected on the site to law enforcement, government agencies or other third parties. We reserve the right to disclose information
to our service providers and to law enforcement or government agencies where a formal request such as in response to a court order, subpoena or judicial proceeding is made. Where we believe in good faith that to our service providers and to law enforcement or government agencies where a formal request such as in response to a court order, subpoena or judicial proceeding is made. Where we believe in good faith that
disclosure of information is necessary to prevent imminent physical or financial harm, or loss, or in protecting against illegal activity on the site, we reserve to disclose information. disclosure of information is necessary to prevent imminent physical or financial harm, or loss, or in protecting against illegal activity on the site, we reserve to disclose information.
</p> </p>
<p class="mb-4"> <p class="mb-4">
Should the company undergo the merger, acquisition or sale of some or all of its assets, your personal information may likely be a part of the transferred assets. In such an event, your personal information Should the company undergo the merger, acquisition or sale of some or all of its assets, your personal information may likely be a part of the transferred assets. In such an event, your personal information
on the site, would be governed by this privacy statement; any changes to the privacy practices governing your information as a result of transfer would be relayed to you by means of a prominent notice on the on the site, would be governed by this privacy statement; any changes to the privacy practices governing your information as a result of transfer would be relayed to you by means of a prominent notice on the
Site, or by email. Site, or by email.
</p> </p>
<p class="font-bold mb-4 mt-6">Using information from BizMatch.net website</p> <p class="font-bold mb-4 mt-6">Using information from BizMatch.net website</p>
<p class="mb-4"> <p class="mb-4">
In certain cases, (where you are receiving contact details of buyers interested in your business opportunity or a business opportunity you represent), you must comply with data protection laws, and give other In certain cases, (where you are receiving contact details of buyers interested in your business opportunity or a business opportunity you represent), you must comply with data protection laws, and give other
users a chance to remove themselves from your database and a chance to review what information you have collected about them. users a chance to remove themselves from your database and a chance to review what information you have collected about them.
</p> </p>
<p class="font-bold mb-4 mt-6">You agree to use BizMatch.net user information only for:</p> <p class="font-bold mb-4 mt-6">You agree to use BizMatch.net user information only for:</p>
<p class="mb-4"> <p class="mb-4">
BizMatch.net transaction-related purposes that are not unsolicited commercial messages;<br /> BizMatch.net transaction-related purposes that are not unsolicited commercial messages;<br />
using services offered through BizMatch.net, or<br /> using services offered through BizMatch.net, or<br />
other purposes that a user expressly chooses. other purposes that a user expressly chooses.
</p> </p>
<p class="font-bold mb-4 mt-6">Marketing</p> <p class="font-bold mb-4 mt-6">Marketing</p>
<p class="mb-4"> <p class="mb-4">
We do not sell or rent your personal information to third parties for their marketing purposes without your explicit consent. Where you explicitly express your consent at the point of collection to receive We do not sell or rent your personal information to third parties for their marketing purposes without your explicit consent. Where you explicitly express your consent at the point of collection to receive
offers from third party partners or affiliates, we will communicate to you on their behalf. We will not pass your information on. offers from third party partners or affiliates, we will communicate to you on their behalf. We will not pass your information on.
</p> </p>
<p class="mb-4"> <p class="mb-4">
You will receive email marketing communications from us throughout the duration of your relationship with our websites. If you do not wish to receive marketing communications from us you may unsubscribe and / You will receive email marketing communications from us throughout the duration of your relationship with our websites. If you do not wish to receive marketing communications from us you may unsubscribe and /
or change your preferences at any time by following instructions included within a communication or emailing Customer Services. or change your preferences at any time by following instructions included within a communication or emailing Customer Services.
</p> </p>
<p class="mb-4">If you have an account with one of our websites you can also log in and click the email preferences link to unsubscribe and / or change your preferences.</p> <p class="mb-4">If you have an account with one of our websites you can also log in and click the email preferences link to unsubscribe and / or change your preferences.</p>
<p class="mb-4"> <p class="mb-4">
Please note that we reserve the right to send all website users notifications and administrative emails where necessary which are considered a part of the service. Given that these messages aren't promotional Please note that we reserve the right to send all website users notifications and administrative emails where necessary which are considered a part of the service. Given that these messages aren't promotional
in nature, you will be unable to opt-out of them. in nature, you will be unable to opt-out of them.
</p> </p>
<p class="font-bold mb-4 mt-6">Cookies</p> <p class="font-bold mb-4 mt-6">Cookies</p>
<p class="mb-4"> <p class="mb-4">
A cookie is a small text file written to your hard drive that contains information about you. Cookies do not contain any personal information about users. Once you close your browser or log out of the A cookie is a small text file written to your hard drive that contains information about you. Cookies do not contain any personal information about users. Once you close your browser or log out of the
website, the cookie simply terminates. We use cookies so that we can personalise your experience of our websites. website, the cookie simply terminates. We use cookies so that we can personalise your experience of our websites.
</p> </p>
<p class="mb-4"> <p class="mb-4">
If you set up your browser to reject the cookie, you may still use the website however; doing so may interfere with your use of some aspects of our websites. Some of our business partners use cookies on our If you set up your browser to reject the cookie, you may still use the website however; doing so may interfere with your use of some aspects of our websites. Some of our business partners use cookies on our
site (for example, advertisers). We have no access to or control over these cookies. site (for example, advertisers). We have no access to or control over these cookies.
</p> </p>
<p class="mb-4">For more information about how BizMatch.net uses cookies please read our Cookie Policy.</p> <p class="mb-4">For more information about how BizMatch.net uses cookies please read our Cookie Policy.</p>
<p class="font-bold mb-4 mt-6">Spam, spyware or spoofing</p> <p class="font-bold mb-4 mt-6">Spam, spyware or spoofing</p>
<p class="mb-4"> <p class="mb-4">
We and our users do not tolerate spam. Make sure to set your email preferences so we can communicate with you, as you prefer. Please add us to your safe senders list. To report spam or spoof emails, please We and our users do not tolerate spam. Make sure to set your email preferences so we can communicate with you, as you prefer. Please add us to your safe senders list. To report spam or spoof emails, please
contact us using the contact information provided in the Contact Us section of this privacy statement. contact us using the contact information provided in the Contact Us section of this privacy statement.
</p> </p>
<p class="mb-4"> <p class="mb-4">
You may not use our communication tools to send spam or otherwise send content that would breach our Terms and Conditions. We automatically scan and may manually filter messages to check for spam, viruses, You may not use our communication tools to send spam or otherwise send content that would breach our Terms and Conditions. We automatically scan and may manually filter messages to check for spam, viruses,
phishing attacks and other malicious activity or illegal or prohibited content. We may also store these messages for back up purposes only. phishing attacks and other malicious activity or illegal or prohibited content. We may also store these messages for back up purposes only.
</p> </p>
<p class="mb-4"> <p class="mb-4">
If you send an email to an email address that is not registered in our community, we do not permanently store that email or use that email address for any marketing purpose. We do not rent or sell these email If you send an email to an email address that is not registered in our community, we do not permanently store that email or use that email address for any marketing purpose. We do not rent or sell these email
addresses. addresses.
</p> </p>
<p class="font-bold mb-4 mt-6">Account protection</p> <p class="font-bold mb-4 mt-6">Account protection</p>
<p class="mb-4"> <p class="mb-4">
Your password is the key to your account. Make sure this is stored safely. Use unique numbers, letters and special characters, and do not disclose your password to anyone. If you do share your password or Your password is the key to your account. Make sure this is stored safely. Use unique numbers, letters and special characters, and do not disclose your password to anyone. If you do share your password or
your personal information with others, remember that you are responsible for all actions taken in the name of your account. If you lose control of your password, you may lose substantial control over your your personal information with others, remember that you are responsible for all actions taken in the name of your account. If you lose control of your password, you may lose substantial control over your
personal information and may be subject to legally binding actions taken on your behalf. Therefore, if your password has been compromised for any reason, you should immediately notify us and change your personal information and may be subject to legally binding actions taken on your behalf. Therefore, if your password has been compromised for any reason, you should immediately notify us and change your
password. password.
</p> </p>
<p class="font-bold mb-4 mt-6">Accessing, reviewing and changing your personal information</p> <p class="font-bold mb-4 mt-6">Accessing, reviewing and changing your personal information</p>
<p class="mb-4">You can view and amend your personal information at any time by logging in to your account online. You must promptly update your personal information if it changes or is inaccurate.</p> <p class="mb-4">You can view and amend your personal information at any time by logging in to your account online. You must promptly update your personal information if it changes or is inaccurate.</p>
<p class="mb-4">If at any time you wish to close your account, please contact Customer Services and instruct us to do so. We will process your request as soon as we can.</p> <p class="mb-4">If at any time you wish to close your account, please contact Customer Services and instruct us to do so. We will process your request as soon as we can.</p>
<p class="mb-4">You may also contact us at any time to find out what information we hold about you, what we do with it and ask us to update it for you.</p> <p class="mb-4">You may also contact us at any time to find out what information we hold about you, what we do with it and ask us to update it for you.</p>
<p class="mb-4"> <p class="mb-4">
We do retain personal information from closed accounts to comply with law, prevent fraud, collect any fees owed, resolve disputes, troubleshoot problems, assist with any investigations, enforce our Terms and We do retain personal information from closed accounts to comply with law, prevent fraud, collect any fees owed, resolve disputes, troubleshoot problems, assist with any investigations, enforce our Terms and
Conditions, and take other actions otherwise permitted by law. Conditions, and take other actions otherwise permitted by law.
</p> </p>
<p class="font-bold mb-4 mt-6">Security</p> <p class="font-bold mb-4 mt-6">Security</p>
<p class="mb-4"> <p class="mb-4">
Your information is stored on our servers located in the USA. We treat data as an asset that must be protected and use a variety of tools (encryption, passwords, physical security, etc.) to protect your Your information is stored on our servers located in the USA. We treat data as an asset that must be protected and use a variety of tools (encryption, passwords, physical security, etc.) to protect your
personal information against unauthorized access and disclosure. However, no method of security is 100% effective and while we take every measure to protect your personal information, we make no guarantees of personal information against unauthorized access and disclosure. However, no method of security is 100% effective and while we take every measure to protect your personal information, we make no guarantees of
its absolute security. its absolute security.
</p> </p>
<p class="mb-4">We employ the use of SSL encryption during the transmission of sensitive data across our websites.</p> <p class="mb-4">We employ the use of SSL encryption during the transmission of sensitive data across our websites.</p>
<p class="font-bold mb-4 mt-6">Third parties</p> <p class="font-bold mb-4 mt-6">Third parties</p>
<p class="mb-4"> <p class="mb-4">
Except as otherwise expressly included in this Privacy Policy, this document addresses only the use and disclosure of information we collect from you. If you disclose your information to others, whether they Except as otherwise expressly included in this Privacy Policy, this document addresses only the use and disclosure of information we collect from you. If you disclose your information to others, whether they
are buyers or sellers on our websites or other sites throughout the internet, different rules may apply to their use or disclosure of the information you disclose to them. Dynamis does not control the privacy are buyers or sellers on our websites or other sites throughout the internet, different rules may apply to their use or disclosure of the information you disclose to them. Dynamis does not control the privacy
policies of third parties, and you are subject to the privacy policies of those third parties where applicable. policies of third parties, and you are subject to the privacy policies of those third parties where applicable.
</p> </p>
<p class="mb-4">We encourage you to ask questions before you disclose your personal information to others.</p> <p class="mb-4">We encourage you to ask questions before you disclose your personal information to others.</p>
<p class="font-bold mb-4 mt-6">General</p> <p class="font-bold mb-4 mt-6">General</p>
<p class="mb-4"> <p class="mb-4">
We may change this Privacy Policy from time to time as we add new products and applications, as we improve our current offerings, and as technologies and laws change. You can determine when this Privacy We may change this Privacy Policy from time to time as we add new products and applications, as we improve our current offerings, and as technologies and laws change. You can determine when this Privacy
Policy was last revised by referring to the "Last Updated" legend at the top of this page. Policy was last revised by referring to the "Last Updated" legend at the top of this page.
</p> </p>
<p class="mb-4"> <p class="mb-4">
Any changes will become effective upon our posting of the revised Privacy Policy on our affected websites. We will provide notice to you if these changes are material and, where required by applicable law, we Any changes will become effective upon our posting of the revised Privacy Policy on our affected websites. We will provide notice to you if these changes are material and, where required by applicable law, we
will obtain your consent. This notice may be provided by email, by posting notice of the changes on our affected websites or by other means, consistent with applicable laws. will obtain your consent. This notice may be provided by email, by posting notice of the changes on our affected websites or by other means, consistent with applicable laws.
</p> </p>
<p class="font-bold mb-4 mt-6">Contact Us</p> <p class="font-bold mb-4 mt-6">Contact Us</p>
<p class="mb-4"> <p class="mb-4">
If you have any questions or comments about our privacy policy, and you can't find the answer to your question on our help pages, please contact us using this form or email support&#64;bizmatch.net, or write If you have any questions or comments about our privacy policy, and you can't find the answer to your question on our help pages, please contact us using this form or email support&#64;bizmatch.net, or write
to us at BizMatch, 715 S. Tanahua, Corpus Christi, TX 78401.) to us at BizMatch, 715 S. Tanahua, Corpus Christi, TX 78401.)
</p> </p>
</div> </div>
</section> </section>
</article> </article>
</section> </section>
</div> </div>
</div> </div>

View File

@@ -7,7 +7,7 @@
> >
<i class="fas fa-arrow-left text-lg"></i> <i class="fas fa-arrow-left text-lg"></i>
</button> </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"> <section id="content" role="main">
<article class="post page"> <article class="post page">

View File

@@ -1,141 +1,162 @@
<div class="flex flex-col md:flex-row"> <div class="flex flex-col md:flex-row">
<!-- Filter Panel for Desktop --> <!-- Filter Panel for Desktop -->
<div class="hidden md:block w-full md:w-1/4 h-full bg-white shadow-lg p-6 overflow-y-auto z-10"> <div class="hidden md:block w-full md:w-1/4 h-full bg-white shadow-lg p-6 overflow-y-auto z-10">
<app-search-modal-broker [isModal]="false"></app-search-modal-broker> <app-search-modal-broker [isModal]="false"></app-search-modal-broker>
</div> </div>
<!-- Main Content --> <!-- Main Content -->
<div class="w-full p-4"> <div class="w-full p-4">
<div class="container mx-auto"> <div class="container mx-auto">
<!-- Breadcrumbs --> <!-- Breadcrumbs -->
<div class="mb-4"> <div class="mb-4">
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs> <app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
</div> </div>
<!-- SEO-optimized heading --> <!-- 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> <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 <p class="text-lg text-neutral-600">Connect with licensed business brokers, CPAs, attorneys, and other
professionals across the United States.</p> 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>
<!-- Mobile Filter Button --> </div>
<div class="md:hidden mb-4"> </div>
<button (click)="openFilterModal()"
class="w-full bg-primary-600 text-white py-3 px-4 rounded-lg flex items-center justify-center"> <!-- Mobile Filter Button -->
<i class="fas fa-filter mr-2"></i> <div class="md:hidden mb-4">
Filter Results <button (click)="openFilterModal()"
</button> class="w-full bg-primary-600 text-white py-3 px-4 rounded-lg flex items-center justify-center">
</div> <i class="fas fa-filter mr-2"></i>
Filter Results
@if(users?.length>0){ </button>
<h2 class="text-2xl font-semibold text-neutral-800 mb-4">Professional Listings</h2> </div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Professional Cards --> @if(users?.length>0){
@for (user of users; track user) { <h2 class="text-2xl font-semibold text-neutral-800 mb-4">Professional Listings</h2>
<div <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
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]"> <!-- Professional Cards -->
<div class="flex items-start space-x-4"> @for (user of users; track user) {
@if(user.hasProfile){ <div
<img src="{{ env.imageBaseUrl }}/pictures/profile/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}" 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">
[alt]="altText.generateBrokerProfileAlt(user)" class="rounded-md w-20 h-26 object-cover" width="80" <!-- Quick Actions Overlay -->
height="104" /> <div
} @else { class="absolute top-4 right-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-20">
<img src="/assets/images/person_placeholder.jpg" alt="Default business broker placeholder profile photo" @if(currentUser) {
class="rounded-md w-20 h-26 object-cover" width="80" height="104" /> <button type="button" class="bg-white rounded-full p-2 shadow-lg transition-colors"
} [class.bg-red-50]="isFavorite(user)"
<div class="flex-1"> [title]="isFavorite(user) ? 'Remove from favorites' : 'Save to favorites'"
<p class="text-sm text-neutral-800 mb-2">{{ user.description }}</p> (click)="toggleFavorite($event, user)">
<h3 class="text-lg font-semibold"> <i
{{ user.firstname }} {{ user.lastname }}<span [class]="isFavorite(user) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i>
class="bg-neutral-200 text-neutral-700 text-xs font-semibold px-2 py-1 rounded ml-4">{{ </button>
user.location?.name }} - {{ user.location?.state }}</span> }
</h3> <button type="button" class="bg-white rounded-full p-2 shadow-lg hover:bg-blue-50 transition-colors"
<div class="flex items-center space-x-2 mt-2"> title="Share professional" (click)="shareProfessional($event, user)">
<app-customer-sub-type [customerSubType]="user.customerSubType"></app-customer-sub-type> <i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i>
<p class="text-sm text-neutral-600">{{ user.companyName }}</p> </button>
</div> </div>
<div class="flex items-center justify-between my-2"></div> <div class="flex items-start space-x-4">
</div> @if(user.hasProfile){
</div> <img src="{{ env.imageBaseUrl }}/pictures/profile/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}"
<div class="mt-4 flex justify-between items-center"> [alt]="altText.generateBrokerProfileAlt(user)" class="rounded-md w-20 h-26 object-cover" width="80"
@if(user.hasCompanyLogo){ height="104" />
<img src="{{ env.imageBaseUrl }}/pictures/logo/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}" } @else {
[alt]="altText.generateCompanyLogoAlt(user.companyName, user.firstname + ' ' + user.lastname)" <img src="/assets/images/person_placeholder.jpg" alt="Default business broker placeholder profile photo"
class="w-8 h-10 object-contain" width="32" height="40" /> class="rounded-md w-20 h-26 object-cover" width="80" height="104" />
} @else { }
<img src="/assets/images/placeholder.png" alt="Default company logo placeholder" <div class="flex-1">
class="w-8 h-10 object-contain" width="32" height="40" /> <p class="text-sm text-neutral-800 mb-2">{{ user.description }}</p>
} <h3 class="text-lg font-semibold">
<button {{ user.firstname }} {{ user.lastname }}<span
class="bg-success-500 hover:bg-success-600 text-white font-medium py-2 px-4 rounded-full flex items-center" class="bg-neutral-200 text-neutral-700 text-xs font-semibold px-2 py-1 rounded ml-4">{{
[routerLink]="['/details-user', user.id]"> user.location?.name }} - {{ user.location?.state }}</span>
View Full profile </h3>
<i class="fas fa-arrow-right ml-2"></i> <div class="flex items-center space-x-2 mt-2">
</button> <app-customer-sub-type [customerSubType]="user.customerSubType"></app-customer-sub-type>
</div> <p class="text-sm text-neutral-600">{{ user.companyName }}</p>
</div> </div>
} <div class="flex items-center justify-between my-2"></div>
</div> </div>
} @else if (users?.length===0){ </div>
<!-- Empty State --> <div class="mt-4 flex justify-between items-center">
<div class="w-full flex items-center flex-wrap justify-center gap-10 py-12"> @if(user.hasCompanyLogo){
<div class="grid gap-4 w-60"> <img src="{{ env.imageBaseUrl }}/pictures/logo/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}"
<svg class="mx-auto" xmlns="http://www.w3.org/2000/svg" width="154" height="161" viewBox="0 0 154 161" [alt]="altText.generateCompanyLogoAlt(user.companyName, user.firstname + ' ' + user.lastname)"
fill="none"> class="w-8 h-10 object-contain" width="32" height="40" />
<path } @else {
d="M0.0616455 84.4268C0.0616455 42.0213 34.435 7.83765 76.6507 7.83765C118.803 7.83765 153.224 42.0055 153.224 84.4268C153.224 102.42 147.026 118.974 136.622 132.034C122.282 150.138 100.367 161 76.6507 161C52.7759 161 30.9882 150.059 16.6633 132.034C6.25961 118.974 0.0616455 102.42 0.0616455 84.4268Z" <img src="/assets/images/placeholder.png" alt="Default company logo placeholder"
fill="#EEF2FF" /> class="w-8 h-10 object-contain" width="32" height="40" />
<path }
d="M96.8189 0.632498L96.8189 0.632384L96.8083 0.630954C96.2034 0.549581 95.5931 0.5 94.9787 0.5H29.338C22.7112 0.5 17.3394 5.84455 17.3394 12.4473V142.715C17.3394 149.318 22.7112 154.662 29.338 154.662H123.948C130.591 154.662 135.946 149.317 135.946 142.715V38.9309C135.946 38.0244 135.847 37.1334 135.648 36.2586L135.648 36.2584C135.117 33.9309 133.874 31.7686 132.066 30.1333C132.066 30.1331 132.065 30.1329 132.065 30.1327L103.068 3.65203C103.068 3.6519 103.067 3.65177 103.067 3.65164C101.311 2.03526 99.1396 0.995552 96.8189 0.632498Z" <button
fill="white" stroke="#E5E7EB" /> class="bg-success-500 hover:bg-success-600 text-white font-medium py-2 px-4 rounded-full flex items-center"
<ellipse cx="80.0618" cy="81" rx="28.0342" ry="28.0342" fill="#EEF2FF" /> [routerLink]="['/details-user', user.id]">
<path View Full profile
d="M99.2393 61.3061L99.2391 61.3058C88.498 50.5808 71.1092 50.5804 60.3835 61.3061C49.6423 72.0316 49.6422 89.4361 60.3832 100.162C71.109 110.903 88.4982 110.903 99.2393 100.162C109.965 89.4363 109.965 72.0317 99.2393 61.3061ZM105.863 54.6832C120.249 69.0695 120.249 92.3985 105.863 106.785C91.4605 121.171 68.1468 121.171 53.7446 106.785C39.3582 92.3987 39.3582 69.0693 53.7446 54.683C68.1468 40.2965 91.4605 40.2966 105.863 54.6832Z" <i class="fas fa-arrow-right ml-2"></i>
stroke="#E5E7EB" /> </button>
<path </div>
d="M110.782 119.267L102.016 110.492C104.888 108.267 107.476 105.651 109.564 102.955L118.329 111.729L110.782 119.267Z" </div>
stroke="#E5E7EB" /> }
<path </div>
d="M139.122 125.781L139.122 125.78L123.313 109.988C123.313 109.987 123.313 109.987 123.312 109.986C121.996 108.653 119.849 108.657 118.521 109.985L118.871 110.335L118.521 109.985L109.047 119.459C107.731 120.775 107.735 122.918 109.044 124.247L109.047 124.249L124.858 140.06C128.789 143.992 135.191 143.992 139.122 140.06C143.069 136.113 143.069 129.728 139.122 125.781Z" } @else if (users?.length===0){
fill="#A5B4FC" stroke="#818CF8" /> <!-- Empty State -->
<path <div class="w-full flex items-center flex-wrap justify-center gap-10 py-12">
d="M83.185 87.2285C82.5387 87.2285 82.0027 86.6926 82.0027 86.0305C82.0027 83.3821 77.9987 83.3821 77.9987 86.0305C77.9987 86.6926 77.4627 87.2285 76.8006 87.2285C76.1543 87.2285 75.6183 86.6926 75.6183 86.0305C75.6183 80.2294 84.3831 80.2451 84.3831 86.0305C84.3831 86.6926 83.8471 87.2285 83.185 87.2285Z" <div class="grid gap-4 w-60">
fill="#4F46E5" /> <svg class="mx-auto" xmlns="http://www.w3.org/2000/svg" width="154" height="161" viewBox="0 0 154 161"
<path fill="none">
d="M93.3528 77.0926H88.403C87.7409 77.0926 87.2049 76.5567 87.2049 75.8946C87.2049 75.2483 87.7409 74.7123 88.403 74.7123H93.3528C94.0149 74.7123 94.5509 75.2483 94.5509 75.8946C94.5509 76.5567 94.0149 77.0926 93.3528 77.0926Z" <path
fill="#4F46E5" /> d="M0.0616455 84.4268C0.0616455 42.0213 34.435 7.83765 76.6507 7.83765C118.803 7.83765 153.224 42.0055 153.224 84.4268C153.224 102.42 147.026 118.974 136.622 132.034C122.282 150.138 100.367 161 76.6507 161C52.7759 161 30.9882 150.059 16.6633 132.034C6.25961 118.974 0.0616455 102.42 0.0616455 84.4268Z"
<path fill="#EEF2FF" />
d="M71.5987 77.0925H66.6488C65.9867 77.0925 65.4507 76.5565 65.4507 75.8945C65.4507 75.2481 65.9867 74.7122 66.6488 74.7122H71.5987C72.245 74.7122 72.781 75.2481 72.781 75.8945C72.781 76.5565 72.245 77.0925 71.5987 77.0925Z" <path
fill="#4F46E5" /> d="M96.8189 0.632498L96.8189 0.632384L96.8083 0.630954C96.2034 0.549581 95.5931 0.5 94.9787 0.5H29.338C22.7112 0.5 17.3394 5.84455 17.3394 12.4473V142.715C17.3394 149.318 22.7112 154.662 29.338 154.662H123.948C130.591 154.662 135.946 149.317 135.946 142.715V38.9309C135.946 38.0244 135.847 37.1334 135.648 36.2586L135.648 36.2584C135.117 33.9309 133.874 31.7686 132.066 30.1333C132.066 30.1331 132.065 30.1329 132.065 30.1327L103.068 3.65203C103.068 3.6519 103.067 3.65177 103.067 3.65164C101.311 2.03526 99.1396 0.995552 96.8189 0.632498Z"
<rect x="38.3522" y="21.5128" width="41.0256" height="2.73504" rx="1.36752" fill="#4F46E5" /> fill="white" stroke="#E5E7EB" />
<rect x="38.3522" y="133.65" width="54.7009" height="5.47009" rx="2.73504" fill="#A5B4FC" /> <ellipse cx="80.0618" cy="81" rx="28.0342" ry="28.0342" fill="#EEF2FF" />
<rect x="38.3522" y="29.7179" width="13.6752" height="2.73504" rx="1.36752" fill="#4F46E5" /> <path
<circle cx="56.13" cy="31.0854" r="1.36752" fill="#4F46E5" /> d="M99.2393 61.3061L99.2391 61.3058C88.498 50.5808 71.1092 50.5804 60.3835 61.3061C49.6423 72.0316 49.6422 89.4361 60.3832 100.162C71.109 110.903 88.4982 110.903 99.2393 100.162C109.965 89.4363 109.965 72.0317 99.2393 61.3061ZM105.863 54.6832C120.249 69.0695 120.249 92.3985 105.863 106.785C91.4605 121.171 68.1468 121.171 53.7446 106.785C39.3582 92.3987 39.3582 69.0693 53.7446 54.683C68.1468 40.2965 91.4605 40.2966 105.863 54.6832Z"
<circle cx="61.6001" cy="31.0854" r="1.36752" fill="#4F46E5" /> stroke="#E5E7EB" />
<circle cx="67.0702" cy="31.0854" r="1.36752" fill="#4F46E5" /> <path
</svg> d="M110.782 119.267L102.016 110.492C104.888 108.267 107.476 105.651 109.564 102.955L118.329 111.729L110.782 119.267Z"
<div> stroke="#E5E7EB" />
<h2 class="text-center text-black text-xl font-semibold leading-loose pb-2">There're no professionals here <path
</h2> d="M139.122 125.781L139.122 125.78L123.313 109.988C123.313 109.987 123.313 109.987 123.312 109.986C121.996 108.653 119.849 108.657 118.521 109.985L118.871 110.335L118.521 109.985L109.047 119.459C107.731 120.775 107.735 122.918 109.044 124.247L109.047 124.249L124.858 140.06C128.789 143.992 135.191 143.992 139.122 140.06C143.069 136.113 143.069 129.728 139.122 125.781Z"
<p class="text-center text-black text-base font-normal leading-relaxed pb-4">Try changing your filters to fill="#A5B4FC" stroke="#818CF8" />
<br />see professionals</p> <path
<div class="flex gap-3"> d="M83.185 87.2285C82.5387 87.2285 82.0027 86.6926 82.0027 86.0305C82.0027 83.3821 77.9987 83.3821 77.9987 86.0305C77.9987 86.6926 77.4627 87.2285 76.8006 87.2285C76.1543 87.2285 75.6183 86.6926 75.6183 86.0305C75.6183 80.2294 84.3831 80.2451 84.3831 86.0305C84.3831 86.6926 83.8471 87.2285 83.185 87.2285Z"
<button (click)="clearAllFilters()" fill="#4F46E5" />
class="w-full px-3 py-2 rounded-full border border-neutral-300 text-neutral-900 text-xs font-semibold leading-4">Clear <path
Filter</button> d="M93.3528 77.0926H88.403C87.7409 77.0926 87.2049 76.5567 87.2049 75.8946C87.2049 75.2483 87.7409 74.7123 88.403 74.7123H93.3528C94.0149 74.7123 94.5509 75.2483 94.5509 75.8946C94.5509 76.5567 94.0149 77.0926 93.3528 77.0926Z"
</div> fill="#4F46E5" />
</div> <path
</div> d="M71.5987 77.0925H66.6488C65.9867 77.0925 65.4507 76.5565 65.4507 75.8945C65.4507 75.2481 65.9867 74.7122 66.6488 74.7122H71.5987C72.245 74.7122 72.781 75.2481 72.781 75.8945C72.781 76.5565 72.245 77.0925 71.5987 77.0925Z"
</div> fill="#4F46E5" />
} <rect x="38.3522" y="21.5128" width="41.0256" height="2.73504" rx="1.36752" fill="#4F46E5" />
<rect x="38.3522" y="133.65" width="54.7009" height="5.47009" rx="2.73504" fill="#A5B4FC" />
<!-- Pagination --> <rect x="38.3522" y="29.7179" width="13.6752" height="2.73504" rx="1.36752" fill="#4F46E5" />
@if(pageCount>1){ <circle cx="56.13" cy="31.0854" r="1.36752" fill="#4F46E5" />
<div class="mt-8"> <circle cx="61.6001" cy="31.0854" r="1.36752" fill="#4F46E5" />
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator> <circle cx="67.0702" cy="31.0854" r="1.36752" fill="#4F46E5" />
</div> </svg>
} <div>
</div> <h2 class="text-center text-black text-xl font-semibold leading-loose pb-2">There're no professionals here
</div> </h2>
<p class="text-center text-black text-base font-normal leading-relaxed pb-4">Try changing your filters to
<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
Filter</button>
</div>
</div>
</div>
</div>
}
<!-- Pagination -->
@if(pageCount>1){
<div class="mt-8">
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator>
</div>
}
</div>
</div>
</div> </div>

View File

@@ -1,147 +1,244 @@
import { CommonModule, NgOptimizedImage } from '@angular/common'; 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 { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { UntilDestroy } from '@ngneat/until-destroy'; import { UntilDestroy } from '@ngneat/until-destroy';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
import { BusinessListing, SortByOptions, User } from '../../../../../../bizmatch-server/src/models/db.model'; import { BusinessListing, SortByOptions, User } from '../../../../../../bizmatch-server/src/models/db.model';
import { LISTINGS_PER_PAGE, ListingType, UserListingCriteria, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model'; import { LISTINGS_PER_PAGE, ListingType, UserListingCriteria, emailToDirName, KeycloakUser } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component'; import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
import { CustomerSubTypeComponent } from '../../../components/customer-sub-type/customer-sub-type.component'; import { CustomerSubTypeComponent } from '../../../components/customer-sub-type/customer-sub-type.component';
import { PaginatorComponent } from '../../../components/paginator/paginator.component'; import { PaginatorComponent } from '../../../components/paginator/paginator.component';
import { SearchModalBrokerComponent } from '../../../components/search-modal/search-modal-broker.component'; import { SearchModalBrokerComponent } from '../../../components/search-modal/search-modal-broker.component';
import { ModalService } from '../../../components/search-modal/modal.service'; import { ModalService } from '../../../components/search-modal/modal.service';
import { AltTextService } from '../../../services/alt-text.service'; import { AltTextService } from '../../../services/alt-text.service';
import { CriteriaChangeService } from '../../../services/criteria-change.service'; import { CriteriaChangeService } from '../../../services/criteria-change.service';
import { FilterStateService } from '../../../services/filter-state.service'; import { FilterStateService } from '../../../services/filter-state.service';
import { ImageService } from '../../../services/image.service'; import { ImageService } from '../../../services/image.service';
import { ListingsService } from '../../../services/listings.service'; import { ListingsService } from '../../../services/listings.service';
import { SearchService } from '../../../services/search.service'; import { SearchService } from '../../../services/search.service';
import { SelectOptionsService } from '../../../services/select-options.service'; import { SelectOptionsService } from '../../../services/select-options.service';
import { UserService } from '../../../services/user.service'; import { UserService } from '../../../services/user.service';
import { assignProperties, resetUserListingCriteria } from '../../../utils/utils'; import { AuthService } from '../../../services/auth.service';
@UntilDestroy() import { assignProperties, resetUserListingCriteria, map2User } from '../../../utils/utils';
@Component({ @UntilDestroy()
selector: 'app-broker-listings', @Component({
standalone: true, selector: 'app-broker-listings',
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, CustomerSubTypeComponent, BreadcrumbsComponent, SearchModalBrokerComponent], standalone: true,
templateUrl: './broker-listings.component.html', imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, CustomerSubTypeComponent, BreadcrumbsComponent, SearchModalBrokerComponent],
styleUrls: ['./broker-listings.component.scss', '../../pages.scss'], templateUrl: './broker-listings.component.html',
}) styleUrls: ['./broker-listings.component.scss', '../../pages.scss'],
export class BrokerListingsComponent implements OnInit, OnDestroy { changeDetection: ChangeDetectionStrategy.OnPush,
private destroy$ = new Subject<void>(); })
breadcrumbs: BreadcrumbItem[] = [ export class BrokerListingsComponent implements OnInit, OnDestroy {
{ label: 'Home', url: '/home', icon: 'fas fa-home' }, private destroy$ = new Subject<void>();
{ label: 'Professionals', url: '/brokerListings' } breadcrumbs: BreadcrumbItem[] = [
]; { label: 'Home', url: '/home', icon: 'fas fa-home' },
environment = environment; { label: 'Professionals', url: '/brokerListings' }
listings: Array<BusinessListing>; ];
users: Array<User>; environment = environment;
filteredListings: Array<ListingType>; listings: Array<BusinessListing>;
criteria: UserListingCriteria; users: Array<User>;
realEstateChecked: boolean; filteredListings: Array<ListingType>;
maxPrice: string; criteria: UserListingCriteria;
minPrice: string; realEstateChecked: boolean;
type: string; maxPrice: string;
statesSet = new Set(); minPrice: string;
state: string; type: string;
first: number = 0; statesSet = new Set();
rows: number = 12; state: string;
totalRecords: number = 0; first: number = 0;
ts = new Date().getTime(); rows: number = 12;
env = environment; totalRecords: number = 0;
public category: 'business' | 'commercialProperty' | 'professionals_brokers' | undefined; ts = new Date().getTime();
emailToDirName = emailToDirName; env = environment;
page = 1; public category: 'business' | 'commercialProperty' | 'professionals_brokers' | undefined;
pageCount = 1; emailToDirName = emailToDirName;
sortBy: SortByOptions = null; // Neu: Separate Property page = 1;
constructor( pageCount = 1;
public altText: AltTextService, sortBy: SortByOptions = null; // Neu: Separate Property
public selectOptions: SelectOptionsService, currentUser: KeycloakUser | null = null; // Current logged-in user
private listingsService: ListingsService, constructor(
private userService: UserService, public altText: AltTextService,
private activatedRoute: ActivatedRoute, public selectOptions: SelectOptionsService,
private router: Router, private listingsService: ListingsService,
private cdRef: ChangeDetectorRef, private userService: UserService,
private imageService: ImageService, private activatedRoute: ActivatedRoute,
private route: ActivatedRoute, private router: Router,
private searchService: SearchService, private cdRef: ChangeDetectorRef,
private modalService: ModalService, private imageService: ImageService,
private criteriaChangeService: CriteriaChangeService, private route: ActivatedRoute,
private filterStateService: FilterStateService, private searchService: SearchService,
) { private modalService: ModalService,
this.loadSortBy(); private criteriaChangeService: CriteriaChangeService,
} private filterStateService: FilterStateService,
private loadSortBy() { private authService: AuthService,
const storedSortBy = sessionStorage.getItem('professionalsSortBy'); ) {
this.sortBy = storedSortBy && storedSortBy !== 'null' ? (storedSortBy as SortByOptions) : null; this.loadSortBy();
} }
ngOnInit(): void { private loadSortBy() {
// Subscribe to FilterStateService for criteria changes const storedSortBy = sessionStorage.getItem('professionalsSortBy');
this.filterStateService this.sortBy = storedSortBy && storedSortBy !== 'null' ? (storedSortBy as SortByOptions) : null;
.getState$('brokerListings') }
.pipe(takeUntil(this.destroy$)) async ngOnInit(): Promise<void> {
.subscribe(state => { // Get current logged-in user
this.criteria = state.criteria as UserListingCriteria; const token = await this.authService.getToken();
this.sortBy = state.sortBy; this.currentUser = map2User(token);
this.search();
}); // Subscribe to FilterStateService for criteria changes
this.filterStateService
// Subscribe to SearchService for search triggers .getState$('brokerListings')
this.searchService.searchTrigger$ .pipe(takeUntil(this.destroy$))
.pipe(takeUntil(this.destroy$)) .subscribe(state => {
.subscribe(type => { this.criteria = state.criteria as UserListingCriteria;
if (type === 'brokerListings') { this.sortBy = state.sortBy;
this.search(); this.search();
} });
});
} // Subscribe to SearchService for search triggers
this.searchService.searchTrigger$
ngOnDestroy(): void { .pipe(takeUntil(this.destroy$))
this.destroy$.next(); .subscribe(type => {
this.destroy$.complete(); if (type === 'brokerListings') {
} this.search();
async search() { }
const usersReponse = await this.userService.search(this.criteria); });
this.users = usersReponse.results; }
this.totalRecords = usersReponse.totalCount;
this.pageCount = this.totalRecords % LISTINGS_PER_PAGE === 0 ? this.totalRecords / LISTINGS_PER_PAGE : Math.floor(this.totalRecords / LISTINGS_PER_PAGE) + 1; ngOnDestroy(): void {
this.page = this.criteria.page ? this.criteria.page : 1; this.destroy$.next();
this.cdRef.markForCheck(); this.destroy$.complete();
this.cdRef.detectChanges(); }
} async search() {
onPageChange(page: any) { const usersReponse = await this.userService.search(this.criteria);
this.criteria.start = (page - 1) * LISTINGS_PER_PAGE; this.users = usersReponse.results;
this.criteria.length = LISTINGS_PER_PAGE; this.totalRecords = usersReponse.totalCount;
this.criteria.page = page; this.pageCount = this.totalRecords % LISTINGS_PER_PAGE === 0 ? this.totalRecords / LISTINGS_PER_PAGE : Math.floor(this.totalRecords / LISTINGS_PER_PAGE) + 1;
this.search(); this.page = this.criteria.page ? this.criteria.page : 1;
} this.cdRef.markForCheck();
this.cdRef.detectChanges();
reset() { } }
onPageChange(page: any) {
// New methods for filter actions this.criteria.start = (page - 1) * LISTINGS_PER_PAGE;
clearAllFilters() { this.criteria.length = LISTINGS_PER_PAGE;
// Reset criteria to default values this.criteria.page = page;
resetUserListingCriteria(this.criteria); this.search();
}
// Reset pagination
this.criteria.page = 1; reset() { }
this.criteria.start = 0;
// New methods for filter actions
this.criteriaChangeService.notifyCriteriaChange(); clearAllFilters() {
// Reset criteria to default values
// Search with cleared filters resetUserListingCriteria(this.criteria);
this.searchService.search('brokerListings');
} // Reset pagination
this.criteria.page = 1;
async openFilterModal() { this.criteria.start = 0;
// Open the search modal with current criteria
const modalResult = await this.modalService.showModal(this.criteria); this.criteriaChangeService.notifyCriteriaChange();
if (modalResult.accepted) {
this.searchService.search('brokerListings'); // Search with cleared filters
} else { this.searchService.search('brokerListings');
this.criteria = assignProperties(this.criteria, modalResult.criteria); }
}
} async openFilterModal() {
} // Open the search modal with current criteria
const modalResult = await this.modalService.showModal(this.criteria);
if (modalResult.accepted) {
this.searchService.search('brokerListings');
} else {
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);
});
}
}

View File

@@ -1,259 +1,262 @@
<div class="flex flex-col md:flex-row"> <div class="flex flex-col md:flex-row">
<!-- Filter Panel for Desktop --> <!-- Filter Panel for Desktop -->
<div class="hidden md:block w-full md:w-1/4 h-full bg-white shadow-lg p-6 overflow-y-auto z-10"> <div class="hidden md:block w-full md:w-1/4 h-full bg-white shadow-lg p-6 overflow-y-auto z-10">
<app-search-modal [isModal]="false"></app-search-modal> <app-search-modal [isModal]="false"></app-search-modal>
</div> </div>
<!-- Main Content --> <!-- Main Content -->
<div class="w-full p-4"> <div class="w-full p-4">
<div class="container mx-auto"> <div class="container mx-auto">
<!-- Breadcrumbs --> <!-- Breadcrumbs -->
<div class="mb-4"> <div class="mb-4">
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs> <app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
</div> </div>
<!-- SEO-optimized heading --> <!-- 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">Businesses for Sale</h1> <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 United States. Browse <p class="text-lg text-neutral-600">Discover profitable business opportunities across the United States. Browse
verified listings from business owners and brokers.</p> verified listings from business owners and brokers.</p>
</div> <div class="mt-4 text-base text-neutral-700 max-w-4xl">
<p>BizMatch features thousands of businesses for sale across all industries and price ranges. Browse restaurants, retail stores, franchises, service businesses, e-commerce operations, and manufacturing companies. 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>
<!-- Loading Skeleton --> </div>
@if(isLoading) { </div>
<h2 class="text-2xl font-semibold text-neutral-800 mb-4">Loading Business Listings...</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> <!-- Loading Skeleton -->
@for (item of [1,2,3,4,5,6]; track item) { @if(isLoading) {
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg overflow-hidden"> <h2 class="text-2xl font-semibold text-neutral-800 mb-4">Loading Business Listings...</h2>
<div class="p-6 animate-pulse"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Category icon and text --> @for (item of [1,2,3,4,5,6]; track item) {
<div class="flex items-center mb-4"> <div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg overflow-hidden">
<div class="w-5 h-5 bg-neutral-200 rounded mr-2"></div> <div class="p-6 animate-pulse">
<div class="h-5 bg-neutral-200 rounded w-32"></div> <!-- Category icon and text -->
</div> <div class="flex items-center mb-4">
<!-- Title --> <div class="w-5 h-5 bg-neutral-200 rounded mr-2"></div>
<div class="h-7 bg-neutral-200 rounded w-3/4 mb-4"></div> <div class="h-5 bg-neutral-200 rounded w-32"></div>
<!-- Badges --> </div>
<div class="flex justify-between mb-4"> <!-- Title -->
<div class="h-6 bg-neutral-200 rounded-full w-20"></div> <div class="h-7 bg-neutral-200 rounded w-3/4 mb-4"></div>
<div class="h-6 bg-neutral-200 rounded-full w-16"></div> <!-- Badges -->
</div> <div class="flex justify-between mb-4">
<!-- Details --> <div class="h-6 bg-neutral-200 rounded-full w-20"></div>
<div class="space-y-2 mb-4"> <div class="h-6 bg-neutral-200 rounded-full w-16"></div>
<div class="h-4 bg-neutral-200 rounded w-full"></div> </div>
<div class="h-4 bg-neutral-200 rounded w-5/6"></div> <!-- Details -->
<div class="h-4 bg-neutral-200 rounded w-4/6"></div> <div class="space-y-2 mb-4">
<div class="h-4 bg-neutral-200 rounded w-3/4"></div> <div class="h-4 bg-neutral-200 rounded w-full"></div>
<div class="h-4 bg-neutral-200 rounded w-2/3"></div> <div class="h-4 bg-neutral-200 rounded w-5/6"></div>
</div> <div class="h-4 bg-neutral-200 rounded w-4/6"></div>
<!-- Button --> <div class="h-4 bg-neutral-200 rounded w-3/4"></div>
<div class="h-12 bg-neutral-200 rounded-full w-full mt-4"></div> <div class="h-4 bg-neutral-200 rounded w-2/3"></div>
</div> </div>
</div> <!-- Button -->
} <div class="h-12 bg-neutral-200 rounded-full w-full mt-4"></div>
</div> </div>
} @else if(listings?.length > 0) { </div>
<h2 class="text-2xl font-semibold text-neutral-800 mb-4">Available Business Listings</h2> }
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> </div>
@for (listing of listings; track listing.id) { } @else if(listings?.length > 0) {
<div <h2 class="text-2xl font-semibold text-neutral-800 mb-4">Available Business Listings</h2>
class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg overflow-hidden hover:shadow-2xl transition-all duration-300 hover:scale-[1.02] group"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<div class="p-6 flex flex-col h-full relative z-[0]"> @for (listing of listings; track listing.id) {
<!-- Quick Actions Overlay --> <div
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg overflow-hidden hover:shadow-2xl transition-all duration-300 hover:scale-[1.02] group">
class="absolute top-4 right-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-20"> <div class="p-6 flex flex-col h-full relative z-[0]">
@if(user) { <!-- Quick Actions Overlay -->
<button class="bg-white rounded-full p-2 shadow-lg transition-colors" <div
[class.bg-red-50]="isFavorite(listing)" class="absolute top-4 right-4 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-20">
[title]="isFavorite(listing) ? 'Remove from favorites' : 'Save to favorites'" @if(user) {
(click)="toggleFavorite($event, listing)"> <button class="bg-white rounded-full p-2 shadow-lg transition-colors"
<i [class.bg-red-50]="isFavorite(listing)"
[class]="isFavorite(listing) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i> [title]="isFavorite(listing) ? 'Remove from favorites' : 'Save to favorites'"
</button> (click)="toggleFavorite($event, listing)">
} <i
<button type="button" class="bg-white rounded-full p-2 shadow-lg hover:bg-blue-50 transition-colors" [class]="isFavorite(listing) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i>
title="Share listing" (click)="shareListing($event, listing)"> </button>
<i class="fas fa-share-alt text-blue-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"
</div> title="Share listing" (click)="shareListing($event, listing)">
<i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i>
<div class="flex items-center mb-4"> </button>
<i [class]="selectOptions.getIconAndTextColorType(listing.type)" class="mr-2 text-xl"></i> </div>
<span [class]="selectOptions.getTextColorType(listing.type)" class="font-bold text-lg">{{
selectOptions.getBusiness(listing.type) }}</span> <div class="flex items-center mb-4">
</div> <i [class]="selectOptions.getIconAndTextColorType(listing.type)" class="mr-2 text-xl"></i>
<h2 class="text-xl font-semibold mb-4"> <span [class]="selectOptions.getTextColorType(listing.type)" class="font-bold text-lg">{{
{{ listing.title }} selectOptions.getBusiness(listing.type) }}</span>
@if(listing.draft) { </div>
<span <h2 class="text-xl font-semibold mb-4">
class="bg-amber-100 text-amber-800 border border-amber-300 text-sm font-medium me-2 ml-2 px-2.5 py-0.5 rounded">Draft</span> {{ listing.title }}
} @if(listing.draft) {
</h2> <span
<div class="flex justify-between"> class="bg-amber-100 text-amber-800 border border-amber-300 text-sm font-medium me-2 ml-2 px-2.5 py-0.5 rounded">Draft</span>
<span }
class="w-fit inline-flex items-center justify-center px-2 py-1 mb-4 text-xs font-bold leading-none bg-neutral-200 text-neutral-700 rounded-full"> </h2>
{{ selectOptions.getState(listing.location.state) }} <div class="flex justify-between">
</span> <span
class="w-fit inline-flex items-center justify-center px-2 py-1 mb-4 text-xs font-bold leading-none bg-neutral-200 text-neutral-700 rounded-full">
@if (getListingBadge(listing); as badge) { {{ selectOptions.getState(listing.location.state) }}
<span </span>
class="mb-4 h-fit inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none rounded-full border"
[ngClass]="{ @if (getListingBadge(listing); as badge) {
'bg-emerald-100 text-emerald-800 border-emerald-300': badge === 'NEW', <span
'bg-teal-100 text-teal-800 border-teal-300': badge === 'UPDATED' class="mb-4 h-fit inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none rounded-full border"
}"> [ngClass]="{
{{ badge }} 'bg-emerald-100 text-emerald-800 border-emerald-300': badge === 'NEW',
</span> 'bg-teal-100 text-teal-800 border-teal-300': badge === 'UPDATED'
} }">
</div> {{ badge }}
</span>
<p class="text-base font-bold text-neutral-800 mb-2"> }
<strong>Asking price:</strong> </div>
<span class="text-success-600">
{{ listing?.price != null ? (listing.price | currency : 'USD' : 'symbol' : '1.0-0') : 'undisclosed' }} <p class="text-base font-bold text-neutral-800 mb-2">
</span> <strong>Asking price:</strong>
</p> <span class="text-success-600">
<p class="text-sm text-neutral-600 mb-2"> {{ listing?.price != null ? (listing.price | currency : 'USD' : 'symbol' : '1.0-0') : 'undisclosed' }}
<strong>Sales revenue:</strong> </span>
{{ listing?.salesRevenue != null ? (listing.salesRevenue | currency : 'USD' : 'symbol' : '1.0-0') : </p>
'undisclosed' }} <p class="text-sm text-neutral-600 mb-2">
</p> <strong>Sales revenue:</strong>
<p class="text-sm text-neutral-600 mb-2"> {{ listing?.salesRevenue != null ? (listing.salesRevenue | currency : 'USD' : 'symbol' : '1.0-0') :
<strong>Net profit:</strong> 'undisclosed' }}
{{ listing?.cashFlow != null ? (listing.cashFlow | currency : 'USD' : 'symbol' : '1.0-0') : 'undisclosed' </p>
}} <p class="text-sm text-neutral-600 mb-2">
</p> <strong>Net profit:</strong>
<p class="text-sm text-neutral-600 mb-2"> {{ listing?.cashFlow != null ? (listing.cashFlow | currency : 'USD' : 'symbol' : '1.0-0') : 'undisclosed'
<strong>Location:</strong> {{ listing.location.name ? listing.location.name : listing.location.county ? }}
listing.location.county : this.selectOptions.getState(listing.location.state) }} </p>
</p> <p class="text-sm text-neutral-600 mb-2">
<p class="text-sm text-neutral-600 mb-4"><strong>Years established:</strong> {{ listing.established }}</p> <strong>Location:</strong> {{ listing.location.name ? listing.location.name : listing.location.county ?
@if(listing.imageName) { listing.location.county : this.selectOptions.getState(listing.location.state) }}
<img [appLazyLoad]="env.imageBaseUrl + '/pictures/logo/' + listing.imageName + '.avif?_ts=' + ts" </p>
[alt]="altText.generateListingCardLogoAlt(listing)" <p class="text-sm text-neutral-600 mb-4"><strong>Years established:</strong> {{ listing.established }}</p>
class="absolute bottom-[80px] right-[20px] h-[45px] w-auto" width="100" height="45" /> @if(listing.imageName) {
} <img [appLazyLoad]="env.imageBaseUrl + '/pictures/logo/' + listing.imageName + '.avif?_ts=' + ts"
<div class="flex-grow"></div> [alt]="altText.generateListingCardLogoAlt(listing)"
<button class="absolute bottom-[80px] right-[20px] h-[45px] w-auto" width="100" height="45" />
class="bg-success-600 text-white px-5 py-3 rounded-full w-full flex items-center justify-center mt-4 transition-all duration-200 hover:bg-success-700 hover:shadow-lg group/btn" }
[routerLink]="['/business', listing.slug || listing.id]"> <div class="flex-grow"></div>
<span class="font-semibold">View Opportunity</span> <button
<i class="fas fa-arrow-right ml-2 group-hover/btn:translate-x-1 transition-transform duration-200"></i> class="bg-success-600 text-white px-5 py-3 rounded-full w-full flex items-center justify-center mt-4 transition-all duration-200 hover:bg-success-700 hover:shadow-lg group/btn"
</button> [routerLink]="['/business', listing.slug || listing.id]">
</div> <span class="font-semibold">View Opportunity</span>
</div> <i class="fas fa-arrow-right ml-2 group-hover/btn:translate-x-1 transition-transform duration-200"></i>
} </button>
</div> </div>
} @else if (listings?.length === 0) { </div>
<div class="w-full flex items-center flex-wrap justify-center gap-10 py-12"> }
<div class="grid gap-6 max-w-2xl w-full"> </div>
<svg class="mx-auto" xmlns="http://www.w3.org/2000/svg" width="154" height="161" viewBox="0 0 154 161" } @else if (listings?.length === 0) {
fill="none"> <div class="w-full flex items-center flex-wrap justify-center gap-10 py-12">
<path <div class="grid gap-6 max-w-2xl w-full">
d="M0.0616455 84.4268C0.0616455 42.0213 34.435 7.83765 76.6507 7.83765C118.803 7.83765 153.224 42.0055 153.224 84.4268C153.224 102.42 147.026 118.974 136.622 132.034C122.282 150.138 100.367 161 76.6507 161C52.7759 161 30.9882 150.059 16.6633 132.034C6.25961 118.974 0.0616455 102.42 0.0616455 84.4268Z" <svg class="mx-auto" xmlns="http://www.w3.org/2000/svg" width="154" height="161" viewBox="0 0 154 161"
fill="#EEF2FF" /> fill="none">
<path <path
d="M96.8189 0.632498L96.8189 0.632384L96.8083 0.630954C96.2034 0.549581 95.5931 0.5 94.9787 0.5H29.338C22.7112 0.5 17.3394 5.84455 17.3394 12.4473V142.715C17.3394 149.318 22.7112 154.662 29.338 154.662H123.948C130.591 154.662 135.946 149.317 135.946 142.715V38.9309C135.946 38.0244 135.847 37.1334 135.648 36.2586L135.648 36.2584C135.117 33.9309 133.874 31.7686 132.066 30.1333C132.066 30.1331 132.065 30.1329 132.065 30.1327L103.068 3.65203C103.068 3.6519 103.067 3.65177 103.067 3.65164C101.311 2.03526 99.1396 0.995552 96.8189 0.632498Z" d="M0.0616455 84.4268C0.0616455 42.0213 34.435 7.83765 76.6507 7.83765C118.803 7.83765 153.224 42.0055 153.224 84.4268C153.224 102.42 147.026 118.974 136.622 132.034C122.282 150.138 100.367 161 76.6507 161C52.7759 161 30.9882 150.059 16.6633 132.034C6.25961 118.974 0.0616455 102.42 0.0616455 84.4268Z"
fill="white" stroke="#E5E7EB" /> fill="#EEF2FF" />
<ellipse cx="80.0618" cy="81" rx="28.0342" ry="28.0342" fill="#EEF2FF" /> <path
<path d="M96.8189 0.632498L96.8189 0.632384L96.8083 0.630954C96.2034 0.549581 95.5931 0.5 94.9787 0.5H29.338C22.7112 0.5 17.3394 5.84455 17.3394 12.4473V142.715C17.3394 149.318 22.7112 154.662 29.338 154.662H123.948C130.591 154.662 135.946 149.317 135.946 142.715V38.9309C135.946 38.0244 135.847 37.1334 135.648 36.2586L135.648 36.2584C135.117 33.9309 133.874 31.7686 132.066 30.1333C132.066 30.1331 132.065 30.1329 132.065 30.1327L103.068 3.65203C103.068 3.6519 103.067 3.65177 103.067 3.65164C101.311 2.03526 99.1396 0.995552 96.8189 0.632498Z"
d="M99.2393 61.3061L99.2391 61.3058C88.498 50.5808 71.1092 50.5804 60.3835 61.3061C49.6423 72.0316 49.6422 89.4361 60.3832 100.162C71.109 110.903 88.4982 110.903 99.2393 100.162C109.965 89.4363 109.965 72.0317 99.2393 61.3061ZM105.863 54.6832C120.249 69.0695 120.249 92.3985 105.863 106.785C91.4605 121.171 68.1468 121.171 53.7446 106.785C39.3582 92.3987 39.3582 69.0693 53.7446 54.683C68.1468 40.2965 91.4605 40.2966 105.863 54.6832Z" fill="white" stroke="#E5E7EB" />
stroke="#E5E7EB" /> <ellipse cx="80.0618" cy="81" rx="28.0342" ry="28.0342" fill="#EEF2FF" />
<path <path
d="M110.782 119.267L102.016 110.492C104.888 108.267 107.476 105.651 109.564 102.955L118.329 111.729L110.782 119.267Z" d="M99.2393 61.3061L99.2391 61.3058C88.498 50.5808 71.1092 50.5804 60.3835 61.3061C49.6423 72.0316 49.6422 89.4361 60.3832 100.162C71.109 110.903 88.4982 110.903 99.2393 100.162C109.965 89.4363 109.965 72.0317 99.2393 61.3061ZM105.863 54.6832C120.249 69.0695 120.249 92.3985 105.863 106.785C91.4605 121.171 68.1468 121.171 53.7446 106.785C39.3582 92.3987 39.3582 69.0693 53.7446 54.683C68.1468 40.2965 91.4605 40.2966 105.863 54.6832Z"
stroke="#E5E7EB" /> stroke="#E5E7EB" />
<path <path
d="M139.122 125.781L139.122 125.78L123.313 109.988C123.313 109.987 123.313 109.987 123.312 109.986C121.996 108.653 119.849 108.657 118.521 109.985L118.871 110.335L118.521 109.985L109.047 119.459C107.731 120.775 107.735 122.918 109.044 124.247L109.047 124.249L124.858 140.06C128.789 143.992 135.191 143.992 139.122 140.06C143.069 136.113 143.069 129.728 139.122 125.781Z" d="M110.782 119.267L102.016 110.492C104.888 108.267 107.476 105.651 109.564 102.955L118.329 111.729L110.782 119.267Z"
fill="#A5B4FC" stroke="#818CF8" /> stroke="#E5E7EB" />
<path <path
d="M83.185 87.2285C82.5387 87.2285 82.0027 86.6926 82.0027 86.0305C82.0027 86.0305 82.0027 83.3821 77.9987 83.3821C77.9987 83.3821 77.9987 86.0305 77.9987 86.0305C77.9987 86.6926 77.4627 87.2285 76.8006 87.2285C76.1543 87.2285 75.6183 86.6926 75.6183 86.0305C75.6183 80.2294 84.3831 80.2451 84.3831 86.0305C84.3831 86.6926 83.8471 87.2285 83.185 87.2285Z" d="M139.122 125.781L139.122 125.78L123.313 109.988C123.313 109.987 123.313 109.987 123.312 109.986C121.996 108.653 119.849 108.657 118.521 109.985L118.871 110.335L118.521 109.985L109.047 119.459C107.731 120.775 107.735 122.918 109.044 124.247L109.047 124.249L124.858 140.06C128.789 143.992 135.191 143.992 139.122 140.06C143.069 136.113 143.069 129.728 139.122 125.781Z"
fill="#4F46E5" /> fill="#A5B4FC" stroke="#818CF8" />
<path <path
d="M93.3528 77.0926H88.403C87.7409 77.0926 87.2049 76.5567 87.2049 75.8946C87.2049 75.2483 87.7409 74.7123 88.403 74.7123H93.3528C94.0149 74.7123 94.5509 75.2483 94.5509 75.8946C94.5509 76.5567 94.0149 77.0926 93.3528 77.0926Z" d="M83.185 87.2285C82.5387 87.2285 82.0027 86.6926 82.0027 86.0305C82.0027 86.0305 82.0027 83.3821 77.9987 83.3821C77.9987 83.3821 77.9987 86.0305 77.9987 86.0305C77.9987 86.6926 77.4627 87.2285 76.8006 87.2285C76.1543 87.2285 75.6183 86.6926 75.6183 86.0305C75.6183 80.2294 84.3831 80.2451 84.3831 86.0305C84.3831 86.6926 83.8471 87.2285 83.185 87.2285Z"
fill="#4F46E5" /> fill="#4F46E5" />
<path <path
d="M71.5987 77.0925H66.6488C65.9867 77.0925 65.4507 76.5565 65.4507 75.8945C65.4507 75.2481 65.9867 74.7122 66.6488 74.7122H71.5987C72.245 74.7122 72.781 75.2481 72.781 75.8945C72.781 76.5565 72.245 77.0925 71.5987 77.0925Z" d="M93.3528 77.0926H88.403C87.7409 77.0926 87.2049 76.5567 87.2049 75.8946C87.2049 75.2483 87.7409 74.7123 88.403 74.7123H93.3528C94.0149 74.7123 94.5509 75.2483 94.5509 75.8946C94.5509 76.5567 94.0149 77.0926 93.3528 77.0926Z"
fill="#4F46E5" /> fill="#4F46E5" />
<rect x="38.3522" y="21.5128" width="41.0256" height="2.73504" rx="1.36752" fill="#4F46E5" /> <path
<rect x="38.3522" y="133.65" width="54.7009" height="5.47009" rx="2.73504" fill="#A5B4FC" /> d="M71.5987 77.0925H66.6488C65.9867 77.0925 65.4507 76.5565 65.4507 75.8945C65.4507 75.2481 65.9867 74.7122 66.6488 74.7122H71.5987C72.245 74.7122 72.781 75.2481 72.781 75.8945C72.781 76.5565 72.245 77.0925 71.5987 77.0925Z"
<rect x="38.3522" y="29.7179" width="13.6752" height="2.73504" rx="1.36752" fill="#4F46E5" /> fill="#4F46E5" />
<circle cx="56.13" cy="31.0854" r="1.36752" fill="#4F46E5" /> <rect x="38.3522" y="21.5128" width="41.0256" height="2.73504" rx="1.36752" fill="#4F46E5" />
<circle cx="61.6001" cy="31.0854" r="1.36752" fill="#4F46E5" /> <rect x="38.3522" y="133.65" width="54.7009" height="5.47009" rx="2.73504" fill="#A5B4FC" />
<circle cx="67.0702" cy="31.0854" r="1.36752" fill="#4F46E5" /> <rect x="38.3522" y="29.7179" width="13.6752" height="2.73504" rx="1.36752" fill="#4F46E5" />
</svg> <circle cx="56.13" cy="31.0854" r="1.36752" fill="#4F46E5" />
<div class="text-center"> <circle cx="61.6001" cy="31.0854" r="1.36752" fill="#4F46E5" />
<h2 class="text-black text-2xl font-semibold leading-loose pb-2">No listings found</h2> <circle cx="67.0702" cy="31.0854" r="1.36752" fill="#4F46E5" />
<p class="text-neutral-600 text-base font-normal leading-relaxed pb-6">We couldn't find any businesses </svg>
matching your criteria.<br />Try adjusting your filters or explore popular categories below.</p> <div class="text-center">
<h2 class="text-black text-2xl font-semibold leading-loose pb-2">No listings found</h2>
<!-- Action Buttons --> <p class="text-neutral-600 text-base font-normal leading-relaxed pb-6">We couldn't find any businesses
<div class="flex flex-col sm:flex-row gap-3 justify-center mb-8"> matching your criteria.<br />Try adjusting your filters or explore popular categories below.</p>
<button (click)="clearAllFilters()"
class="px-6 py-3 rounded-full bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700 transition-colors"> <!-- Action Buttons -->
<i class="fas fa-redo mr-2"></i>Clear All Filters <div class="flex flex-col sm:flex-row gap-3 justify-center mb-8">
</button> <button (click)="clearAllFilters()"
<button [routerLink]="['/home']" class="px-6 py-3 rounded-full bg-primary-600 text-white text-sm font-semibold hover:bg-primary-700 transition-colors">
class="px-6 py-3 rounded-full border-2 border-neutral-300 text-neutral-700 text-sm font-semibold hover:border-primary-600 hover:text-primary-600 transition-colors"> <i class="fas fa-redo mr-2"></i>Clear All Filters
<i class="fas fa-home mr-2"></i>Back to Home </button>
</button> <button [routerLink]="['/home']"
</div> class="px-6 py-3 rounded-full border-2 border-neutral-300 text-neutral-700 text-sm font-semibold hover:border-primary-600 hover:text-primary-600 transition-colors">
<i class="fas fa-home mr-2"></i>Back to Home
<!-- Popular Categories Suggestions --> </button>
<div class="mt-8 p-6 bg-neutral-50 rounded-lg"> </div>
<h3 class="text-lg font-semibold text-neutral-800 mb-4">
<i class="fas fa-fire text-orange-500 mr-2"></i>Popular Categories <!-- Popular Categories Suggestions -->
</h3> <div class="mt-8 p-6 bg-neutral-50 rounded-lg">
<div class="grid grid-cols-2 md:grid-cols-3 gap-3"> <h3 class="text-lg font-semibold text-neutral-800 mb-4">
<button (click)="filterByCategory('foodAndRestaurant')" <i class="fas fa-fire text-orange-500 mr-2"></i>Popular Categories
class="px-4 py-2 bg-white rounded-lg border border-neutral-200 hover:border-primary-500 hover:bg-primary-50 text-sm text-neutral-700 hover:text-primary-600 transition-all"> </h3>
<i class="fas fa-utensils mr-2"></i>Restaurants <div class="grid grid-cols-2 md:grid-cols-3 gap-3">
</button> <button (click)="filterByCategory('foodAndRestaurant')"
<button (click)="filterByCategory('retail')" class="px-4 py-2 bg-white rounded-lg border border-neutral-200 hover:border-primary-500 hover:bg-primary-50 text-sm text-neutral-700 hover:text-primary-600 transition-all">
class="px-4 py-2 bg-white rounded-lg border border-neutral-200 hover:border-primary-500 hover:bg-primary-50 text-sm text-neutral-700 hover:text-primary-600 transition-all"> <i class="fas fa-utensils mr-2"></i>Restaurants
<i class="fas fa-store mr-2"></i>Retail </button>
</button> <button (click)="filterByCategory('retail')"
<button (click)="filterByCategory('realEstate')" class="px-4 py-2 bg-white rounded-lg border border-neutral-200 hover:border-primary-500 hover:bg-primary-50 text-sm text-neutral-700 hover:text-primary-600 transition-all">
class="px-4 py-2 bg-white rounded-lg border border-neutral-200 hover:border-primary-500 hover:bg-primary-50 text-sm text-neutral-700 hover:text-primary-600 transition-all"> <i class="fas fa-store mr-2"></i>Retail
<i class="fas fa-building mr-2"></i>Real Estate </button>
</button> <button (click)="filterByCategory('realEstate')"
<button (click)="filterByCategory('service')" class="px-4 py-2 bg-white rounded-lg border border-neutral-200 hover:border-primary-500 hover:bg-primary-50 text-sm text-neutral-700 hover:text-primary-600 transition-all">
class="px-4 py-2 bg-white rounded-lg border border-neutral-200 hover:border-primary-500 hover:bg-primary-50 text-sm text-neutral-700 hover:text-primary-600 transition-all"> <i class="fas fa-building mr-2"></i>Real Estate
<i class="fas fa-cut mr-2"></i>Services </button>
</button> <button (click)="filterByCategory('service')"
<button (click)="filterByCategory('franchise')" class="px-4 py-2 bg-white rounded-lg border border-neutral-200 hover:border-primary-500 hover:bg-primary-50 text-sm text-neutral-700 hover:text-primary-600 transition-all">
class="px-4 py-2 bg-white rounded-lg border border-neutral-200 hover:border-primary-500 hover:bg-primary-50 text-sm text-neutral-700 hover:text-primary-600 transition-all"> <i class="fas fa-cut mr-2"></i>Services
<i class="fas fa-handshake mr-2"></i>Franchise </button>
</button> <button (click)="filterByCategory('franchise')"
<button (click)="filterByCategory('professional')" class="px-4 py-2 bg-white rounded-lg border border-neutral-200 hover:border-primary-500 hover:bg-primary-50 text-sm text-neutral-700 hover:text-primary-600 transition-all">
class="px-4 py-2 bg-white rounded-lg border border-neutral-200 hover:border-primary-500 hover:bg-primary-50 text-sm text-neutral-700 hover:text-primary-600 transition-all"> <i class="fas fa-handshake mr-2"></i>Franchise
<i class="fas fa-briefcase mr-2"></i>Professional </button>
</button> <button (click)="filterByCategory('professional')"
</div> class="px-4 py-2 bg-white rounded-lg border border-neutral-200 hover:border-primary-500 hover:bg-primary-50 text-sm text-neutral-700 hover:text-primary-600 transition-all">
</div> <i class="fas fa-briefcase mr-2"></i>Professional
</button>
<!-- Helpful Tips --> </div>
<div class="mt-6 p-4 bg-primary-50 border border-primary-100 rounded-lg text-left"> </div>
<h4 class="font-semibold text-primary-900 mb-2 flex items-center">
<i class="fas fa-lightbulb mr-2"></i>Search Tips <!-- Helpful Tips -->
</h4> <div class="mt-6 p-4 bg-primary-50 border border-primary-100 rounded-lg text-left">
<ul class="text-sm text-primary-800 space-y-1"> <h4 class="font-semibold text-primary-900 mb-2 flex items-center">
<li>• Try expanding your search radius</li> <i class="fas fa-lightbulb mr-2"></i>Search Tips
<li>• Consider adjusting your price range</li> </h4>
<li>• Browse all categories to discover opportunities</li> <ul class="text-sm text-primary-800 space-y-1">
</ul> <li>• Try expanding your search radius</li>
</div> <li>• Consider adjusting your price range</li>
</div> <li>• Browse all categories to discover opportunities</li>
</div> </ul>
</div> </div>
} </div>
</div> </div>
@if(pageCount > 1) { </div>
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator> }
} </div>
</div> @if(pageCount > 1) {
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator>
<!-- Filter Button for Mobile --> }
<button (click)="openFilterModal()" </div>
class="md:hidden fixed bottom-4 right-4 bg-primary-500 text-white p-3 rounded-full shadow-lg z-20"><i
class="fas fa-filter"></i> Filter</button> <!-- Filter Button for Mobile -->
<button (click)="openFilterModal()"
class="md:hidden fixed bottom-4 right-4 bg-primary-500 text-white p-3 rounded-full shadow-lg z-20"><i
class="fas fa-filter"></i> Filter</button>
</div> </div>

View File

@@ -1,330 +1,331 @@
import { CommonModule } from '@angular/common'; 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 { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { UntilDestroy } from '@ngneat/until-destroy'; import { UntilDestroy } from '@ngneat/until-destroy';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { BusinessListing, SortByOptions } from '../../../../../../bizmatch-server/src/models/db.model'; import { BusinessListing, SortByOptions } from '../../../../../../bizmatch-server/src/models/db.model';
import { BusinessListingCriteria, KeycloakUser, LISTINGS_PER_PAGE, ListingType, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model'; import { BusinessListingCriteria, KeycloakUser, LISTINGS_PER_PAGE, ListingType, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component'; import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
import { PaginatorComponent } from '../../../components/paginator/paginator.component'; import { PaginatorComponent } from '../../../components/paginator/paginator.component';
import { ModalService } from '../../../components/search-modal/modal.service'; import { ModalService } from '../../../components/search-modal/modal.service';
import { SearchModalComponent } from '../../../components/search-modal/search-modal.component'; import { SearchModalComponent } from '../../../components/search-modal/search-modal.component';
import { LazyLoadImageDirective } from '../../../directives/lazy-load-image.directive'; import { LazyLoadImageDirective } from '../../../directives/lazy-load-image.directive';
import { AltTextService } from '../../../services/alt-text.service'; import { AltTextService } from '../../../services/alt-text.service';
import { AuthService } from '../../../services/auth.service'; import { AuthService } from '../../../services/auth.service';
import { FilterStateService } from '../../../services/filter-state.service'; import { FilterStateService } from '../../../services/filter-state.service';
import { ImageService } from '../../../services/image.service'; import { ImageService } from '../../../services/image.service';
import { ListingsService } from '../../../services/listings.service'; import { ListingsService } from '../../../services/listings.service';
import { SearchService } from '../../../services/search.service'; import { SearchService } from '../../../services/search.service';
import { SelectOptionsService } from '../../../services/select-options.service'; import { SelectOptionsService } from '../../../services/select-options.service';
import { SeoService } from '../../../services/seo.service'; import { SeoService } from '../../../services/seo.service';
import { map2User } from '../../../utils/utils'; import { map2User } from '../../../utils/utils';
@UntilDestroy() @UntilDestroy()
@Component({ @Component({
selector: 'app-business-listings', selector: 'app-business-listings',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, SearchModalComponent, LazyLoadImageDirective, BreadcrumbsComponent], imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, SearchModalComponent, LazyLoadImageDirective, BreadcrumbsComponent],
templateUrl: './business-listings.component.html', templateUrl: './business-listings.component.html',
styleUrls: ['./business-listings.component.scss', '../../pages.scss'], styleUrls: ['./business-listings.component.scss', '../../pages.scss'],
}) changeDetection: ChangeDetectionStrategy.OnPush,
export class BusinessListingsComponent implements OnInit, OnDestroy { })
private destroy$ = new Subject<void>(); export class BusinessListingsComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
// Component properties
environment = environment; // Component properties
env = environment; environment = environment;
listings: Array<BusinessListing> = []; env = environment;
filteredListings: Array<ListingType> = []; listings: Array<BusinessListing> = [];
criteria: BusinessListingCriteria; filteredListings: Array<ListingType> = [];
sortBy: SortByOptions | null = null; criteria: BusinessListingCriteria;
sortBy: SortByOptions | null = null;
// Pagination
totalRecords = 0; // Pagination
page = 1; totalRecords = 0;
pageCount = 1; page = 1;
first = 0; pageCount = 1;
rows = LISTINGS_PER_PAGE; first = 0;
rows = LISTINGS_PER_PAGE;
// UI state
ts = new Date().getTime(); // UI state
emailToDirName = emailToDirName; ts = new Date().getTime();
isLoading = false; emailToDirName = emailToDirName;
isLoading = false;
// Breadcrumbs
breadcrumbs: BreadcrumbItem[] = [ // Breadcrumbs
{ label: 'Home', url: '/', icon: 'fas fa-home' }, breadcrumbs: BreadcrumbItem[] = [
{ label: 'Business Listings' } { label: 'Home', url: '/', icon: 'fas fa-home' },
]; { label: 'Business Listings' }
];
// User for favorites
user: KeycloakUser | null = null; // User for favorites
user: KeycloakUser | null = null;
constructor(
public altText: AltTextService, constructor(
public selectOptions: SelectOptionsService, public altText: AltTextService,
private listingsService: ListingsService, public selectOptions: SelectOptionsService,
private router: Router, private listingsService: ListingsService,
private cdRef: ChangeDetectorRef, private router: Router,
private imageService: ImageService, private cdRef: ChangeDetectorRef,
private searchService: SearchService, private imageService: ImageService,
private modalService: ModalService, private searchService: SearchService,
private filterStateService: FilterStateService, private modalService: ModalService,
private route: ActivatedRoute, private filterStateService: FilterStateService,
private seoService: SeoService, private route: ActivatedRoute,
private authService: AuthService, private seoService: SeoService,
) { } private authService: AuthService,
) { }
async ngOnInit(): Promise<void> {
// Load user for favorites functionality async ngOnInit(): Promise<void> {
const token = await this.authService.getToken(); // Load user for favorites functionality
this.user = map2User(token); const token = await this.authService.getToken();
this.user = map2User(token);
// Set SEO meta tags for business listings page
this.seoService.updateMetaTags({ // Set SEO meta tags for business listings page
title: 'Businesses for Sale - Find Profitable Business Opportunities | BizMatch', this.seoService.updateMetaTags({
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',
keywords: 'businesses for sale, buy a business, business opportunities, franchise for sale, restaurant for sale, retail business for sale, business broker listings', description: 'Browse thousands of businesses for sale including restaurants, franchises, and retail stores. Verified listings nationwide.',
type: 'website' keywords: 'businesses for sale, buy a business, business opportunities, franchise for sale, restaurant for sale, retail business for sale, business broker listings',
}); type: 'website'
});
// Subscribe to state changes
this.filterStateService // Subscribe to state changes
.getState$('businessListings') this.filterStateService
.pipe(takeUntil(this.destroy$)) .getState$('businessListings')
.subscribe(state => { .pipe(takeUntil(this.destroy$))
this.criteria = state.criteria; .subscribe(state => {
this.sortBy = state.sortBy; this.criteria = state.criteria;
// Automatically search when state changes this.sortBy = state.sortBy;
this.search(); // Automatically search when state changes
}); this.search();
});
// Subscribe to search triggers (if triggered from other components)
this.searchService.searchTrigger$.pipe(takeUntil(this.destroy$)).subscribe(type => { // Subscribe to search triggers (if triggered from other components)
if (type === 'businessListings') { this.searchService.searchTrigger$.pipe(takeUntil(this.destroy$)).subscribe(type => {
this.search(); if (type === 'businessListings') {
} this.search();
}); }
} });
}
async search(): Promise<void> {
try { async search(): Promise<void> {
// Show loading state try {
this.isLoading = true; // Show loading state
this.isLoading = true;
// Get current criteria from service
this.criteria = this.filterStateService.getCriteria('businessListings') as BusinessListingCriteria; // Get current criteria from service
this.criteria = this.filterStateService.getCriteria('businessListings') as BusinessListingCriteria;
// Add sortBy if available
const searchCriteria = { // Add sortBy if available
...this.criteria, const searchCriteria = {
sortBy: this.sortBy, ...this.criteria,
}; sortBy: this.sortBy,
};
// Perform search
const listingsResponse = await this.listingsService.getListings('business'); // Perform search
this.listings = listingsResponse.results; const listingsResponse = await this.listingsService.getListings('business');
this.totalRecords = listingsResponse.totalCount; this.listings = listingsResponse.results;
this.pageCount = Math.ceil(this.totalRecords / LISTINGS_PER_PAGE); this.totalRecords = listingsResponse.totalCount;
this.page = this.criteria.page || 1; this.pageCount = Math.ceil(this.totalRecords / LISTINGS_PER_PAGE);
this.page = this.criteria.page || 1;
// Hide loading state
this.isLoading = false; // Hide loading state
this.isLoading = false;
// Update pagination SEO links
this.updatePaginationSEO(); // Update pagination SEO links
this.updatePaginationSEO();
// Update view
this.cdRef.markForCheck(); // Update view
this.cdRef.detectChanges(); this.cdRef.markForCheck();
} catch (error) { this.cdRef.detectChanges();
console.error('Search error:', error); } catch (error) {
// Handle error appropriately console.error('Search error:', error);
this.listings = []; // Handle error appropriately
this.totalRecords = 0; this.listings = [];
this.isLoading = false; this.totalRecords = 0;
this.cdRef.markForCheck(); this.isLoading = false;
} this.cdRef.markForCheck();
} }
}
onPageChange(page: number): void {
// Update only pagination properties onPageChange(page: number): void {
this.filterStateService.updateCriteria('businessListings', { // Update only pagination properties
page: page, this.filterStateService.updateCriteria('businessListings', {
start: (page - 1) * LISTINGS_PER_PAGE, page: page,
length: LISTINGS_PER_PAGE, start: (page - 1) * LISTINGS_PER_PAGE,
}); length: LISTINGS_PER_PAGE,
// Search will be triggered automatically through state subscription });
} // Search will be triggered automatically through state subscription
}
clearAllFilters(): void {
// Reset criteria but keep sortBy clearAllFilters(): void {
this.filterStateService.clearFilters('businessListings'); // Reset criteria but keep sortBy
// Search will be triggered automatically through state subscription this.filterStateService.clearFilters('businessListings');
} // Search will be triggered automatically through state subscription
}
async openFilterModal(): Promise<void> {
// Open modal with current criteria async openFilterModal(): Promise<void> {
const currentCriteria = this.filterStateService.getCriteria('businessListings'); // Open modal with current criteria
const modalResult = await this.modalService.showModal(currentCriteria); const currentCriteria = this.filterStateService.getCriteria('businessListings');
const modalResult = await this.modalService.showModal(currentCriteria);
if (modalResult.accepted) {
// Modal accepted changes - state is updated by modal if (modalResult.accepted) {
// Search will be triggered automatically through state subscription // Modal accepted changes - state is updated by modal
} else { // Search will be triggered automatically through state subscription
// Modal was cancelled - no action needed } else {
} // Modal was cancelled - no action needed
} }
}
getListingPrice(listing: BusinessListing): string {
if (!listing.price) return 'Price on Request'; getListingPrice(listing: BusinessListing): string {
return `$${listing.price.toLocaleString()}`; if (!listing.price) return 'Price on Request';
} return `$${listing.price.toLocaleString()}`;
}
getListingLocation(listing: BusinessListing): string {
if (!listing.location) return 'Location not specified'; getListingLocation(listing: BusinessListing): string {
return `${listing.location.name}, ${listing.location.state}`; if (!listing.location) return 'Location not specified';
} return `${listing.location.name}, ${listing.location.state}`;
private isWithinDays(date: Date | string | undefined | null, days: number): boolean { }
if (!date) return false; private isWithinDays(date: Date | string | undefined | null, days: number): boolean {
return dayjs().diff(dayjs(date), 'day') < days; if (!date) return false;
} return dayjs().diff(dayjs(date), 'day') < days;
}
getListingBadge(listing: BusinessListing): 'NEW' | 'UPDATED' | null {
if (this.isWithinDays(listing.created, 14)) return 'NEW'; // Priorität getListingBadge(listing: BusinessListing): 'NEW' | 'UPDATED' | null {
if (this.isWithinDays(listing.updated, 14)) return 'UPDATED'; if (this.isWithinDays(listing.created, 14)) return 'NEW'; // Priorität
return null; if (this.isWithinDays(listing.updated, 14)) return 'UPDATED';
} return null;
navigateToDetails(listingId: string): void { }
this.router.navigate(['/details-business', listingId]); navigateToDetails(listingId: string): void {
} this.router.navigate(['/details-business', listingId]);
getDaysListed(listing: BusinessListing) { }
return dayjs().diff(listing.created, 'day'); getDaysListed(listing: BusinessListing) {
} return dayjs().diff(listing.created, 'day');
}
/**
* Filter by popular category /**
*/ * Filter by popular category
filterByCategory(category: string): void { */
this.filterStateService.updateCriteria('businessListings', { filterByCategory(category: string): void {
types: [category], this.filterStateService.updateCriteria('businessListings', {
page: 1, types: [category],
start: 0, page: 1,
length: LISTINGS_PER_PAGE, start: 0,
}); length: LISTINGS_PER_PAGE,
// Search will be triggered automatically through state subscription });
} // Search will be triggered automatically through state subscription
}
/**
* Check if listing is already in user's favorites /**
*/ * Check if listing is already in user's favorites
isFavorite(listing: BusinessListing): boolean { */
if (!this.user?.email || !listing.favoritesForUser) return false; isFavorite(listing: BusinessListing): boolean {
return listing.favoritesForUser.includes(this.user.email); if (!this.user?.email || !listing.favoritesForUser) return false;
} return listing.favoritesForUser.includes(this.user.email);
}
/**
* Toggle favorite status for a listing /**
*/ * Toggle favorite status for a listing
async toggleFavorite(event: Event, listing: BusinessListing): Promise<void> { */
event.stopPropagation(); async toggleFavorite(event: Event, listing: BusinessListing): Promise<void> {
event.preventDefault(); event.stopPropagation();
event.preventDefault();
if (!this.user?.email) {
// User not logged in - redirect to login or show message if (!this.user?.email) {
this.router.navigate(['/login']); // User not logged in - redirect to login or show message
return; this.router.navigate(['/login']);
} return;
}
try {
if (this.isFavorite(listing)) { try {
// Remove from favorites if (this.isFavorite(listing)) {
await this.listingsService.removeFavorite(listing.id, 'business'); // Remove from favorites
listing.favoritesForUser = listing.favoritesForUser.filter(email => email !== this.user!.email); await this.listingsService.removeFavorite(listing.id, 'business');
} else { listing.favoritesForUser = listing.favoritesForUser.filter(email => email !== this.user!.email);
// Add to favorites } else {
await this.listingsService.addToFavorites(listing.id, 'business'); // Add to favorites
if (!listing.favoritesForUser) { await this.listingsService.addToFavorites(listing.id, 'business');
listing.favoritesForUser = []; if (!listing.favoritesForUser) {
} listing.favoritesForUser = [];
listing.favoritesForUser.push(this.user.email); }
} listing.favoritesForUser.push(this.user.email);
this.cdRef.detectChanges(); }
} catch (error) { this.cdRef.detectChanges();
console.error('Error toggling favorite:', error); } catch (error) {
} console.error('Error toggling favorite:', error);
} }
}
/**
* Share a listing - opens native share dialog or copies to clipboard /**
*/ * Share a listing - opens native share dialog or copies to clipboard
async shareListing(event: Event, listing: BusinessListing): Promise<void> { */
event.stopPropagation(); async shareListing(event: Event, listing: BusinessListing): Promise<void> {
event.preventDefault(); event.stopPropagation();
event.preventDefault();
const url = `${window.location.origin}/business/${listing.slug || listing.id}`;
const title = listing.title || 'Business Listing'; const url = `${window.location.origin}/business/${listing.slug || listing.id}`;
const title = listing.title || 'Business Listing';
// Try native share API first (works on mobile and some desktop browsers)
if (navigator.share) { // Try native share API first (works on mobile and some desktop browsers)
try { if (navigator.share) {
await navigator.share({ try {
title: title, await navigator.share({
text: `Check out this business: ${title}`, title: title,
url: url, text: `Check out this business: ${title}`,
}); url: url,
} catch (err) { });
// User cancelled or share failed - fall back to clipboard } catch (err) {
this.copyToClipboard(url); // User cancelled or share failed - fall back to clipboard
} this.copyToClipboard(url);
} else { }
// Fallback: open Facebook share dialog } else {
const encodedUrl = encodeURIComponent(url); // Fallback: open Facebook share dialog
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, '_blank', 'width=600,height=400'); 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 /**
*/ * Copy URL to clipboard and show feedback
private copyToClipboard(url: string): void { */
navigator.clipboard.writeText(url).then(() => { private copyToClipboard(url: string): void {
// Could add a toast notification here navigator.clipboard.writeText(url).then(() => {
console.log('Link copied to clipboard!'); // Could add a toast notification here
}).catch(err => { console.log('Link copied to clipboard!');
console.error('Failed to copy link:', err); }).catch(err => {
}); console.error('Failed to copy link:', err);
} });
}
ngOnDestroy(): void {
this.destroy$.next(); ngOnDestroy(): void {
this.destroy$.complete(); this.destroy$.next();
// Clean up pagination links when leaving the page this.destroy$.complete();
this.seoService.clearPaginationLinks(); // Clean up pagination links when leaving the page
} this.seoService.clearPaginationLinks();
}
/**
* Update pagination SEO links (rel="next/prev") and CollectionPage schema /**
*/ * Update pagination SEO links (rel="next/prev") and CollectionPage schema
private updatePaginationSEO(): void { */
const baseUrl = `${this.seoService.getBaseUrl()}/businessListings`; private updatePaginationSEO(): void {
const baseUrl = `${this.seoService.getBaseUrl()}/businessListings`;
// Inject rel="next" and rel="prev" links
this.seoService.injectPaginationLinks(baseUrl, this.page, this.pageCount); // Inject rel="next" and rel="prev" links
this.seoService.injectPaginationLinks(baseUrl, this.page, this.pageCount);
// Inject CollectionPage schema for paginated results
const collectionSchema = this.seoService.generateCollectionPageSchema({ // Inject CollectionPage schema for paginated results
name: 'Businesses for Sale', const collectionSchema = this.seoService.generateCollectionPageSchema({
description: 'Browse thousands of businesses for sale across the United States. Find restaurants, franchises, retail stores, and more.', name: 'Businesses for Sale',
totalItems: this.totalRecords, description: 'Browse thousands of businesses for sale across the United States. Find restaurants, franchises, retail stores, and more.',
itemsPerPage: LISTINGS_PER_PAGE, totalItems: this.totalRecords,
currentPage: this.page, itemsPerPage: LISTINGS_PER_PAGE,
baseUrl: baseUrl currentPage: this.page,
}); baseUrl: baseUrl
this.seoService.injectStructuredData(collectionSchema); });
} this.seoService.injectStructuredData(collectionSchema);
} }
}

View File

@@ -1,137 +1,146 @@
<div class="flex flex-col md:flex-row"> <div class="flex flex-col md:flex-row">
<!-- Filter Panel for Desktop --> <!-- Filter Panel for Desktop -->
<div class="hidden md:block w-full md:w-1/4 h-full bg-white shadow-lg p-6 overflow-y-auto z-10"> <div class="hidden md:block w-full md:w-1/4 h-full bg-white shadow-lg p-6 overflow-y-auto z-10">
<app-search-modal-commercial [isModal]="false"></app-search-modal-commercial> <app-search-modal-commercial [isModal]="false"></app-search-modal-commercial>
</div> </div>
<!-- Main Content --> <!-- Main Content -->
<div class="w-full p-4"> <div class="w-full p-4">
<div class="container mx-auto"> <div class="container mx-auto">
<!-- Breadcrumbs --> <!-- Breadcrumbs -->
<div class="mb-4"> <div class="mb-4">
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs> <app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
</div> </div>
<!-- SEO-optimized heading --> <!-- 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">Commercial Properties for Sale</h1> <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">Find office buildings, retail spaces, warehouses, and industrial properties across the United States. Investment opportunities from verified sellers and commercial real estate brokers.</p>
</div> <div class="mt-4 text-base text-neutral-700 max-w-4xl">
<p>BizMatch showcases commercial real estate listings including office buildings, retail spaces, warehouses, and industrial properties for sale or lease. Browse investment properties across the United States with detailed information on square footage, zoning, pricing, and location. Our platform connects property buyers and investors with sellers and commercial real estate brokers. Find shopping centers, medical buildings, land parcels, and mixed-use developments in your target market.</p>
@if(listings?.length > 0) { </div>
<h2 class="text-2xl font-semibold text-neutral-800 mb-4">Available Commercial Property Listings</h2> </div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@for (listing of listings; track listing.id) { @if(listings?.length > 0) {
<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"> <h2 class="text-2xl font-semibold text-neutral-800 mb-4">Available Commercial Property Listings</h2>
<!-- Favorites Button --> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@if(user) { @for (listing of listings; track listing.id) {
<button <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">
class="absolute top-4 right-4 z-10 bg-white rounded-full p-2 shadow-lg transition-colors opacity-0 group-hover:opacity-100" <!-- Quick Actions Overlay -->
[class.bg-red-50]="isFavorite(listing)" <div class="absolute top-4 right-4 z-10 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
[class.opacity-100]="isFavorite(listing)" @if(user) {
[title]="isFavorite(listing) ? 'Remove from favorites' : 'Save to favorites'" <button
(click)="toggleFavorite($event, listing)"> class="bg-white rounded-full p-2 shadow-lg transition-colors"
<i [class]="isFavorite(listing) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i> [class.bg-red-50]="isFavorite(listing)"
</button> [class.opacity-100]="isFavorite(listing)"
} [title]="isFavorite(listing) ? 'Remove from favorites' : 'Save to favorites'"
@if (listing.imageOrder?.length>0){ (click)="toggleFavorite($event, listing)">
<img [appLazyLoad]="env.imageBaseUrl + '/pictures/property/' + listing.imagePath + '/' + listing.serialId + '/' + listing.imageOrder[0]" <i [class]="isFavorite(listing) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i>
[alt]="altText.generatePropertyListingAlt(listing)" </button>
class="w-full h-48 object-cover" }
width="400" <button type="button" class="bg-white rounded-full p-2 shadow-lg hover:bg-blue-50 transition-colors"
height="192" /> title="Share property" (click)="shareProperty($event, listing)">
} @else { <i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i>
<img [appLazyLoad]="'assets/images/placeholder_properties.jpg'" </button>
[alt]="'Commercial property placeholder - ' + listing.title" </div>
class="w-full h-48 object-cover" @if (listing.imageOrder?.length>0){
width="400" <img [appLazyLoad]="env.imageBaseUrl + '/pictures/property/' + listing.imagePath + '/' + listing.serialId + '/' + listing.imageOrder[0]"
height="192" /> [alt]="altText.generatePropertyListingAlt(listing)"
} class="w-full h-48 object-cover"
<div class="p-4 flex flex-col flex-grow"> width="400"
<span [class]="selectOptions.getTextColorTypeOfCommercial(listing.type)" class="text-sm font-semibold" height="192" />
><i [class]="selectOptions.getIconAndTextColorTypeOfCommercials(listing.type)" class="mr-1"></i> {{ selectOptions.getCommercialProperty(listing.type) }}</span } @else {
> <img [appLazyLoad]="'assets/images/placeholder_properties.jpg'"
<div class="flex items-center justify-between my-2"> [alt]="'Commercial property placeholder - ' + listing.title"
<span class="bg-neutral-200 text-neutral-700 text-xs font-semibold px-2 py-1 rounded">{{ selectOptions.getState(listing.location.state) }}</span> class="w-full h-48 object-cover"
<p class="text-sm text-neutral-600 mb-4"> width="400"
<strong>{{ getDaysListed(listing) }} days listed</strong> height="192" />
</p> }
</div> <div class="p-4 flex flex-col flex-grow">
<h3 class="text-lg font-semibold mb-2"> <span [class]="selectOptions.getTextColorTypeOfCommercial(listing.type)" class="text-sm font-semibold"
{{ listing.title }} ><i [class]="selectOptions.getIconAndTextColorTypeOfCommercials(listing.type)" class="mr-1"></i> {{ selectOptions.getCommercialProperty(listing.type) }}</span
@if(listing.draft){ >
<span class="bg-red-100 text-red-800 text-sm font-medium me-2 ml-2 px-2.5 py-0.5 rounded dark:bg-red-900 dark:text-red-300">Draft</span> <div class="flex items-center justify-between my-2">
} <span class="bg-neutral-200 text-neutral-700 text-xs font-semibold px-2 py-1 rounded">{{ selectOptions.getState(listing.location.state) }}</span>
</h3> <p class="text-sm text-neutral-600 mb-4">
<p class="text-neutral-600 mb-2">{{ listing.location.name ? listing.location.name : listing.location.county }}</p> <strong>{{ getDaysListed(listing) }} days listed</strong>
<p class="text-xl font-bold mb-4">{{ listing.price | currency : 'USD' : 'symbol' : '1.0-0' }}</p> </p>
<div class="flex-grow"></div> </div>
<button [routerLink]="['/commercial-property', listing.slug || listing.id]" class="bg-success-500 text-white px-4 py-2 rounded-full w-full hover:bg-success-600 transition duration-300 mt-auto"> <h3 class="text-lg font-semibold mb-2">
View Full Listing <i class="fas fa-arrow-right ml-1"></i> {{ listing.title }}
</button> @if(listing.draft){
</div> <span class="bg-red-100 text-red-800 text-sm font-medium me-2 ml-2 px-2.5 py-0.5 rounded dark:bg-red-900 dark:text-red-300">Draft</span>
</div> }
} </h3>
</div> <p class="text-neutral-600 mb-2">{{ listing.location.name ? listing.location.name : listing.location.county }}</p>
} @else if (listings?.length === 0){ <p class="text-xl font-bold mb-4">{{ listing.price | currency : 'USD' : 'symbol' : '1.0-0' }}</p>
<div class="w-full flex items-center flex-wrap justify-center gap-10"> <div class="flex-grow"></div>
<div class="grid gap-4 w-60"> <button [routerLink]="['/commercial-property', listing.slug || listing.id]" class="bg-success-500 text-white px-4 py-2 rounded-full w-full hover:bg-success-600 transition duration-300 mt-auto">
<svg class="mx-auto" xmlns="http://www.w3.org/2000/svg" width="154" height="161" viewBox="0 0 154 161" fill="none"> View Full Listing <i class="fas fa-arrow-right ml-1"></i>
<path </button>
d="M0.0616455 84.4268C0.0616455 42.0213 34.435 7.83765 76.6507 7.83765C118.803 7.83765 153.224 42.0055 153.224 84.4268C153.224 102.42 147.026 118.974 136.622 132.034C122.282 150.138 100.367 161 76.6507 161C52.7759 161 30.9882 150.059 16.6633 132.034C6.25961 118.974 0.0616455 102.42 0.0616455 84.4268Z" </div>
fill="#EEF2FF" </div>
/> }
<path </div>
d="M96.8189 0.632498L96.8189 0.632384L96.8083 0.630954C96.2034 0.549581 95.5931 0.5 94.9787 0.5H29.338C22.7112 0.5 17.3394 5.84455 17.3394 12.4473V142.715C17.3394 149.318 22.7112 154.662 29.338 154.662H123.948C130.591 154.662 135.946 149.317 135.946 142.715V38.9309C135.946 38.0244 135.847 37.1334 135.648 36.2586L135.648 36.2584C135.117 33.9309 133.874 31.7686 132.066 30.1333C132.066 30.1331 132.065 30.1329 132.065 30.1327L103.068 3.65203C103.068 3.6519 103.067 3.65177 103.067 3.65164C101.311 2.03526 99.1396 0.995552 96.8189 0.632498Z" } @else if (listings?.length === 0){
fill="white" <div class="w-full flex items-center flex-wrap justify-center gap-10">
stroke="#E5E7EB" <div class="grid gap-4 w-60">
/> <svg class="mx-auto" xmlns="http://www.w3.org/2000/svg" width="154" height="161" viewBox="0 0 154 161" fill="none">
<ellipse cx="80.0618" cy="81" rx="28.0342" ry="28.0342" fill="#EEF2FF" /> <path
<path d="M0.0616455 84.4268C0.0616455 42.0213 34.435 7.83765 76.6507 7.83765C118.803 7.83765 153.224 42.0055 153.224 84.4268C153.224 102.42 147.026 118.974 136.622 132.034C122.282 150.138 100.367 161 76.6507 161C52.7759 161 30.9882 150.059 16.6633 132.034C6.25961 118.974 0.0616455 102.42 0.0616455 84.4268Z"
d="M99.2393 61.3061L99.2391 61.3058C88.498 50.5808 71.1092 50.5804 60.3835 61.3061C49.6423 72.0316 49.6422 89.4361 60.3832 100.162C71.109 110.903 88.4982 110.903 99.2393 100.162C109.965 89.4363 109.965 72.0317 99.2393 61.3061ZM105.863 54.6832C120.249 69.0695 120.249 92.3985 105.863 106.785C91.4605 121.171 68.1468 121.171 53.7446 106.785C39.3582 92.3987 39.3582 69.0693 53.7446 54.683C68.1468 40.2965 91.4605 40.2966 105.863 54.6832Z" fill="#EEF2FF"
stroke="#E5E7EB" />
/> <path
<path d="M110.782 119.267L102.016 110.492C104.888 108.267 107.476 105.651 109.564 102.955L118.329 111.729L110.782 119.267Z" stroke="#E5E7EB" /> d="M96.8189 0.632498L96.8189 0.632384L96.8083 0.630954C96.2034 0.549581 95.5931 0.5 94.9787 0.5H29.338C22.7112 0.5 17.3394 5.84455 17.3394 12.4473V142.715C17.3394 149.318 22.7112 154.662 29.338 154.662H123.948C130.591 154.662 135.946 149.317 135.946 142.715V38.9309C135.946 38.0244 135.847 37.1334 135.648 36.2586L135.648 36.2584C135.117 33.9309 133.874 31.7686 132.066 30.1333C132.066 30.1331 132.065 30.1329 132.065 30.1327L103.068 3.65203C103.068 3.6519 103.067 3.65177 103.067 3.65164C101.311 2.03526 99.1396 0.995552 96.8189 0.632498Z"
<path fill="white"
d="M139.122 125.781L139.122 125.78L123.313 109.988C123.313 109.987 123.313 109.987 123.312 109.986C121.996 108.653 119.849 108.657 118.521 109.985L118.871 110.335L118.521 109.985L109.047 119.459C107.731 120.775 107.735 122.918 109.044 124.247L109.047 124.249L124.858 140.06C128.789 143.992 135.191 143.992 139.122 140.06C143.069 136.113 143.069 129.728 139.122 125.781Z" stroke="#E5E7EB"
fill="#A5B4FC" />
stroke="#818CF8" <ellipse cx="80.0618" cy="81" rx="28.0342" ry="28.0342" fill="#EEF2FF" />
/> <path
<path d="M99.2393 61.3061L99.2391 61.3058C88.498 50.5808 71.1092 50.5804 60.3835 61.3061C49.6423 72.0316 49.6422 89.4361 60.3832 100.162C71.109 110.903 88.4982 110.903 99.2393 100.162C109.965 89.4363 109.965 72.0317 99.2393 61.3061ZM105.863 54.6832C120.249 69.0695 120.249 92.3985 105.863 106.785C91.4605 121.171 68.1468 121.171 53.7446 106.785C39.3582 92.3987 39.3582 69.0693 53.7446 54.683C68.1468 40.2965 91.4605 40.2966 105.863 54.6832Z"
d="M83.185 87.2285C82.5387 87.2285 82.0027 86.6926 82.0027 86.0305C82.0027 83.3821 77.9987 83.3821 77.9987 86.0305C77.9987 86.6926 77.4627 87.2285 76.8006 87.2285C76.1543 87.2285 75.6183 86.6926 75.6183 86.0305C75.6183 80.2294 84.3831 80.2451 84.3831 86.0305C84.3831 86.6926 83.8471 87.2285 83.185 87.2285Z" stroke="#E5E7EB"
fill="#4F46E5" />
/> <path d="M110.782 119.267L102.016 110.492C104.888 108.267 107.476 105.651 109.564 102.955L118.329 111.729L110.782 119.267Z" stroke="#E5E7EB" />
<path <path
d="M93.3528 77.0926H88.403C87.7409 77.0926 87.2049 76.5567 87.2049 75.8946C87.2049 75.2483 87.7409 74.7123 88.403 74.7123H93.3528C94.0149 74.7123 94.5509 75.2483 94.5509 75.8946C94.5509 76.5567 94.0149 77.0926 93.3528 77.0926Z" d="M139.122 125.781L139.122 125.78L123.313 109.988C123.313 109.987 123.313 109.987 123.312 109.986C121.996 108.653 119.849 108.657 118.521 109.985L118.871 110.335L118.521 109.985L109.047 119.459C107.731 120.775 107.735 122.918 109.044 124.247L109.047 124.249L124.858 140.06C128.789 143.992 135.191 143.992 139.122 140.06C143.069 136.113 143.069 129.728 139.122 125.781Z"
fill="#4F46E5" fill="#A5B4FC"
/> stroke="#818CF8"
<path />
d="M71.5987 77.0925H66.6488C65.9867 77.0925 65.4507 76.5565 65.4507 75.8945C65.4507 75.2481 65.9867 74.7122 66.6488 74.7122H71.5987C72.245 74.7122 72.781 75.2481 72.781 75.8945C72.781 76.5565 72.245 77.0925 71.5987 77.0925Z" <path
fill="#4F46E5" d="M83.185 87.2285C82.5387 87.2285 82.0027 86.6926 82.0027 86.0305C82.0027 83.3821 77.9987 83.3821 77.9987 86.0305C77.9987 86.6926 77.4627 87.2285 76.8006 87.2285C76.1543 87.2285 75.6183 86.6926 75.6183 86.0305C75.6183 80.2294 84.3831 80.2451 84.3831 86.0305C84.3831 86.6926 83.8471 87.2285 83.185 87.2285Z"
/> fill="#4F46E5"
<rect x="38.3522" y="21.5128" width="41.0256" height="2.73504" rx="1.36752" fill="#4F46E5" /> />
<rect x="38.3522" y="133.65" width="54.7009" height="5.47009" rx="2.73504" fill="#A5B4FC" /> <path
<rect x="38.3522" y="29.7179" width="13.6752" height="2.73504" rx="1.36752" fill="#4F46E5" /> d="M93.3528 77.0926H88.403C87.7409 77.0926 87.2049 76.5567 87.2049 75.8946C87.2049 75.2483 87.7409 74.7123 88.403 74.7123H93.3528C94.0149 74.7123 94.5509 75.2483 94.5509 75.8946C94.5509 76.5567 94.0149 77.0926 93.3528 77.0926Z"
<circle cx="56.13" cy="31.0854" r="1.36752" fill="#4F46E5" /> fill="#4F46E5"
<circle cx="61.6001" cy="31.0854" r="1.36752" fill="#4F46E5" /> />
<circle cx="67.0702" cy="31.0854" r="1.36752" fill="#4F46E5" /> <path
</svg> d="M71.5987 77.0925H66.6488C65.9867 77.0925 65.4507 76.5565 65.4507 75.8945C65.4507 75.2481 65.9867 74.7122 66.6488 74.7122H71.5987C72.245 74.7122 72.781 75.2481 72.781 75.8945C72.781 76.5565 72.245 77.0925 71.5987 77.0925Z"
<div> fill="#4F46E5"
<h2 class="text-center text-black text-xl font-semibold leading-loose pb-2">Theres no listing here</h2> />
<p class="text-center text-black text-base font-normal leading-relaxed pb-4">Try changing your filters to <br />see listings</p> <rect x="38.3522" y="21.5128" width="41.0256" height="2.73504" rx="1.36752" fill="#4F46E5" />
<div class="flex gap-3"> <rect x="38.3522" y="133.65" width="54.7009" height="5.47009" rx="2.73504" fill="#A5B4FC" />
<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 Filter</button> <rect x="38.3522" y="29.7179" width="13.6752" height="2.73504" rx="1.36752" fill="#4F46E5" />
</div> <circle cx="56.13" cy="31.0854" r="1.36752" fill="#4F46E5" />
</div> <circle cx="61.6001" cy="31.0854" r="1.36752" fill="#4F46E5" />
</div> <circle cx="67.0702" cy="31.0854" r="1.36752" fill="#4F46E5" />
</div> </svg>
} <div>
</div> <h2 class="text-center text-black text-xl font-semibold leading-loose pb-2">Theres no listing here</h2>
@if(pageCount > 1) { <p class="text-center text-black text-base font-normal leading-relaxed pb-4">Try changing your filters to <br />see listings</p>
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator> <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 Filter</button>
</div> </div>
</div>
<!-- Filter Button for Mobile --> </div>
<button (click)="openFilterModal()" class="md:hidden fixed bottom-4 right-4 bg-primary-500 text-white p-3 rounded-full shadow-lg z-20"><i class="fas fa-filter"></i> Filter</button> </div>
</div> }
</div>
@if(pageCount > 1) {
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator>
}
</div>
<!-- Filter Button for Mobile -->
<button (click)="openFilterModal()" class="md:hidden fixed bottom-4 right-4 bg-primary-500 text-white p-3 rounded-full shadow-lg z-20"><i class="fas fa-filter"></i> Filter</button>
</div>

View File

@@ -1,261 +1,302 @@
import { CommonModule } from '@angular/common'; 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 { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { UntilDestroy } from '@ngneat/until-destroy'; import { UntilDestroy } from '@ngneat/until-destroy';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
import { CommercialPropertyListing, SortByOptions } from '../../../../../../bizmatch-server/src/models/db.model'; import { CommercialPropertyListing, SortByOptions } from '../../../../../../bizmatch-server/src/models/db.model';
import { CommercialPropertyListingCriteria, KeycloakUser, LISTINGS_PER_PAGE, ResponseCommercialPropertyListingArray } from '../../../../../../bizmatch-server/src/models/main.model'; import { CommercialPropertyListingCriteria, KeycloakUser, LISTINGS_PER_PAGE, ResponseCommercialPropertyListingArray } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component'; import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
import { PaginatorComponent } from '../../../components/paginator/paginator.component'; import { PaginatorComponent } from '../../../components/paginator/paginator.component';
import { ModalService } from '../../../components/search-modal/modal.service'; import { ModalService } from '../../../components/search-modal/modal.service';
import { SearchModalCommercialComponent } from '../../../components/search-modal/search-modal-commercial.component'; import { SearchModalCommercialComponent } from '../../../components/search-modal/search-modal-commercial.component';
import { LazyLoadImageDirective } from '../../../directives/lazy-load-image.directive'; import { LazyLoadImageDirective } from '../../../directives/lazy-load-image.directive';
import { AltTextService } from '../../../services/alt-text.service'; import { AltTextService } from '../../../services/alt-text.service';
import { FilterStateService } from '../../../services/filter-state.service'; import { FilterStateService } from '../../../services/filter-state.service';
import { ImageService } from '../../../services/image.service'; import { ImageService } from '../../../services/image.service';
import { ListingsService } from '../../../services/listings.service'; import { ListingsService } from '../../../services/listings.service';
import { SearchService } from '../../../services/search.service'; import { SearchService } from '../../../services/search.service';
import { SelectOptionsService } from '../../../services/select-options.service'; import { SelectOptionsService } from '../../../services/select-options.service';
import { SeoService } from '../../../services/seo.service'; import { SeoService } from '../../../services/seo.service';
import { AuthService } from '../../../services/auth.service'; import { AuthService } from '../../../services/auth.service';
import { map2User } from '../../../utils/utils'; import { map2User } from '../../../utils/utils';
@UntilDestroy() @UntilDestroy()
@Component({ @Component({
selector: 'app-commercial-property-listings', selector: 'app-commercial-property-listings',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, SearchModalCommercialComponent, LazyLoadImageDirective, BreadcrumbsComponent], imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, SearchModalCommercialComponent, LazyLoadImageDirective, BreadcrumbsComponent],
templateUrl: './commercial-property-listings.component.html', templateUrl: './commercial-property-listings.component.html',
styleUrls: ['./commercial-property-listings.component.scss', '../../pages.scss'], styleUrls: ['./commercial-property-listings.component.scss', '../../pages.scss'],
}) changeDetection: ChangeDetectionStrategy.OnPush,
export class CommercialPropertyListingsComponent implements OnInit, OnDestroy { })
private destroy$ = new Subject<void>(); export class CommercialPropertyListingsComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
// Component properties
environment = environment; // Component properties
env = environment; environment = environment;
listings: Array<CommercialPropertyListing> = []; env = environment;
filteredListings: Array<CommercialPropertyListing> = []; listings: Array<CommercialPropertyListing> = [];
criteria: CommercialPropertyListingCriteria; filteredListings: Array<CommercialPropertyListing> = [];
sortBy: SortByOptions | null = null; criteria: CommercialPropertyListingCriteria;
sortBy: SortByOptions | null = null;
// Pagination
totalRecords = 0; // Pagination
page = 1; totalRecords = 0;
pageCount = 1; page = 1;
first = 0; pageCount = 1;
rows = LISTINGS_PER_PAGE; first = 0;
rows = LISTINGS_PER_PAGE;
// UI state
ts = new Date().getTime(); // UI state
ts = new Date().getTime();
// Breadcrumbs
breadcrumbs: BreadcrumbItem[] = [ // Breadcrumbs
{ label: 'Home', url: '/home', icon: 'fas fa-home' }, breadcrumbs: BreadcrumbItem[] = [
{ label: 'Commercial Properties' } { label: 'Home', url: '/home', icon: 'fas fa-home' },
]; { label: 'Commercial Properties' }
];
// User for favorites
user: KeycloakUser | null = null; // User for favorites
user: KeycloakUser | null = null;
constructor(
public altText: AltTextService, constructor(
public selectOptions: SelectOptionsService, public altText: AltTextService,
private listingsService: ListingsService, public selectOptions: SelectOptionsService,
private router: Router, private listingsService: ListingsService,
private cdRef: ChangeDetectorRef, private router: Router,
private imageService: ImageService, private cdRef: ChangeDetectorRef,
private searchService: SearchService, private imageService: ImageService,
private modalService: ModalService, private searchService: SearchService,
private filterStateService: FilterStateService, private modalService: ModalService,
private route: ActivatedRoute, private filterStateService: FilterStateService,
private seoService: SeoService, private route: ActivatedRoute,
private authService: AuthService, private seoService: SeoService,
) {} private authService: AuthService,
) {}
async ngOnInit(): Promise<void> {
// Load user for favorites functionality async ngOnInit(): Promise<void> {
const token = await this.authService.getToken(); // Load user for favorites functionality
this.user = map2User(token); const token = await this.authService.getToken();
this.user = map2User(token);
// Set SEO meta tags for commercial property listings page
this.seoService.updateMetaTags({ // Set SEO meta tags for commercial property listings page
title: 'Commercial Properties for Sale - Office, Retail, Industrial Real Estate | BizMatch', this.seoService.updateMetaTags({
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',
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', description: 'Browse commercial real estate including offices, retail, warehouses, and industrial properties. Verified investment opportunities.',
type: 'website' 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'
});
// Subscribe to state changes
this.filterStateService // Subscribe to state changes
.getState$('commercialPropertyListings') this.filterStateService
.pipe(takeUntil(this.destroy$)) .getState$('commercialPropertyListings')
.subscribe(state => { .pipe(takeUntil(this.destroy$))
this.criteria = state.criteria; .subscribe(state => {
this.sortBy = state.sortBy; this.criteria = state.criteria;
// Automatically search when state changes this.sortBy = state.sortBy;
this.search(); // Automatically search when state changes
}); this.search();
});
// Subscribe to search triggers (if triggered from other components)
this.searchService.searchTrigger$.pipe(takeUntil(this.destroy$)).subscribe(type => { // Subscribe to search triggers (if triggered from other components)
if (type === 'commercialPropertyListings') { this.searchService.searchTrigger$.pipe(takeUntil(this.destroy$)).subscribe(type => {
this.search(); if (type === 'commercialPropertyListings') {
} this.search();
}); }
} });
}
async search(): Promise<void> {
try { async search(): Promise<void> {
// Perform search try {
const listingResponse = await this.listingsService.getListings('commercialProperty'); // Perform search
this.listings = (listingResponse as ResponseCommercialPropertyListingArray).results; const listingResponse = await this.listingsService.getListings('commercialProperty');
this.totalRecords = (listingResponse as ResponseCommercialPropertyListingArray).totalCount; this.listings = (listingResponse as ResponseCommercialPropertyListingArray).results;
this.pageCount = Math.ceil(this.totalRecords / LISTINGS_PER_PAGE); this.totalRecords = (listingResponse as ResponseCommercialPropertyListingArray).totalCount;
this.page = this.criteria.page || 1; this.pageCount = Math.ceil(this.totalRecords / LISTINGS_PER_PAGE);
this.page = this.criteria.page || 1;
// Update pagination SEO links
this.updatePaginationSEO(); // Update pagination SEO links
this.updatePaginationSEO();
// Update view
this.cdRef.markForCheck(); // Update view
this.cdRef.detectChanges(); this.cdRef.markForCheck();
} catch (error) { this.cdRef.detectChanges();
console.error('Search error:', error); } catch (error) {
// Handle error appropriately console.error('Search error:', error);
this.listings = []; // Handle error appropriately
this.totalRecords = 0; this.listings = [];
this.cdRef.markForCheck(); this.totalRecords = 0;
} this.cdRef.markForCheck();
} }
}
onPageChange(page: number): void {
// Update only pagination properties onPageChange(page: number): void {
this.filterStateService.updateCriteria('commercialPropertyListings', { // Update only pagination properties
page: page, this.filterStateService.updateCriteria('commercialPropertyListings', {
start: (page - 1) * LISTINGS_PER_PAGE, page: page,
length: LISTINGS_PER_PAGE, start: (page - 1) * LISTINGS_PER_PAGE,
}); length: LISTINGS_PER_PAGE,
// Search will be triggered automatically through state subscription });
} // Search will be triggered automatically through state subscription
}
clearAllFilters(): void {
// Reset criteria but keep sortBy clearAllFilters(): void {
this.filterStateService.clearFilters('commercialPropertyListings'); // Reset criteria but keep sortBy
// Search will be triggered automatically through state subscription this.filterStateService.clearFilters('commercialPropertyListings');
} // Search will be triggered automatically through state subscription
}
async openFilterModal(): Promise<void> {
// Open modal with current criteria async openFilterModal(): Promise<void> {
const currentCriteria = this.filterStateService.getCriteria('commercialPropertyListings'); // Open modal with current criteria
const modalResult = await this.modalService.showModal(currentCriteria); const currentCriteria = this.filterStateService.getCriteria('commercialPropertyListings');
const modalResult = await this.modalService.showModal(currentCriteria);
if (modalResult.accepted) {
// Modal accepted changes - state is updated by modal if (modalResult.accepted) {
// Search will be triggered automatically through state subscription // Modal accepted changes - state is updated by modal
} else { // Search will be triggered automatically through state subscription
// Modal was cancelled - no action needed } else {
} // Modal was cancelled - no action needed
} }
}
// Helper methods for template
getTS(): number { // Helper methods for template
return new Date().getTime(); getTS(): number {
} return new Date().getTime();
}
getDaysListed(listing: CommercialPropertyListing): number {
return dayjs().diff(listing.created, 'day'); getDaysListed(listing: CommercialPropertyListing): number {
} return dayjs().diff(listing.created, 'day');
}
getListingImage(listing: CommercialPropertyListing): string {
if (listing.imageOrder?.length > 0) { getListingImage(listing: CommercialPropertyListing): string {
return `${this.env.imageBaseUrl}/pictures/property/${listing.imagePath}/${listing.serialId}/${listing.imageOrder[0]}`; if (listing.imageOrder?.length > 0) {
} return `${this.env.imageBaseUrl}/pictures/property/${listing.imagePath}/${listing.serialId}/${listing.imageOrder[0]}`;
return 'assets/images/placeholder_properties.jpg'; }
} return 'assets/images/placeholder_properties.jpg';
}
getListingPrice(listing: CommercialPropertyListing): string {
if (!listing.price) return 'Price on Request'; getListingPrice(listing: CommercialPropertyListing): string {
return `$${listing.price.toLocaleString()}`; if (!listing.price) return 'Price on Request';
} return `$${listing.price.toLocaleString()}`;
}
getListingLocation(listing: CommercialPropertyListing): string {
if (!listing.location) return 'Location not specified'; getListingLocation(listing: CommercialPropertyListing): string {
return listing.location.name || listing.location.county || 'Location not specified'; if (!listing.location) return 'Location not specified';
} return listing.location.name || listing.location.county || 'Location not specified';
}
navigateToDetails(listingId: string): void {
this.router.navigate(['/details-commercial-property-listing', listingId]); navigateToDetails(listingId: string): void {
} this.router.navigate(['/details-commercial-property-listing', listingId]);
}
/**
* Check if listing is already in user's favorites /**
*/ * Check if listing is already in user's favorites
isFavorite(listing: CommercialPropertyListing): boolean { */
if (!this.user?.email || !listing.favoritesForUser) return false; isFavorite(listing: CommercialPropertyListing): boolean {
return listing.favoritesForUser.includes(this.user.email); if (!this.user?.email || !listing.favoritesForUser) return false;
} return listing.favoritesForUser.includes(this.user.email);
}
/**
* Toggle favorite status for a listing /**
*/ * Toggle favorite status for a listing
async toggleFavorite(event: Event, listing: CommercialPropertyListing): Promise<void> { */
event.stopPropagation(); async toggleFavorite(event: Event, listing: CommercialPropertyListing): Promise<void> {
event.preventDefault(); event.stopPropagation();
event.preventDefault();
if (!this.user?.email) {
// User not logged in - redirect to login if (!this.user?.email) {
this.router.navigate(['/login']); // User not logged in - redirect to login
return; this.router.navigate(['/login']);
} return;
}
try {
if (this.isFavorite(listing)) { try {
// Remove from favorites if (this.isFavorite(listing)) {
await this.listingsService.removeFavorite(listing.id, 'commercialProperty'); // Remove from favorites
listing.favoritesForUser = listing.favoritesForUser.filter(email => email !== this.user!.email); await this.listingsService.removeFavorite(listing.id, 'commercialProperty');
} else { listing.favoritesForUser = listing.favoritesForUser.filter(email => email !== this.user!.email);
// Add to favorites } else {
await this.listingsService.addToFavorites(listing.id, 'commercialProperty'); // Add to favorites
if (!listing.favoritesForUser) { await this.listingsService.addToFavorites(listing.id, 'commercialProperty');
listing.favoritesForUser = []; if (!listing.favoritesForUser) {
} listing.favoritesForUser = [];
listing.favoritesForUser.push(this.user.email); }
} listing.favoritesForUser.push(this.user.email);
this.cdRef.detectChanges(); }
} catch (error) { this.cdRef.detectChanges();
console.error('Error toggling favorite:', error); } catch (error) {
} console.error('Error toggling favorite:', error);
} }
}
ngOnDestroy(): void {
this.destroy$.next(); ngOnDestroy(): void {
this.destroy$.complete(); this.destroy$.next();
// Clean up pagination links when leaving the page this.destroy$.complete();
this.seoService.clearPaginationLinks(); // Clean up pagination links when leaving the page
} this.seoService.clearPaginationLinks();
}
/**
* Update pagination SEO links (rel="next/prev") and CollectionPage schema /**
*/ * Update pagination SEO links (rel="next/prev") and CollectionPage schema
private updatePaginationSEO(): void { */
const baseUrl = `${this.seoService.getBaseUrl()}/commercialPropertyListings`; private updatePaginationSEO(): void {
const baseUrl = `${this.seoService.getBaseUrl()}/commercialPropertyListings`;
// Inject rel="next" and rel="prev" links
this.seoService.injectPaginationLinks(baseUrl, this.page, this.pageCount); // Inject rel="next" and rel="prev" links
this.seoService.injectPaginationLinks(baseUrl, this.page, this.pageCount);
// Inject CollectionPage schema for paginated results
const collectionSchema = this.seoService.generateCollectionPageSchema({ // Inject CollectionPage schema for paginated results
name: 'Commercial Properties for Sale', const collectionSchema = this.seoService.generateCollectionPageSchema({
description: 'Browse commercial real estate listings including office buildings, retail spaces, warehouses, and industrial properties across the United States.', name: 'Commercial Properties for Sale',
totalItems: this.totalRecords, description: 'Browse commercial real estate listings including office buildings, retail spaces, warehouses, and industrial properties across the United States.',
itemsPerPage: LISTINGS_PER_PAGE, totalItems: this.totalRecords,
currentPage: this.page, itemsPerPage: LISTINGS_PER_PAGE,
baseUrl: baseUrl currentPage: this.page,
}); baseUrl: baseUrl
this.seoService.injectStructuredData(collectionSchema); });
} this.seoService.injectStructuredData(collectionSchema);
} }
/**
* Share property listing
*/
async shareProperty(event: Event, listing: CommercialPropertyListing): Promise<void> {
event.stopPropagation();
event.preventDefault();
const url = `${window.location.origin}/commercial-property/${listing.slug || listing.id}`;
const title = listing.title || 'Commercial Property Listing';
// Try native share API first (works on mobile and some desktop browsers)
if (navigator.share) {
try {
await navigator.share({
title: title,
text: `Check out this property: ${title}`,
url: url,
});
} catch (err) {
// User cancelled or share failed - fall back to clipboard
this.copyToClipboard(url);
}
} else {
// Fallback: open Facebook share dialog
const encodedUrl = encodeURIComponent(url);
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, '_blank', 'width=600,height=400');
}
}
/**
* Copy URL to clipboard and show feedback
*/
private copyToClipboard(url: string): void {
navigator.clipboard.writeText(url).then(() => {
console.log('Link copied to clipboard!');
}).catch(err => {
console.error('Failed to copy link:', err);
});
}
}

View File

@@ -233,7 +233,6 @@
<span class="text-sm text-gray-500 ml-2">[Add more licenses or remove existing ones.]</span> <span class="text-sm text-gray-500 ml-2">[Add more licenses or remove existing ones.]</span>
</div> </div>
</div> </div>
}
<div class="flex items-center !my-8"> <div class="flex items-center !my-8">
<label class="flex items-center cursor-pointer"> <label class="flex items-center cursor-pointer">
<div class="relative"> <div class="relative">
@@ -243,6 +242,7 @@
<div class="ml-3 text-gray-700 font-medium">Show your profile in Professional Directory</div> <div class="ml-3 text-gray-700 font-medium">Show your profile in Professional Directory</div>
</label> </label>
</div> </div>
}
<div class="flex justify-start"> <div class="flex justify-start">
<button type="submit" <button type="submit"

View File

@@ -16,27 +16,52 @@
</thead> </thead>
<tbody> <tbody>
@for(listing of favorites; track listing){ @for(listing of favorites; track listing){
@if(isBusinessOrCommercial(listing)){
<tr class="border-b"> <tr class="border-b">
<td class="py-2 px-4">{{ listing.title }}</td> <td class="py-2 px-4">{{ $any(listing).title }}</td>
<td class="py-2 px-4">{{ listing.listingsCategory === 'commercialProperty' ? 'Commercial Property' : 'Business' }}</td> <td class="py-2 px-4">{{ $any(listing).listingsCategory === 'commercialProperty' ? 'Commercial Property' :
<td class="py-2 px-4">{{ listing.location.name ? listing.location.name : listing.location.county }}, {{ listing.location.state }}</td> 'Business' }}</td>
<td class="py-2 px-4">${{ listing.price.toLocaleString() }}</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"> <td class="py-2 px-4 flex">
@if(listing.listingsCategory==='business'){ @if($any(listing).listingsCategory==='business'){
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2" [routerLink]="['/business', listing.slug || listing.id]"> <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> <i class="fa-regular fa-eye"></i>
</button> </button>
} @if(listing.listingsCategory==='commercialProperty'){ } @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', listing.slug || listing.id]"> <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> <i class="fa-regular fa-eye"></i>
</button> </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> <i class="fa-solid fa-trash"></i>
</button> </button>
</td> </td>
</tr> </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> </tbody>
</table> </table>
@@ -45,25 +70,47 @@
<!-- Mobile view --> <!-- Mobile view -->
<div class="md:hidden"> <div class="md:hidden">
<div *ngFor="let listing of favorites" class="bg-white drop-shadow-inner-faint rounded-lg p-4 mb-4"> <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> @if(isBusinessOrCommercial(listing)){
<p class="text-gray-600 mb-2">Category: {{ listing.listingsCategory === 'commercialProperty' ? 'Commercial Property' : 'Business' }}</p> <h2 class="text-xl font-semibold mb-2">{{ $any(listing).title }}</h2>
<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">Category: {{ $any(listing).listingsCategory === 'commercialProperty' ? 'Commercial
<p class="text-gray-600 mb-2">Price: ${{ listing.price.toLocaleString() }}</p> 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"> <div class="flex justify-start">
@if(listing.listingsCategory==='business'){ @if($any(listing).listingsCategory==='business'){
<button class="bg-green-500 text-white w-10 h-10 flex items-center justify-center rounded-full mr-2" [routerLink]="['/business', listing.slug || listing.id]"> <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> <i class="fa-regular fa-eye"></i>
</button> </button>
} @if(listing.listingsCategory==='commercialProperty'){ } @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', listing.slug || listing.id]"> <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> <i class="fa-regular fa-eye"></i>
</button> </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> <i class="fa-solid fa-trash"></i>
</button> </button>
</div> </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>
</div> </div>
@@ -82,4 +129,4 @@
</div> --> </div> -->
</div> </div>
</div> </div>
<app-confirmation></app-confirmation> <app-confirmation></app-confirmation>

View File

@@ -1,5 +1,5 @@
import { Component } from '@angular/core'; 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 { KeycloakUser } from '../../../../../../bizmatch-server/src/models/main.model';
import { ConfirmationComponent } from '../../../components/confirmation/confirmation.component'; import { ConfirmationComponent } from '../../../components/confirmation/confirmation.component';
import { ConfirmationService } from '../../../components/confirmation/confirmation.service'; import { ConfirmationService } from '../../../components/confirmation/confirmation.service';
@@ -19,28 +19,36 @@ import { map2User } from '../../../utils/utils';
export class FavoritesComponent { export class FavoritesComponent {
user: KeycloakUser; user: KeycloakUser;
// listings: Array<ListingType> = []; //= dataListings as unknown as Array<BusinessListing>; // listings: Array<ListingType> = []; //= dataListings as unknown as Array<BusinessListing>;
favorites: Array<BusinessListing | CommercialPropertyListing>; favorites: Array<BusinessListing | CommercialPropertyListing | User>;
constructor(private listingsService: ListingsService, public selectOptions: SelectOptionsService, private confirmationService: ConfirmationService, private authService: AuthService) {} constructor(private listingsService: ListingsService, public selectOptions: SelectOptionsService, private confirmationService: ConfirmationService, private authService: AuthService) { }
async ngOnInit() { async ngOnInit() {
const token = await this.authService.getToken(); const token = await this.authService.getToken();
this.user = map2User(token); this.user = map2User(token);
const result = await Promise.all([await this.listingsService.getFavoriteListings('business'), await this.listingsService.getFavoriteListings('commercialProperty')]); 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]]; 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?` }); const confirmed = await this.confirmationService.showConfirmation({ message: `Are you sure you want to remove this listing from your Favorites?` });
if (confirmed) { if (confirmed) {
// this.messageService.showMessage('Listing has been deleted'); // this.messageService.showMessage('Listing has been deleted');
this.deleteListing(listing); this.deleteListing(listing);
} }
} }
async deleteListing(listing: BusinessListing | CommercialPropertyListing) { async deleteListing(listing: BusinessListing | CommercialPropertyListing | User) {
if (listing.listingsCategory === 'business') { if ('listingsCategory' in listing) {
await this.listingsService.removeFavorite(listing.id, 'business'); if (listing.listingsCategory === 'business') {
await this.listingsService.removeFavorite(listing.id, 'business');
} else {
await this.listingsService.removeFavorite(listing.id, 'commercialProperty');
}
} else { } 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')]); 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]]; this.favorites = [...result[0], ...result[1], ...result[2]] as Array<BusinessListing | CommercialPropertyListing | User>;
}
isBusinessOrCommercial(listing: any): boolean {
return !!listing.listingsCategory;
} }
} }

View File

@@ -1,172 +1,172 @@
<div class="container mx-auto p-4"> <div class="container mx-auto p-4">
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6"> <div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6">
<h1 class="text-2xl font-bold md:mb-4">My Listings</h1> <h1 class="text-2xl font-bold md:mb-4">My Listings</h1>
<!-- Desktop view --> <!-- Desktop view -->
<div class="hidden md:block"> <div class="hidden md:block">
<table class="w-full table-fixed bg-white drop-shadow-inner-faint rounded-lg overflow-hidden"> <table class="w-full table-fixed bg-white drop-shadow-inner-faint rounded-lg overflow-hidden">
<colgroup> <colgroup>
<col class="w-auto" /> <col class="w-auto" />
<!-- Title: restliche Breite --> <!-- Title: restliche Breite -->
<col class="w-40" /> <col class="w-40" />
<!-- Category --> <!-- Category -->
<col class="w-60" /> <col class="w-60" />
<!-- Located in --> <!-- Located in -->
<col class="w-32" /> <col class="w-32" />
<!-- Price --> <!-- Price -->
<col class="w-28" /> <col class="w-28" />
<!-- Internal # --> <!-- Internal # -->
<col class="w-40" /> <col class="w-40" />
<!-- Publication Status --> <!-- Publication Status -->
<col class="w-36" /> <col class="w-36" />
<!-- Actions --> <!-- Actions -->
</colgroup> </colgroup>
<thead class="bg-gray-100"> <thead class="bg-gray-100">
<!-- Header --> <!-- Header -->
<tr> <tr>
<th class="py-2 px-4 text-left">Title</th> <th class="py-2 px-4 text-left">Title</th>
<th class="py-2 px-4 text-left">Category</th> <th class="py-2 px-4 text-left">Category</th>
<th class="py-2 px-4 text-left">Located in</th> <th class="py-2 px-4 text-left">Located in</th>
<th class="py-2 px-4 text-left">Price</th> <th class="py-2 px-4 text-left">Price</th>
<th class="py-2 px-4 text-left">Internal #</th> <th class="py-2 px-4 text-left">Internal #</th>
<th class="py-2 px-4 text-left">Publication Status</th> <th class="py-2 px-4 text-left">Publication Status</th>
<th class="py-2 px-4 text-left whitespace-nowrap">Actions</th> <th class="py-2 px-4 text-left whitespace-nowrap">Actions</th>
</tr> </tr>
<!-- Filter row (zwischen Header und Inhalt) --> <!-- Filter row (zwischen Header und Inhalt) -->
<tr class="bg-white border-b"> <tr class="bg-white border-b">
<th class="py-2 px-4"> <th class="py-2 px-4">
<input type="text" class="w-full border rounded px-2 py-1" placeholder="Filter title…" [(ngModel)]="filters.title" (input)="applyFilters()" /> <input type="text" class="w-full border rounded px-2 py-1" placeholder="Filter title…" [(ngModel)]="filters.title" (input)="applyFilters()" />
</th> </th>
<!-- Category Filter --> <!-- Category Filter -->
<th class="py-2 px-4"> <th class="py-2 px-4">
<select class="w-full border rounded px-2 py-1" [(ngModel)]="filters.category" (change)="applyFilters()"> <select class="w-full border rounded px-2 py-1" [(ngModel)]="filters.category" (change)="applyFilters()">
<option value="">All</option> <option value="">All</option>
<option value="business">Business</option> <option value="business">Business</option>
<option value="commercialProperty">Commercial Property</option> <option value="commercialProperty">Commercial Property</option>
</select> </select>
</th> </th>
<th class="py-2 px-4"> <th class="py-2 px-4">
<input type="text" class="w-full border rounded px-2 py-1" placeholder="City/County/State…" [(ngModel)]="filters.location" (input)="applyFilters()" /> <input type="text" class="w-full border rounded px-2 py-1" placeholder="City/County/State…" [(ngModel)]="filters.location" (input)="applyFilters()" />
</th> </th>
<th class="py-2 px-4"> <th class="py-2 px-4">
<!-- Preis nicht gefiltert, daher leer --> <!-- Preis nicht gefiltert, daher leer -->
</th> </th>
<th class="py-2 px-4"> <th class="py-2 px-4">
<input type="text" class="w-full border rounded px-2 py-1" placeholder="Internal #" [(ngModel)]="filters.internalListingNumber" (input)="applyFilters()" /> <input type="text" class="w-full border rounded px-2 py-1" placeholder="Internal #" [(ngModel)]="filters.internalListingNumber" (input)="applyFilters()" />
</th> </th>
<th class="py-2 px-4"> <th class="py-2 px-4">
<select class="w-full border rounded px-2 py-1" [(ngModel)]="filters.status" (change)="applyFilters()"> <select class="w-full border rounded px-2 py-1" [(ngModel)]="filters.status" (change)="applyFilters()">
<option value="">All</option> <option value="">All</option>
<option value="published">Published</option> <option value="published">Published</option>
<option value="draft">Draft</option> <option value="draft">Draft</option>
</select> </select>
</th> </th>
<th class="py-2 px-4"> <th class="py-2 px-4">
<button class="text-sm underline" (click)="clearFilters()">Clear</button> <button class="text-sm underline" (click)="clearFilters()">Clear</button>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let listing of myListings" class="border-b"> <tr *ngFor="let listing of myListings" class="border-b">
<td class="py-2 px-4">{{ listing.title }}</td> <td class="py-2 px-4">{{ listing.title }}</td>
<td class="py-2 px-4">{{ listing.listingsCategory === 'commercialProperty' ? 'Commercial Property' : 'Business' }}</td> <td class="py-2 px-4">{{ listing.listingsCategory === 'commercialProperty' ? 'Commercial Property' : 'Business' }}</td>
<td class="py-2 px-4">{{ listing.location.name ? listing.location.name : listing.location.county ? listing.location.county : this.selectOptions.getState(listing.location.state) }}</td> <td class="py-2 px-4">{{ listing.location.name ? listing.location.name : listing.location.county ? listing.location.county : this.selectOptions.getState(listing.location.state) }}</td>
<td class="py-2 px-4">${{ listing.price ? listing.price.toLocaleString() : '' }}</td> <td class="py-2 px-4">${{ listing.price ? listing.price.toLocaleString() : '' }}</td>
<td class="py-2 px-4 flex justify-center"> <td class="py-2 px-4 flex justify-center">
{{ listing.internalListingNumber ?? '—' }} {{ listing.internalListingNumber ?? '—' }}
</td> </td>
<td class="py-2 px-4"> <td class="py-2 px-4">
<span class="{{ listing.draft ? 'bg-yellow-100 text-yellow-800' : 'bg-green-100 text-green-800' }} py-1 px-2 rounded-full text-xs font-medium"> <span class="{{ listing.draft ? 'bg-yellow-100 text-yellow-800' : 'bg-green-100 text-green-800' }} py-1 px-2 rounded-full text-xs font-medium">
{{ listing.draft ? 'Draft' : 'Published' }} {{ listing.draft ? 'Draft' : 'Published' }}
</span> </span>
</td> </td>
<td class="py-2 px-4 whitespace-nowrap"> <td class="py-2 px-4 whitespace-nowrap">
@if(listing.listingsCategory==='business'){ @if(listing.listingsCategory==='business'){
<button class="bg-green-500 text-white p-2 rounded-full mr-2" [routerLink]="['/editBusinessListing', listing.id]"> <button class="bg-green-500 text-white p-2 rounded-full mr-2" [routerLink]="['/editBusinessListing', listing.id]">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" /> <path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg> </svg>
</button> </button>
} @if(listing.listingsCategory==='commercialProperty'){ } @if(listing.listingsCategory==='commercialProperty'){
<button class="bg-green-500 text-white p-2 rounded-full mr-2" [routerLink]="['/editCommercialPropertyListing', listing.id]"> <button class="bg-green-500 text-white p-2 rounded-full mr-2" [routerLink]="['/editCommercialPropertyListing', listing.id]">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" /> <path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg> </svg>
</button> </button>
} }
<button class="bg-orange-500 text-white p-2 rounded-full" (click)="confirm(listing)"> <button class="bg-orange-500 text-white p-2 rounded-full" (click)="confirm(listing)">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path <path
fill-rule="evenodd" fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd" clip-rule="evenodd"
/> />
</svg> </svg>
</button> </button>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Mobile view --> <!-- Mobile view -->
<div class="md:hidden"> <div class="md:hidden">
<!-- Mobile Filter --> <!-- Mobile Filter -->
<div class="bg-white drop-shadow-inner-faint rounded-lg p-4 mb-4 border"> <div class="bg-white drop-shadow-inner-faint rounded-lg p-4 mb-4 border">
<div class="grid grid-cols-1 gap-3"> <div class="grid grid-cols-1 gap-3">
<input type="text" class="w-full border rounded px-3 py-2" placeholder="Filter title…" [(ngModel)]="filters.title" (input)="applyFilters()" /> <input type="text" class="w-full border rounded px-3 py-2" placeholder="Filter title…" [(ngModel)]="filters.title" (input)="applyFilters()" />
<input type="text" class="w-full border rounded px-3 py-2" placeholder="City/County/State…" [(ngModel)]="filters.location" (input)="applyFilters()" /> <input type="text" class="w-full border rounded px-3 py-2" placeholder="City/County/State…" [(ngModel)]="filters.location" (input)="applyFilters()" />
<input type="text" class="w-full border rounded px-3 py-2" placeholder="Internal #" [(ngModel)]="filters.internalListingNumber" (input)="applyFilters()" /> <input type="text" class="w-full border rounded px-3 py-2" placeholder="Internal #" [(ngModel)]="filters.internalListingNumber" (input)="applyFilters()" />
<select class="w-full border rounded px-3 py-2" [(ngModel)]="filters.status" (change)="applyFilters()"> <select class="w-full border rounded px-3 py-2" [(ngModel)]="filters.status" (change)="applyFilters()">
<option value="">All</option> <option value="">All</option>
<option value="published">Published</option> <option value="published">Published</option>
<option value="draft">Draft</option> <option value="draft">Draft</option>
</select> </select>
<button class="text-sm underline justify-self-start" (click)="clearFilters()">Clear</button> <button class="text-sm underline justify-self-start" (click)="clearFilters()">Clear</button>
</div> </div>
</div> </div>
<div *ngFor="let listing of myListings" class="bg-white drop-shadow-inner-faint rounded-lg p-4 mb-4"> <div *ngFor="let listing of myListings" class="bg-white drop-shadow-inner-faint rounded-lg p-4 mb-4">
<h2 class="text-xl font-semibold mb-2">{{ listing.title }}</h2> <h2 class="text-xl font-semibold mb-2">{{ listing.title }}</h2>
<p class="text-gray-600 mb-2">Category: {{ listing.listingsCategory === 'commercialProperty' ? 'Commercial Property' : 'Business' }}</p> <p class="text-gray-600 mb-2">Category: {{ listing.listingsCategory === 'commercialProperty' ? 'Commercial Property' : 'Business' }}</p>
<p class="text-gray-600 mb-2">Located in: {{ listing.location?.name ? listing.location.name : listing.location?.county }} - {{ listing.location?.state }}</p> <p class="text-gray-600 mb-2">Located in: {{ listing.location?.name ? listing.location.name : listing.location?.county }} - {{ listing.location?.state }}</p>
<p class="text-gray-600 mb-2">Price: ${{ listing.price.toLocaleString() }}</p> <p class="text-gray-600 mb-2">Price: ${{ listing.price.toLocaleString() }}</p>
<p class="text-gray-600 mb-2">Internal #: {{ listing.internalListingNumber ?? '—' }}</p> <p class="text-gray-600 mb-2">Internal #: {{ listing.internalListingNumber ?? '—' }}</p>
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<span class="text-gray-600">Publication Status:</span> <span class="text-gray-600">Publication Status:</span>
<span class="{{ listing.draft ? 'bg-yellow-100 text-yellow-800' : 'bg-green-100 text-green-800' }} py-1 px-2 rounded-full text-xs font-medium"> <span class="{{ listing.draft ? 'bg-yellow-100 text-yellow-800' : 'bg-green-100 text-green-800' }} py-1 px-2 rounded-full text-xs font-medium">
{{ listing.draft ? 'Draft' : 'Published' }} {{ listing.draft ? 'Draft' : 'Published' }}
</span> </span>
</div> </div>
<div class="flex justify-start"> <div class="flex justify-start">
@if(listing.listingsCategory==='business'){ @if(listing.listingsCategory==='business'){
<button class="bg-green-500 text-white p-2 rounded-full mr-2" [routerLink]="['/editBusinessListing', listing.id]"> <button class="bg-green-500 text-white p-2 rounded-full mr-2" [routerLink]="['/editBusinessListing', listing.id]">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" /> <path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg> </svg>
</button> </button>
} @if(listing.listingsCategory==='commercialProperty'){ } @if(listing.listingsCategory==='commercialProperty'){
<button class="bg-green-500 text-white p-2 rounded-full mr-2" [routerLink]="['/editCommercialPropertyListing', listing.id]"> <button class="bg-green-500 text-white p-2 rounded-full mr-2" [routerLink]="['/editCommercialPropertyListing', listing.id]">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" /> <path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg> </svg>
</button> </button>
} }
<button class="bg-orange-500 text-white p-2 rounded-full" (click)="confirm(listing)"> <button class="bg-orange-500 text-white p-2 rounded-full" (click)="confirm(listing)">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path <path
fill-rule="evenodd" fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd" clip-rule="evenodd"
/> />
</svg> </svg>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<app-confirmation></app-confirmation> <app-confirmation></app-confirmation>

View File

@@ -183,11 +183,8 @@ export class AuthService {
return Promise.resolve(); return Promise.resolve();
} }
isAdmin(): Observable<boolean> { isAdmin(): Observable<boolean> {
return this.getUserRole().pipe( return this.userRole$.pipe(
map(role => role === 'admin'), 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 // 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 // Cache zurücksetzen, wenn die Caching-Zeit abgelaufen ist oder kein Cache existiert
if (!this.cachedUserRole$ || now - this.lastCacheTime > this.cacheDuration) { if (!this.cachedUserRole$ || now - this.lastCacheTime > this.cacheDuration) {
if (!this.getLocalStorageItem('authToken')) {
return of(null);
}
this.lastCacheTime = now; this.lastCacheTime = now;
let headers = new HttpHeaders().set('X-Hide-Loading', 'true').set('Accept-Language', 'en-US'); 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( this.cachedUserRole$ = this.http.get<{ role: UserRole | null }>(`${environment.apiBaseUrl}/bizmatch/auth/me/role`, { headers }).pipe(

View File

@@ -226,6 +226,7 @@ export class FilterStateService {
minPrice: null, minPrice: null,
maxPrice: null, maxPrice: null,
title: null, title: null,
brokerName: null,
prompt: null, prompt: null,
page: 1, page: 1,
start: 0, start: 0,

View File

@@ -11,7 +11,7 @@ import { getCriteriaByListingCategory, getSortByListingCategory } from '../utils
}) })
export class ListingsService { export class ListingsService {
private apiBaseUrl = environment.apiBaseUrl; private apiBaseUrl = environment.apiBaseUrl;
constructor(private http: HttpClient) {} constructor(private http: HttpClient) { }
async getListings(listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty'): Promise<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray> { async getListings(listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty'): Promise<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray> {
const criteria = getCriteriaByListingCategory(listingsCategory); const criteria = getCriteriaByListingCategory(listingsCategory);
@@ -35,8 +35,8 @@ export class ListingsService {
getListingsByEmail(email: string, listingsCategory: 'business' | 'commercialProperty'): Promise<ListingType[]> { getListingsByEmail(email: string, listingsCategory: 'business' | 'commercialProperty'): Promise<ListingType[]> {
return lastValueFrom(this.http.get<BusinessListing[]>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/user/${email}`)); return lastValueFrom(this.http.get<BusinessListing[]>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/user/${email}`));
} }
getFavoriteListings(listingsCategory: 'business' | 'commercialProperty'): Promise<ListingType[]> { getFavoriteListings(listingsCategory: 'business' | 'commercialProperty' | 'user'): Promise<ListingType[]> {
return lastValueFrom(this.http.get<BusinessListing[] | CommercialPropertyListing[]>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorites/all`)); return lastValueFrom(this.http.post<BusinessListing[] | CommercialPropertyListing[] | any[]>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorites/all`, {}));
} }
async save(listing: any, listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty') { async save(listing: any, listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty') {
if (listing.id) { if (listing.id) {
@@ -51,11 +51,15 @@ export class ListingsService {
async deleteCommercialPropertyListing(id: string, imagePath: string) { async deleteCommercialPropertyListing(id: string, imagePath: string) {
await lastValueFrom(this.http.delete<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/commercialProperty/listing/${id}/${imagePath}`)); await lastValueFrom(this.http.delete<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/commercialProperty/listing/${id}/${imagePath}`));
} }
async addToFavorites(id: string, listingsCategory?: 'business' | 'commercialProperty') { async addToFavorites(id: string, listingsCategory?: 'business' | 'commercialProperty' | 'user') {
await lastValueFrom(this.http.post<void>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorite/${id}`, {})); 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') { async removeFavorite(id: string, listingsCategory?: 'business' | 'commercialProperty' | 'user') {
await lastValueFrom(this.http.delete<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorite/${id}`)); const url = `${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorite/${id}`;
console.log('[ListingsService] removeFavorite calling URL:', url);
await lastValueFrom(this.http.delete<ListingType>(url));
} }
/** /**

File diff suppressed because it is too large Load Diff

View File

@@ -1,135 +1,135 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
export interface SitemapUrl { export interface SitemapUrl {
loc: string; loc: string;
lastmod?: string; lastmod?: string;
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never'; changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
priority?: number; priority?: number;
} }
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class SitemapService { export class SitemapService {
private readonly baseUrl = 'https://biz-match.com'; private readonly baseUrl = 'https://www.bizmatch.net';
/** /**
* Generate XML sitemap content * Generate XML sitemap content
*/ */
generateSitemap(urls: SitemapUrl[]): string { generateSitemap(urls: SitemapUrl[]): string {
const urlElements = urls.map(url => this.generateUrlElement(url)).join('\n '); const urlElements = urls.map(url => this.generateUrlElement(url)).join('\n ');
return `<?xml version="1.0" encoding="UTF-8"?> return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urlElements} ${urlElements}
</urlset>`; </urlset>`;
} }
/** /**
* Generate a single URL element for the sitemap * Generate a single URL element for the sitemap
*/ */
private generateUrlElement(url: SitemapUrl): string { private generateUrlElement(url: SitemapUrl): string {
let element = `<url>\n <loc>${url.loc}</loc>`; let element = `<url>\n <loc>${url.loc}</loc>`;
if (url.lastmod) { if (url.lastmod) {
element += `\n <lastmod>${url.lastmod}</lastmod>`; element += `\n <lastmod>${url.lastmod}</lastmod>`;
} }
if (url.changefreq) { if (url.changefreq) {
element += `\n <changefreq>${url.changefreq}</changefreq>`; element += `\n <changefreq>${url.changefreq}</changefreq>`;
} }
if (url.priority !== undefined) { if (url.priority !== undefined) {
element += `\n <priority>${url.priority.toFixed(1)}</priority>`; element += `\n <priority>${url.priority.toFixed(1)}</priority>`;
} }
element += '\n </url>'; element += '\n </url>';
return element; return element;
} }
/** /**
* Generate sitemap URLs for static pages * Generate sitemap URLs for static pages
*/ */
getStaticPageUrls(): SitemapUrl[] { getStaticPageUrls(): SitemapUrl[] {
return [ return [
{ {
loc: `${this.baseUrl}/`, loc: `${this.baseUrl}/`,
changefreq: 'daily', changefreq: 'daily',
priority: 1.0 priority: 1.0
}, },
{ {
loc: `${this.baseUrl}/home`, loc: `${this.baseUrl}/home`,
changefreq: 'daily', changefreq: 'daily',
priority: 1.0 priority: 1.0
}, },
{ {
loc: `${this.baseUrl}/listings`, loc: `${this.baseUrl}/listings`,
changefreq: 'daily', changefreq: 'daily',
priority: 0.9 priority: 0.9
}, },
{ {
loc: `${this.baseUrl}/listings-2`, loc: `${this.baseUrl}/listings-2`,
changefreq: 'daily', changefreq: 'daily',
priority: 0.8 priority: 0.8
}, },
{ {
loc: `${this.baseUrl}/listings-3`, loc: `${this.baseUrl}/listings-3`,
changefreq: 'daily', changefreq: 'daily',
priority: 0.8 priority: 0.8
}, },
{ {
loc: `${this.baseUrl}/listings-4`, loc: `${this.baseUrl}/listings-4`,
changefreq: 'daily', changefreq: 'daily',
priority: 0.8 priority: 0.8
} }
]; ];
} }
/** /**
* Generate sitemap URLs for business listings * Generate sitemap URLs for business listings
*/ */
generateBusinessListingUrls(listings: any[]): SitemapUrl[] { generateBusinessListingUrls(listings: any[]): SitemapUrl[] {
return listings.map(listing => ({ return listings.map(listing => ({
loc: `${this.baseUrl}/details-business-listing/${listing.id}`, loc: `${this.baseUrl}/details-business-listing/${listing.id}`,
lastmod: this.formatDate(listing.updated || listing.created), lastmod: this.formatDate(listing.updated || listing.created),
changefreq: 'weekly' as const, changefreq: 'weekly' as const,
priority: 0.8 priority: 0.8
})); }));
} }
/** /**
* Generate sitemap URLs for commercial property listings * Generate sitemap URLs for commercial property listings
*/ */
generateCommercialPropertyUrls(properties: any[]): SitemapUrl[] { generateCommercialPropertyUrls(properties: any[]): SitemapUrl[] {
return properties.map(property => ({ return properties.map(property => ({
loc: `${this.baseUrl}/details-commercial-property/${property.id}`, loc: `${this.baseUrl}/details-commercial-property/${property.id}`,
lastmod: this.formatDate(property.updated || property.created), lastmod: this.formatDate(property.updated || property.created),
changefreq: 'weekly' as const, changefreq: 'weekly' as const,
priority: 0.8 priority: 0.8
})); }));
} }
/** /**
* Format date to ISO 8601 format (YYYY-MM-DD) * Format date to ISO 8601 format (YYYY-MM-DD)
*/ */
private formatDate(date: Date | string): string { private formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date; const d = typeof date === 'string' ? new Date(date) : date;
return d.toISOString().split('T')[0]; return d.toISOString().split('T')[0];
} }
/** /**
* Generate complete sitemap with all URLs * Generate complete sitemap with all URLs
*/ */
async generateCompleteSitemap( async generateCompleteSitemap(
businessListings: any[], businessListings: any[],
commercialProperties: any[] commercialProperties: any[]
): Promise<string> { ): Promise<string> {
const allUrls = [ const allUrls = [
...this.getStaticPageUrls(), ...this.getStaticPageUrls(),
...this.generateBusinessListingUrls(businessListings), ...this.generateBusinessListingUrls(businessListings),
...this.generateCommercialPropertyUrls(commercialProperties) ...this.generateCommercialPropertyUrls(commercialProperties)
]; ];
return this.generateSitemap(allUrls); return this.generateSitemap(allUrls);
} }
} }

View File

@@ -49,6 +49,7 @@ export function createEmptyCommercialPropertyListingCriteria(): CommercialProper
minPrice: null, minPrice: null,
maxPrice: null, maxPrice: null,
title: '', title: '',
brokerName: '',
searchType: 'exact', searchType: 'exact',
radius: null, radius: null,
}; };
@@ -361,8 +362,8 @@ export function getCriteriaByListingCategory(listingsCategory: 'business' | 'pro
listingsCategory === 'business' listingsCategory === 'business'
? sessionStorage.getItem('businessListings') ? sessionStorage.getItem('businessListings')
: listingsCategory === 'commercialProperty' : listingsCategory === 'commercialProperty'
? sessionStorage.getItem('commercialPropertyListings') ? sessionStorage.getItem('commercialPropertyListings')
: sessionStorage.getItem('brokerListings'); : sessionStorage.getItem('brokerListings');
return storedState ? JSON.parse(storedState) : null; return storedState ? JSON.parse(storedState) : null;
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 MiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@@ -1,6 +1,6 @@
// Build information, automatically generated by `the_build_script` :zwinkern: // Build information, automatically generated by `the_build_script` :zwinkern:
const build = { const build = {
timestamp: "GER: 06.01.2026 22:33 | TX: 01/06/2026 3:33 PM" timestamp: "GER: 03.02.2026 12:44 | TX: 02/03/2026 5:44 AM"
}; };
export default build; export default build;

View File

@@ -1,140 +1,143 @@
# robots.txt for BizMatch - Business Marketplace # robots.txt for BizMatch - Business Marketplace
# https://biz-match.com # https://www.bizmatch.net
# Last updated: 2026-01-02 # Last updated: 2026-02-03
# =========================================== # ===========================================
# Default rules for all crawlers # Default rules for all crawlers
# =========================================== # ===========================================
User-agent: * User-agent: *
# Allow all public pages # Allow all public pages
Allow: / Allow: /
Allow: /home Allow: /home
Allow: /businessListings Allow: /businessListings
Allow: /commercialPropertyListings Allow: /commercialPropertyListings
Allow: /brokerListings Allow: /brokerListings
Allow: /business/* Allow: /business/*
Allow: /commercial-property/* Allow: /commercial-property/*
Allow: /details-user/* Allow: /details-user/*
Allow: /terms-of-use Allow: /terms-of-use
Allow: /privacy-statement Allow: /privacy-statement
# Disallow private/admin areas # Disallow private/admin areas
Disallow: /admin/ Disallow: /admin/
Disallow: /account Disallow: /account
Disallow: /myListings Disallow: /myListings
Disallow: /myFavorites Disallow: /myFavorites
Disallow: /createBusinessListing Disallow: /createBusinessListing
Disallow: /createCommercialPropertyListing Disallow: /createCommercialPropertyListing
Disallow: /editBusinessListing/* Disallow: /editBusinessListing/*
Disallow: /editCommercialPropertyListing/* Disallow: /editCommercialPropertyListing/*
Disallow: /login Disallow: /login
Disallow: /logout Disallow: /logout
Disallow: /register Disallow: /register
Disallow: /emailUs Disallow: /emailUs
# Disallow duplicate content / API routes # Disallow duplicate content / API routes
Disallow: /api/ Disallow: /api/
Disallow: /bizmatch/ Disallow: /bizmatch/
# Disallow search result pages with parameters (to avoid duplicate content) # Disallow Cloudflare internal paths (prevents 404 errors in crawl reports)
Disallow: /*?*sortBy= Disallow: /cdn-cgi/
Disallow: /*?*page=
Disallow: /*?*start= # Disallow search result pages with parameters (to avoid duplicate content)
Disallow: /*?*sortBy=
# =========================================== Disallow: /*?*page=
# Google-specific rules Disallow: /*?*start=
# ===========================================
User-agent: Googlebot # ===========================================
Allow: / # Google-specific rules
Crawl-delay: 1 # ===========================================
User-agent: Googlebot
# Allow Google to index images Allow: /
User-agent: Googlebot-Image Crawl-delay: 1
Allow: /assets/
Disallow: /assets/leaflet/ # Allow Google to index images
User-agent: Googlebot-Image
# =========================================== Allow: /assets/
# Bing-specific rules Disallow: /assets/leaflet/
# ===========================================
User-agent: Bingbot # ===========================================
Allow: / # Bing-specific rules
Crawl-delay: 2 # ===========================================
User-agent: Bingbot
# =========================================== Allow: /
# Other major search engines Crawl-delay: 2
# ===========================================
User-agent: DuckDuckBot # ===========================================
Allow: / # Other major search engines
Crawl-delay: 2 # ===========================================
User-agent: DuckDuckBot
User-agent: Slurp Allow: /
Allow: / Crawl-delay: 2
Crawl-delay: 2
User-agent: Slurp
User-agent: Yandex Allow: /
Allow: / Crawl-delay: 2
Crawl-delay: 5
User-agent: Yandex
User-agent: Baiduspider Allow: /
Allow: / Crawl-delay: 5
Crawl-delay: 5
User-agent: Baiduspider
# =========================================== Allow: /
# AI/LLM Crawlers (Answer Engine Optimization) Crawl-delay: 5
# ===========================================
User-agent: GPTBot # ===========================================
Allow: / # AI/LLM Crawlers (Answer Engine Optimization)
Allow: /businessListings # ===========================================
Allow: /business/* User-agent: GPTBot
Disallow: /admin/ Allow: /
Disallow: /account Allow: /businessListings
Allow: /business/*
User-agent: ChatGPT-User Disallow: /admin/
Allow: / Disallow: /account
User-agent: Claude-Web User-agent: ChatGPT-User
Allow: / Allow: /
User-agent: Anthropic-AI User-agent: Claude-Web
Allow: / Allow: /
User-agent: PerplexityBot User-agent: Anthropic-AI
Allow: / Allow: /
User-agent: Cohere-ai User-agent: PerplexityBot
Allow: / Allow: /
# =========================================== User-agent: Cohere-ai
# Block unwanted bots Allow: /
# ===========================================
User-agent: AhrefsBot # ===========================================
Disallow: / # Block unwanted bots
# ===========================================
User-agent: SemrushBot User-agent: AhrefsBot
Disallow: / Disallow: /
User-agent: MJ12bot User-agent: SemrushBot
Disallow: / Disallow: /
User-agent: DotBot User-agent: MJ12bot
Disallow: / Disallow: /
User-agent: BLEXBot User-agent: DotBot
Disallow: / Disallow: /
# =========================================== User-agent: BLEXBot
# Sitemap locations Disallow: /
# ===========================================
# Main sitemap index (dynamically generated, contains all sub-sitemaps) # ===========================================
Sitemap: https://biz-match.com/bizmatch/sitemap.xml # Sitemap locations
# ===========================================
# Individual sitemaps (auto-listed in sitemap index) # Main sitemap index (dynamically generated, contains all sub-sitemaps)
# - https://biz-match.com/bizmatch/sitemap/static.xml Sitemap: https://www.bizmatch.net/bizmatch/sitemap.xml
# - https://biz-match.com/bizmatch/sitemap/business-1.xml
# - https://biz-match.com/bizmatch/sitemap/commercial-1.xml # Individual sitemaps (auto-listed in sitemap index)
# - https://www.bizmatch.net/bizmatch/sitemap/static.xml
# =========================================== # - https://www.bizmatch.net/bizmatch/sitemap/business-1.xml
# Host directive (for Yandex) # - https://www.bizmatch.net/bizmatch/sitemap/commercial-1.xml
# ===========================================
Host: https://biz-match.com # ===========================================
# Host directive (for Yandex)
# ===========================================
Host: https://www.bizmatch.net

59
debug-inarray.ts Normal file
View File

@@ -0,0 +1,59 @@
import { and, inArray, sql, SQL } from 'drizzle-orm';
import { businesses_json, users_json } from './bizmatch-server/src/drizzle/schema';
// Mock criteria similar to what the user used
const criteria: any = {
types: ['retail'],
brokerName: 'page',
criteriaType: 'businessListings'
};
const user = { role: 'guest', email: 'timo@example.com' };
function getWhereConditions(criteria: any, user: any): SQL[] {
const whereConditions: SQL[] = [];
// Category filter
if (criteria.types && criteria.types.length > 0) {
// Suspected problematic line:
whereConditions.push(inArray(sql`${businesses_json.data}->>'type'`, criteria.types));
}
// Broker filter
if (criteria.brokerName) {
const firstname = criteria.brokerName;
const lastname = criteria.brokerName;
whereConditions.push(
sql`((${users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${users_json.data}->>'lastname') ILIKE ${`%${lastname}%` bubble})`
);
}
// Draft check
if (user?.role !== 'admin') {
whereConditions.push(
sql`((${ businesses_json.email } = ${ user?.email || null}) OR(${ businesses_json.data } ->> 'draft')::boolean IS NOT TRUE)`
);
}
return whereConditions;
}
const conditions = getWhereConditions(criteria, user);
const combined = and(...conditions);
console.log('--- Conditions Count ---');
console.log(conditions.length);
console.log('--- Generated SQL Fragment ---');
// We need a dummy query to see the full SQL
// Since we don't have a real DB connection here, we just inspect the SQL parts
// Drizzle conditions can be serialized to SQL strings
// This is a simplified test
try {
// In a real environment we would use a dummy pg adapter
console.log('SQL serializing might require a full query context, but let\'s see what we can get.');
} catch (e) {
console.error(e);
}