Compare commits

..

No commits in common. "master" and "noKeycloak" have entirely different histories.

133 changed files with 36278 additions and 3656 deletions

195
README.md
View File

@ -1,195 +0,0 @@
# bizmatch-project
Monorepo bestehend aus **Client** (`bizmatch-project/bizmatch`) und **Server/API** (`bizmatch-project/bizmatch-server`). Diese README führt dich vom frischen Clone bis zum laufenden System mit Produktivdaten im lokalen Dev-Setup.
---
## Voraussetzungen
- **Node.js** ≥ 20.x (empfohlen LTS) und **npm**
- **Docker** ≥ 24.x und **Docker Compose**
- Netzwerkzugriff auf die lokalen Ports (Standard: App 3001, Postgres 5433)
> **Hinweis zu Container-Namen/Ports**
> In Beispielen wird der DB-Container als `bizmatchdb` angesprochen. Falls deine Compose andere Namen/Ports nutzt (z.B. `bizmatchdb-prod` oder Ports 5433/3001), passe die Befehle entsprechend an.
---
## Repository-Struktur (Auszug)
```
bizmatch-project/
├─ bizmatch/ # Client (Angular/React/…)
├─ bizmatch-server/ # Server (NestJS + Postgres via Docker)
│ ├─ docker-compose.yml
│ ├─ env.prod # Umgebungsvariablen (Beispiel)
│ ├─ bizmatchdb-data-prod/ # (Volume-Pfad für Postgres-Daten)
│ └─ initdb/ # (optional: SQL-Skripte für Erstinitialisierung)
└─ README.md
```
---
## 1) Client starten (Ordner `bizmatch`)
```bash
cd ~/git/bizmatch-project/bizmatch
npm install
npm start
```
- Der Client startet im Dev-Modus (Standardport: meist `http://localhost:4200` oder projektspezifisch; siehe `package.json`).
- API-URL ggf. in den Client-Env-Dateien anpassen (z.B. `environment.ts`).
---
## 2) Server & Datenbank starten (Ordner `bizmatch-server`)
### 2.1 .env-Datei anlegen
Lege im Ordner `bizmatch-server` eine `.env` (oder `env.prod`) mit folgenden **Beispiel-/Dummy-Werten** an:
```
POSTGRES_DB=bizmatch
POSTGRES_USER=bizmatch
POSTGRES_PASSWORD=qG5LZhL7Y3
DATABASE_URL=postgresql://bizmatch:qG5LZhL7Y3@postgres:5432/bizmatch
OPENAI_API_KEY=sk-proj-3PVgp1dMTxnigr4nxgg
```
> **Wichtig:** Wenn du `DATABASE_URL` verwendest und dein Passwort Sonderzeichen wie `@ : / % # ?` enthält, **URL-encoden** (z.B. `@``%40`). Alternativ nur die Einzel-Variablen `POSTGRES_*` in der App verwenden.
### 2.2 Docker-Services starten
```bash
cd ~/git/bizmatch-project/bizmatch-server
# Erststart/Neustart der Services
docker compose up -d
```
- Der Server-Container baut die App (NestJS) und startet auf Port **3001** (Host), intern **3000** (Container), sofern so in `docker-compose.yml` konfiguriert.
- Postgres läuft im Container auf **5432**; per Port-Mapping meist auf **5433** am Host erreichbar (siehe `docker-compose.yml`).
> Warte nach dem Start, bis in den DB-Logs „database system is ready to accept connections“ erscheint:
>
> ```bash
> docker logs -f bizmatchdb
> ```
---
## 3) Produktiv-Dump lokal importieren
Falls du einen Dump aus der Produktion hast (Datei `prod.dump`), kannst du ihn in deine lokale DB importieren.
### 3.1 Dump in den DB-Container kopieren
```bash
# im Ordner bizmatch-server
docker cp prod.dump bizmatchdb:/tmp/prod.dump
```
> **Container-Name:** Falls dein DB-Container anders heißt (z.B. `bizmatchdb-prod`), ersetze den Namen im Befehl entsprechend.
### 3.2 Restore ausführen
```bash
docker exec -it bizmatchdb \
sh -c 'pg_restore -U "$POSTGRES_USER" --clean --no-owner -d "$POSTGRES_DB" /tmp/prod.dump'
```
- `--clean` löscht vorhandene Objekte vor dem Einspielen
- `--no-owner` ignoriert Besitzer/Role-Bindungen (praktisch für Dev)
### 3.3 Smoke-Test: DB erreichbar?
```bash
# Ping/Verbindung testen (pSQL muss im Container verfügbar sein)
docker exec -it bizmatchdb \
sh -lc 'PGPASSWORD="$POSTGRES_PASSWORD" psql -h /var/run/postgresql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "select current_user, now();"'
```
---
## 4) Häufige Probleme & Lösungen
### 4.1 `password authentication failed for user "bizmatch"`
- Prüfe, ob die Passwortänderung **in der DB** erfolgt ist (Env-Änderung allein genügt nicht, wenn das Volume existiert).
- Passwort in Postgres setzen:
```bash
docker exec -u postgres -it bizmatchdb \
psql -d postgres -c "ALTER ROLE bizmatch WITH LOGIN PASSWORD 'NEUES_PWD';"
```
- App-Umgebung (`.env`) anpassen und App neu starten:
```bash
docker compose restart app
```
- Bei Nutzung von `DATABASE_URL`: Sonderzeichen **URL-encoden**.
### 4.2 Container-Hostnamen stimmen nicht
- Innerhalb des Compose-Netzwerks ist der **Service-Name** der Host (z.B. `postgres` oder `postgres-prod`). Achte darauf, dass `DB_HOST`/`DATABASE_URL` dazu passen.
### 4.3 Dump/Restore vs. Datenverzeichnis-Kopie
- **Empfehlung:** `pg_dump/pg_restore` für Prod→Dev.
- Ganze Datenverzeichnisse (Volume) nur **bei gestoppter** DB und **identischer Postgres-Major-Version** kopieren.
### 4.4 Ports
- API nicht erreichbar? Prüfe Port-Mapping in `docker-compose.yml` (z.B. `3001:3000`) und Firewall.
- Postgres-Hostport (z.B. `5433`) gegen Client-Konfiguration prüfen.
---
## 5) Nützliche Befehle (Cheatsheet)
```bash
# Compose starten/stoppen
cd ~/git/bizmatch-project/bizmatch-server
docker compose up -d
docker compose stop
# Logs
docker logs -f bizmatchdb
docker logs -f bizmatch-app
# Shell in Container
docker exec -it bizmatchdb sh
# Datenbankbenutzer-Passwort ändern
docker exec -u postgres -it bizmatchdb \
psql -d postgres -c "ALTER ROLE bizmatch WITH LOGIN PASSWORD 'NEUES_PWD';"
# Dump aus laufender DB (vom Host, falls Port veröffentlicht ist)
PGPASSWORD="$POSTGRES_PASSWORD" \
pg_dump -h 127.0.0.1 -p 5433 -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
-F c -Z 9 -f ./prod.dump
```
---
## 6) Sicherheit & Datenschutz
- Lege **keine echten Secrets** (API-Keys, Prod-Passwörter) im Repo ab. Nutze `.env`-Dateien außerhalb der Versionskontrolle oder einen Secrets-Manager.
- Bei Produktivdaten in Dev: **Anonymisierung** (Masking) für personenbezogene Daten erwägen.
---
## 7) Erweiterungen (optional)
- **Init-Skripte**: Lege SQL-Dateien in `bizmatch-server/initdb/` ab, um beim Erststart Benutzer/Schema anzulegen.
- **Multi-Stage Dockerfile** für den App-Container (schnellere, reproduzierbare Builds ohne devDependencies).
- **Makefile/Skripte** für häufige Tasks (z.B. `make db-backup`, `make db-restore`).
---
## 8) Support
Bei Fragen zu Setup, Dumps oder Container-Namen/Ports: Logs und Compose-Datei prüfen, anschließend die oben beschriebenen Tests (DNS/Ports, psql) durchführen. Anschließend Issue/Notiz anlegen mit Logs & `docker-compose.yml`-Ausschnitt.

View File

@ -0,0 +1,4 @@
REALM=bizmatch-dev
usersURL=/admin/realms/bizmatch-dev/users
WEB_HOST=https://dev.bizmatch.net
STRIPE_WEBHOOK_SECRET=whsec_w2yvJY8qFMfO5wJgyNHCn6oYT7o2J5pS

View File

@ -0,0 +1,2 @@
REALM=bizmatch
WEB_HOST=https://www.bizmatch.net

View File

@ -1,19 +0,0 @@
# Build Stage
FROM node:18-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Runtime Stage
FROM node:18-alpine
WORKDIR /app
COPY --from=build /app/dist /app/dist
COPY --from=build /app/package*.json /app/
RUN npm install --production
CMD ["node", "dist/main.js"]

View File

@ -1,45 +0,0 @@
# ~/git/bizmatch-project/bizmatch-server/docker-compose.yml
services:
app:
image: node:22-alpine
container_name: bizmatch-app
working_dir: /app
volumes:
- ./:/app # Code liegt hier direkt im Ordner der Compose
ports:
- '3001:3000' # Host 3001 -> Container 3000
env_file:
- path: ./.env
required: true
environment:
- NODE_ENV=development # Prod-Modus (vorher stand fälschlich "development")
- DATABASE_URL
# Hinweis: npm ci nutzt package-lock.json; falls nicht vorhanden, nimm "npm install"
command: sh -c "npm ci && npm run build && node dist/src/main.js"
restart: unless-stopped
depends_on:
- postgres
networks:
- bizmatch
postgres:
container_name: bizmatchdb
image: postgres:17-alpine # Version pinnen ist stabiler als "latest"
restart: unless-stopped
volumes:
- ${PWD}/bizmatchdb-data:/var/lib/postgresql/data # Daten liegen im Server-Repo
env_file:
- path: ./.env
required: true
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- '5433:5432' # Host 5433 -> Container 5432
networks:
- bizmatch
networks:
bizmatch:
external: true # einmalig anlegen: docker network create bizmatch-prod

View File

@ -3,6 +3,7 @@ export default defineConfig({
schema: './src/drizzle/schema.ts', schema: './src/drizzle/schema.ts',
out: './src/drizzle/migrations', out: './src/drizzle/migrations',
dialect: 'postgresql', dialect: 'postgresql',
// driver: 'pg',
dbCredentials: { dbCredentials: {
url: process.env.DATABASE_URL, url: process.env.DATABASE_URL,
}, },

View File

@ -30,7 +30,6 @@
"@nestjs/common": "^11.0.11", "@nestjs/common": "^11.0.11",
"@nestjs/config": "^4.0.0", "@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.11", "@nestjs/core": "^11.0.11",
"@nestjs/cli": "^11.0.11",
"@nestjs/platform-express": "^11.0.11", "@nestjs/platform-express": "^11.0.11",
"@types/stripe": "^8.0.417", "@types/stripe": "^8.0.417",
"body-parser": "^1.20.2", "body-parser": "^1.20.2",

Binary file not shown.

View File

@ -20,31 +20,21 @@ export class AuthController {
} }
try { try {
// Step 1: Get the user by email address // Schritt 1: Hole den Benutzer anhand der E-Mail-Adresse
const userRecord = await this.firebaseAdmin.auth().getUserByEmail(email); const userRecord = await this.firebaseAdmin.auth().getUserByEmail(email);
if (userRecord.emailVerified) { if (userRecord.emailVerified) {
// Even if already verified, we'll still return a valid token return { message: 'Email is already verified' };
const customToken = await this.firebaseAdmin.auth().createCustomToken(userRecord.uid);
return {
message: 'Email is already verified',
token: customToken,
};
} }
// Step 2: Update the user status to set emailVerified to true // Schritt 2: Aktualisiere den Benutzerstatus
// Hinweis: Wir können den oobCode nicht serverseitig validieren.
// Wir nehmen an, dass der oobCode korrekt ist, da er von Firebase generiert wurde.
await this.firebaseAdmin.auth().updateUser(userRecord.uid, { await this.firebaseAdmin.auth().updateUser(userRecord.uid, {
emailVerified: true, emailVerified: true,
}); });
// Step 3: Generate a custom Firebase token for the user return { message: 'Email successfully verified' };
// This token can be used on the client side to authenticate with Firebase
const customToken = await this.firebaseAdmin.auth().createCustomToken(userRecord.uid);
return {
message: 'Email successfully verified',
token: customToken,
};
} catch (error) { } catch (error) {
throw new HttpException(error.message || 'Failed to verify email', HttpStatus.BAD_REQUEST); throw new HttpException(error.message || 'Failed to verify email', HttpStatus.BAD_REQUEST);
} }

View File

@ -16,13 +16,7 @@ const { Pool } = pkg;
inject: [ConfigService, WINSTON_MODULE_PROVIDER, ClsService], inject: [ConfigService, WINSTON_MODULE_PROVIDER, ClsService],
useFactory: async (configService: ConfigService, logger: Logger, cls: ClsService) => { useFactory: async (configService: ConfigService, logger: Logger, cls: ClsService) => {
const connectionString = configService.get<string>('DATABASE_URL'); const connectionString = configService.get<string>('DATABASE_URL');
// const dbHost = configService.get<string>('DB_HOST'); console.log('--->',connectionString)
// const dbPort = configService.get<string>('DB_PORT');
// const dbName = configService.get<string>('DB_NAME');
// const dbUser = configService.get<string>('DB_USER');
const dbPassword = configService.get<string>('DB_PASSWORD');
// logger.info(`Drizzle Connection - URL: ${connectionString}, Host: ${dbHost}, Port: ${dbPort}, DB: ${dbName}, User: ${dbUser}`);
// console.log(`---> Drizzle Connection - URL: ${connectionString}, Host: ${dbHost}, Port: ${dbPort}, DB: ${dbName}, User: ${dbUser}`);
const pool = new Pool({ const pool = new Pool({
connectionString, connectionString,
// ssl: true, // Falls benötigt // ssl: true, // Falls benötigt

View File

@ -8,56 +8,6 @@ export const customerSubTypeEnum = pgEnum('customerSubType', ['broker', 'cpa', '
export const listingsCategoryEnum = pgEnum('listingsCategory', ['commercialProperty', 'business']); export const listingsCategoryEnum = pgEnum('listingsCategory', ['commercialProperty', 'business']);
export const subscriptionTypeEnum = pgEnum('subscriptionType', ['free', 'professional', 'broker']); export const subscriptionTypeEnum = pgEnum('subscriptionType', ['free', 'professional', 'broker']);
// Neue JSONB-basierte Tabellen
export const users_json = pgTable(
'users_json',
{
id: uuid('id').primaryKey().defaultRandom().notNull(),
email: varchar('email', { length: 255 }).notNull().unique(),
data: jsonb('data'),
},
table => ({
emailIdx: index('idx_users_json_email').on(table.email),
}),
);
export const businesses_json = pgTable(
'businesses_json',
{
id: uuid('id').primaryKey().defaultRandom().notNull(),
email: varchar('email', { length: 255 }).references(() => users_json.email),
data: jsonb('data'),
},
table => ({
emailIdx: index('idx_businesses_json_email').on(table.email),
}),
);
export const commercials_json = pgTable(
'commercials_json',
{
id: uuid('id').primaryKey().defaultRandom().notNull(),
email: varchar('email', { length: 255 }).references(() => users_json.email),
data: jsonb('data'),
},
table => ({
emailIdx: index('idx_commercials_json_email').on(table.email),
}),
);
export const listing_events_json = pgTable(
'listing_events_json',
{
id: uuid('id').primaryKey().defaultRandom().notNull(),
email: varchar('email', { length: 255 }),
data: jsonb('data'),
},
table => ({
emailIdx: index('idx_listing_events_json_email').on(table.email),
}),
);
// Bestehende Tabellen bleiben unverändert
export const users = pgTable( export const users = pgTable(
'users', 'users',
{ {
@ -84,6 +34,10 @@ export const users = pgTable(
subscriptionPlan: subscriptionTypeEnum('subscriptionPlan'), subscriptionPlan: subscriptionTypeEnum('subscriptionPlan'),
location: jsonb('location'), location: jsonb('location'),
showInDirectory: boolean('showInDirectory').default(true), showInDirectory: boolean('showInDirectory').default(true),
// city: varchar('city', { length: 255 }),
// state: char('state', { length: 2 }),
// latitude: doublePrecision('latitude'),
// longitude: doublePrecision('longitude'),
}, },
table => ({ table => ({
locationUserCityStateIdx: index('idx_user_location_city_state').on( locationUserCityStateIdx: index('idx_user_location_city_state').on(
@ -91,7 +45,6 @@ export const users = pgTable(
), ),
}), }),
); );
export const businesses = pgTable( export const businesses = pgTable(
'businesses', 'businesses',
{ {
@ -103,7 +56,7 @@ export const businesses = pgTable(
price: doublePrecision('price'), price: doublePrecision('price'),
favoritesForUser: varchar('favoritesForUser', { length: 30 }).array(), favoritesForUser: varchar('favoritesForUser', { length: 30 }).array(),
draft: boolean('draft'), draft: boolean('draft'),
listingsCategory: listingsCategoryEnum('listingsCategory'), listingsCategory: listingsCategoryEnum('listingsCategory'), //varchar('listingsCategory', { length: 255 }),
realEstateIncluded: boolean('realEstateIncluded'), realEstateIncluded: boolean('realEstateIncluded'),
leasedLocation: boolean('leasedLocation'), leasedLocation: boolean('leasedLocation'),
franchiseResale: boolean('franchiseResale'), franchiseResale: boolean('franchiseResale'),
@ -120,6 +73,14 @@ export const businesses = pgTable(
created: timestamp('created'), created: timestamp('created'),
updated: timestamp('updated'), updated: timestamp('updated'),
location: jsonb('location'), location: jsonb('location'),
// city: varchar('city', { length: 255 }),
// state: char('state', { length: 2 }),
// zipCode: integer('zipCode'),
// county: varchar('county', { length: 255 }),
// street: varchar('street', { length: 255 }),
// housenumber: varchar('housenumber', { length: 10 }),
// latitude: doublePrecision('latitude'),
// longitude: doublePrecision('longitude'),
}, },
table => ({ table => ({
locationBusinessCityStateIdx: index('idx_business_location_city_state').on( locationBusinessCityStateIdx: index('idx_business_location_city_state').on(
@ -127,7 +88,6 @@ export const businesses = pgTable(
), ),
}), }),
); );
export const commercials = pgTable( export const commercials = pgTable(
'commercials', 'commercials',
{ {
@ -139,13 +99,21 @@ export const commercials = pgTable(
description: text('description'), description: text('description'),
price: doublePrecision('price'), price: doublePrecision('price'),
favoritesForUser: varchar('favoritesForUser', { length: 30 }).array(), favoritesForUser: varchar('favoritesForUser', { length: 30 }).array(),
listingsCategory: listingsCategoryEnum('listingsCategory'), listingsCategory: listingsCategoryEnum('listingsCategory'), //listingsCategory: varchar('listingsCategory', { length: 255 }),
draft: boolean('draft'), draft: boolean('draft'),
imageOrder: varchar('imageOrder', { length: 200 }).array(), imageOrder: varchar('imageOrder', { length: 200 }).array(),
imagePath: varchar('imagePath', { length: 200 }), imagePath: varchar('imagePath', { length: 200 }),
created: timestamp('created'), created: timestamp('created'),
updated: timestamp('updated'), updated: timestamp('updated'),
location: jsonb('location'), location: jsonb('location'),
// city: varchar('city', { length: 255 }),
// state: char('state', { length: 2 }),
// zipCode: integer('zipCode'),
// county: varchar('county', { length: 255 }),
// street: varchar('street', { length: 255 }),
// housenumber: varchar('housenumber', { length: 10 }),
// latitude: doublePrecision('latitude'),
// longitude: doublePrecision('longitude'),
}, },
table => ({ table => ({
locationCommercialsCityStateIdx: index('idx_commercials_location_city_state').on( locationCommercialsCityStateIdx: index('idx_commercials_location_city_state').on(
@ -153,19 +121,30 @@ export const commercials = pgTable(
), ),
}), }),
); );
// export const geo = pgTable('geo', {
export const listing_events = pgTable('listing_events', { // id: uuid('id').primaryKey().defaultRandom().notNull(),
// country: varchar('country', { length: 255 }).default('us'),
// state: char('state', { length: 2 }),
// city: varchar('city', { length: 255 }),
// zipCode: integer('zipCode'),
// county: varchar('county', { length: 255 }),
// street: varchar('street', { length: 255 }),
// housenumber: varchar('housenumber', { length: 10 }),
// latitude: doublePrecision('latitude'),
// longitude: doublePrecision('longitude'),
// });
export const listingEvents = pgTable('listing_events', {
id: uuid('id').primaryKey().defaultRandom().notNull(), id: uuid('id').primaryKey().defaultRandom().notNull(),
listingId: varchar('listing_id', { length: 255 }), listingId: varchar('listing_id', { length: 255 }), // Assuming listings are referenced by UUID, adjust as necessary
email: varchar('email', { length: 255 }), email: varchar('email', { length: 255 }),
eventType: varchar('event_type', { length: 50 }), eventType: varchar('event_type', { length: 50 }), // 'view', 'print', 'email', 'facebook', 'x', 'linkedin', 'contact'
eventTimestamp: timestamp('event_timestamp').defaultNow(), eventTimestamp: timestamp('event_timestamp').defaultNow(),
userIp: varchar('user_ip', { length: 45 }), userIp: varchar('user_ip', { length: 45 }), // Optional if you choose to track IP in frontend or backend
userAgent: varchar('user_agent', { length: 255 }), userAgent: varchar('user_agent', { length: 255 }), // Store User-Agent as string
locationCountry: varchar('location_country', { length: 100 }), locationCountry: varchar('location_country', { length: 100 }), // Country from IP
locationCity: varchar('location_city', { length: 100 }), locationCity: varchar('location_city', { length: 100 }), // City from IP
locationLat: varchar('location_lat', { length: 20 }), locationLat: varchar('location_lat', { length: 20 }), // Latitude from IP, stored as varchar
locationLng: varchar('location_lng', { length: 20 }), locationLng: varchar('location_lng', { length: 20 }), // Longitude from IP, stored as varchar
referrer: varchar('referrer', { length: 255 }), referrer: varchar('referrer', { length: 255 }), // Referrer URL if applicable
additionalData: jsonb('additional_data'), additionalData: jsonb('additional_data'), // JSON for any other optional data (like email, social shares etc.)
}); });

View File

@ -2,22 +2,17 @@ import { Inject, Injectable } from '@nestjs/common';
import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { ListingEvent } from 'src/models/db.model'; import { ListingEvent } from 'src/models/db.model';
import { Logger } from 'winston';
import * as schema from '../drizzle/schema'; import * as schema from '../drizzle/schema';
import { listing_events_json, PG_CONNECTION } from '../drizzle/schema'; import { listingEvents, PG_CONNECTION } from '../drizzle/schema';
@Injectable() @Injectable()
export class EventService { export class EventService {
constructor( constructor(
@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>,
) {} ) {}
async createEvent(event: ListingEvent) { async createEvent(event: ListingEvent) {
// Speichere das Event in der Datenbank // Speichere das Event in der Datenbank
event.eventTimestamp = new Date(); event.eventTimestamp = new Date();
const { id, email, ...rest } = event; await this.conn.insert(listingEvents).values(event).execute();
const convertedEvent = { email, data: rest };
await this.conn.insert(listing_events_json).values(convertedEvent).execute();
} }
} }

View File

@ -1,11 +1,12 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { and, arrayContains, asc, count, desc, eq, gte, inArray, lte, or, SQL, sql } from 'drizzle-orm'; import { and, arrayContains, asc, count, desc, eq, gte, ilike, inArray, lte, ne, or, SQL, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; 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, PG_CONNECTION } from '../drizzle/schema';
import { FileService } from '../file/file.service';
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';
@ -16,6 +17,7 @@ export class BusinessListingService {
constructor( constructor(
@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 fileService?: FileService,
private geoService?: GeoService, private geoService?: GeoService,
) {} ) {}
@ -23,101 +25,101 @@ export class BusinessListingService {
const whereConditions: SQL[] = []; const whereConditions: SQL[] = [];
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.location}->>'name' ilike ${criteria.city.name}`);
//whereConditions.push(ilike(businesses.location-->'city', `%${criteria.city.name}%`));
} }
if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) { if (criteria.city && criteria.radius && criteria.searchType === '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, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`);
} }
if (criteria.types && criteria.types.length > 0) { if (criteria.types && criteria.types.length > 0) {
whereConditions.push(inArray(sql`${businesses_json.data}->>'type'`, criteria.types)); whereConditions.push(inArray(businesses.type, criteria.types));
} }
if (criteria.state) { if (criteria.state) {
whereConditions.push(sql`(${businesses_json.data}->'location'->>'state') = ${criteria.state}`); whereConditions.push(sql`${businesses.location}->>'state' = ${criteria.state}`);
} }
if (criteria.minPrice) { if (criteria.minPrice) {
whereConditions.push(gte(sql`(${businesses_json.data}->>'price')::double precision`, criteria.minPrice)); whereConditions.push(gte(businesses.price, criteria.minPrice));
} }
if (criteria.maxPrice) { if (criteria.maxPrice) {
whereConditions.push(lte(sql`(${businesses_json.data}->>'price')::double precision`, criteria.maxPrice)); whereConditions.push(lte(businesses.price, criteria.maxPrice));
} }
if (criteria.minRevenue) { if (criteria.minRevenue) {
whereConditions.push(gte(sql`(${businesses_json.data}->>'salesRevenue')::double precision`, criteria.minRevenue)); whereConditions.push(gte(businesses.salesRevenue, criteria.minRevenue));
} }
if (criteria.maxRevenue) { if (criteria.maxRevenue) {
whereConditions.push(lte(sql`(${businesses_json.data}->>'salesRevenue')::double precision`, criteria.maxRevenue)); whereConditions.push(lte(businesses.salesRevenue, criteria.maxRevenue));
} }
if (criteria.minCashFlow) { if (criteria.minCashFlow) {
whereConditions.push(gte(sql`(${businesses_json.data}->>'cashFlow')::double precision`, criteria.minCashFlow)); whereConditions.push(gte(businesses.cashFlow, criteria.minCashFlow));
} }
if (criteria.maxCashFlow) { if (criteria.maxCashFlow) {
whereConditions.push(lte(sql`(${businesses_json.data}->>'cashFlow')::double precision`, criteria.maxCashFlow)); whereConditions.push(lte(businesses.cashFlow, criteria.maxCashFlow));
} }
if (criteria.minNumberEmployees) { if (criteria.minNumberEmployees) {
whereConditions.push(gte(sql`(${businesses_json.data}->>'employees')::integer`, criteria.minNumberEmployees)); whereConditions.push(gte(businesses.employees, criteria.minNumberEmployees));
} }
if (criteria.maxNumberEmployees) { if (criteria.maxNumberEmployees) {
whereConditions.push(lte(sql`(${businesses_json.data}->>'employees')::integer`, criteria.maxNumberEmployees)); whereConditions.push(lte(businesses.employees, criteria.maxNumberEmployees));
} }
if (criteria.establishedMin) { if (criteria.establishedSince) {
whereConditions.push(gte(sql`(${businesses_json.data}->>'established')::integer`, criteria.establishedMin)); whereConditions.push(gte(businesses.established, criteria.establishedSince));
}
if (criteria.establishedUntil) {
whereConditions.push(lte(businesses.established, criteria.establishedUntil));
} }
if (criteria.realEstateChecked) { if (criteria.realEstateChecked) {
whereConditions.push(eq(sql`(${businesses_json.data}->>'realEstateIncluded')::boolean`, criteria.realEstateChecked)); whereConditions.push(eq(businesses.realEstateIncluded, criteria.realEstateChecked));
} }
if (criteria.leasedLocation) { if (criteria.leasedLocation) {
whereConditions.push(eq(sql`(${businesses_json.data}->>'leasedLocation')::boolean`, criteria.leasedLocation)); whereConditions.push(eq(businesses.leasedLocation, criteria.leasedLocation));
} }
if (criteria.franchiseResale) { if (criteria.franchiseResale) {
whereConditions.push(eq(sql`(${businesses_json.data}->>'franchiseResale')::boolean`, criteria.franchiseResale)); whereConditions.push(eq(businesses.franchiseResale, criteria.franchiseResale));
} }
if (criteria.title) { if (criteria.title) {
whereConditions.push(sql`(${businesses_json.data}->>'title') ILIKE ${`%${criteria.title}%`} OR (${businesses_json.data}->>'description') ILIKE ${`%${criteria.title}%`}`); whereConditions.push(or(ilike(businesses.title, `%${criteria.title}%`), ilike(businesses.description, `%${criteria.title}%`)));
} }
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(or(ilike(schema.users.firstname, `%${firstname}%`), ilike(schema.users.lastname, `%${lastname}%`)));
} else { } else {
whereConditions.push(sql`(${users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} AND (${users_json.data}->>'lastname') ILIKE ${`%${lastname}%`}`); whereConditions.push(and(ilike(schema.users.firstname, `%${firstname}%`), ilike(schema.users.lastname, `%${lastname}%`)));
} }
} }
if (criteria.email) { if (!user?.roles?.includes('ADMIN')) {
whereConditions.push(eq(users_json.email, criteria.email)); whereConditions.push(or(eq(businesses.email, user?.username), ne(businesses.draft, true)));
} }
if (user?.role !== 'admin') { whereConditions.push(and(eq(schema.users.customerType, 'professional'), eq(schema.users.customerSubType, 'broker')));
whereConditions.push(or(eq(businesses_json.email, user?.email), sql`(${businesses_json.data}->>'draft')::boolean IS NOT TRUE`));
}
whereConditions.push(and(sql`(${users_json.data}->>'customerType') = 'professional'`, sql`(${users_json.data}->>'customerSubType') = 'broker'`));
return whereConditions; return whereConditions;
} }
async searchBusinessListings(criteria: BusinessListingCriteria, user: JwtUser) { async searchBusinessListings(criteria: BusinessListingCriteria, user: JwtUser) {
const start = criteria.start ? criteria.start : 0; const start = criteria.start ? criteria.start : 0;
const length = criteria.length ? criteria.length : 12; const length = criteria.length ? criteria.length : 12;
const query = this.conn const query = this.conn
.select({ .select({
business: businesses_json, business: businesses,
brokerFirstName: sql`${users_json.data}->>'firstname'`.as('brokerFirstName'), brokerFirstName: schema.users.firstname,
brokerLastName: sql`${users_json.data}->>'lastname'`.as('brokerLastName'), brokerLastName: schema.users.lastname,
}) })
.from(businesses_json) .from(businesses)
.leftJoin(users_json, eq(businesses_json.email, users_json.email)); .leftJoin(schema.users, eq(businesses.email, schema.users.email));
const whereConditions = this.getWhereConditions(criteria, user); const whereConditions = this.getWhereConditions(criteria, user);
@ -129,69 +131,39 @@ export class BusinessListingService {
// Sortierung // Sortierung
switch (criteria.sortBy) { switch (criteria.sortBy) {
case 'priceAsc': case 'priceAsc':
query.orderBy(asc(sql`(${businesses_json.data}->>'price')::double precision`)); query.orderBy(asc(businesses.price));
break; break;
case 'priceDesc': case 'priceDesc':
query.orderBy(desc(sql`(${businesses_json.data}->>'price')::double precision`)); query.orderBy(desc(businesses.price));
break; break;
case 'srAsc': case 'srAsc':
query.orderBy(asc(sql`(${businesses_json.data}->>'salesRevenue')::double precision`)); query.orderBy(asc(businesses.salesRevenue));
break; break;
case 'srDesc': case 'srDesc':
query.orderBy(desc(sql`(${businesses_json.data}->>'salesRevenue')::double precision`)); query.orderBy(desc(businesses.salesRevenue));
break; break;
case 'cfAsc': case 'cfAsc':
query.orderBy(asc(sql`(${businesses_json.data}->>'cashFlow')::double precision`)); query.orderBy(asc(businesses.cashFlow));
break; break;
case 'cfDesc': case 'cfDesc':
query.orderBy(desc(sql`(${businesses_json.data}->>'cashFlow')::double precision`)); query.orderBy(desc(businesses.cashFlow));
break; break;
case 'creationDateFirst': case 'creationDateFirst':
query.orderBy(asc(sql`${businesses_json.data}->>'created'`)); query.orderBy(asc(businesses.created));
break; break;
case 'creationDateLast': case 'creationDateLast':
query.orderBy(desc(sql`${businesses_json.data}->>'created'`)); query.orderBy(desc(businesses.created));
break; break;
default: { default:
// NEU (created < 14 Tage) > UPDATED (updated < 14 Tage) > Rest // Keine spezifische Sortierung, Standardverhalten kann hier eingefügt werden
const recencyRank = sql`
CASE
WHEN ((${businesses_json.data}->>'created')::timestamptz >= (now() - interval '14 days')) THEN 2
WHEN ((${businesses_json.data}->>'updated')::timestamptz >= (now() - interval '14 days')) THEN 1
ELSE 0
END
`;
// Innerhalb der Gruppe:
// NEW → created DESC
// UPDATED → updated DESC
// Rest → created DESC
const groupTimestamp = sql`
CASE
WHEN ((${businesses_json.data}->>'created')::timestamptz >= (now() - interval '14 days'))
THEN (${businesses_json.data}->>'created')::timestamptz
WHEN ((${businesses_json.data}->>'updated')::timestamptz >= (now() - interval '14 days'))
THEN (${businesses_json.data}->>'updated')::timestamptz
ELSE (${businesses_json.data}->>'created')::timestamptz
END
`;
query.orderBy(desc(recencyRank), desc(groupTimestamp), desc(sql`(${businesses_json.data}->>'created')::timestamptz`));
break; break;
} }
}
// Paginierung // Paginierung
query.limit(length).offset(start); query.limit(length).offset(start);
const data = await query; const data = await query;
const totalCount = await this.getBusinessListingsCount(criteria, user); const totalCount = await this.getBusinessListingsCount(criteria, user);
const results = data.map(r => ({ const results = data.map(r => r.business);
id: r.business.id,
email: r.business.email,
...(r.business.data as BusinessListing),
brokerFirstName: r.brokerFirstName,
brokerLastName: r.brokerLastName,
}));
return { return {
results, results,
totalCount, totalCount,
@ -199,7 +171,7 @@ 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).leftJoin(schema.users, eq(businesses.email, schema.users.email));
const whereConditions = this.getWhereConditions(criteria, user); const whereConditions = this.getWhereConditions(criteria, user);
@ -214,16 +186,16 @@ export class BusinessListingService {
async findBusinessesById(id: string, user: JwtUser): Promise<BusinessListing> { async findBusinessesById(id: string, user: JwtUser): Promise<BusinessListing> {
const conditions = []; const conditions = [];
if (user?.role !== 'admin') { if (!user?.roles?.includes('ADMIN')) {
conditions.push(or(eq(businesses_json.email, user?.email), sql`(${businesses_json.data}->>'draft')::boolean IS NOT TRUE`)); conditions.push(or(eq(businesses.email, user?.username), ne(businesses.draft, true)));
} }
conditions.push(eq(businesses_json.id, id)); conditions.push(sql`${businesses.id} = ${id}`);
const result = await this.conn const result = await this.conn
.select() .select()
.from(businesses_json) .from(businesses)
.where(and(...conditions)); .where(and(...conditions));
if (result.length > 0) { if (result.length > 0) {
return { id: result[0].id, email: result[0].email, ...(result[0].data as BusinessListing) } as BusinessListing; return result[0] as BusinessListing;
} else { } else {
throw new BadRequestException(`No entry available for ${id}`); throw new BadRequestException(`No entry available for ${id}`);
} }
@ -231,34 +203,35 @@ export class BusinessListingService {
async findBusinessesByEmail(email: string, user: JwtUser): Promise<BusinessListing[]> { async findBusinessesByEmail(email: string, user: JwtUser): Promise<BusinessListing[]> {
const conditions = []; const conditions = [];
conditions.push(eq(businesses_json.email, email)); conditions.push(eq(businesses.email, email));
if (email !== user?.email && user?.role !== 'admin') { if (email !== user?.username && (!user?.roles?.includes('ADMIN'))) {
conditions.push(sql`(${businesses_json.data}->>'draft')::boolean IS NOT TRUE`); conditions.push(ne(businesses.draft, true));
} }
const listings = await this.conn const listings = (await this.conn
.select() .select()
.from(businesses_json) .from(businesses)
.where(and(...conditions)); .where(and(...conditions))) as BusinessListing[];
return listings.map(l => ({ id: l.id, email: l.email, ...(l.data as BusinessListing) }) as BusinessListing);
}
return listings;
}
// #### Find Favorites ########################################
async findFavoriteListings(user: JwtUser): Promise<BusinessListing[]> { async findFavoriteListings(user: JwtUser): Promise<BusinessListing[]> {
const userFavorites = await this.conn const userFavorites = await this.conn
.select() .select()
.from(businesses_json) .from(businesses)
.where(arrayContains(sql`${businesses_json.data}->>'favoritesForUser'`, [user.email])); .where(arrayContains(businesses.favoritesForUser, [user.username]));
return userFavorites.map(l => ({ id: l.id, email: l.email, ...(l.data as BusinessListing) }) as BusinessListing); return userFavorites;
} }
// #### CREATE ########################################
async createListing(data: BusinessListing): Promise<BusinessListing> { async createListing(data: BusinessListing): Promise<BusinessListing> {
try { try {
data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date(); data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date();
data.updated = new Date(); data.updated = new Date();
BusinessListingSchema.parse(data); BusinessListingSchema.parse(data);
const { id, email, ...rest } = data; const convertedBusinessListing = data;
const convertedBusinessListing = { email, data: rest }; delete convertedBusinessListing.id;
const [createdListing] = await this.conn.insert(businesses_json).values(convertedBusinessListing).returning(); const [createdListing] = await this.conn.insert(businesses).values(convertedBusinessListing).returning();
return { id: createdListing.id, email: createdListing.email, ...(createdListing.data as BusinessListing) }; return createdListing;
} catch (error) { } catch (error) {
if (error instanceof ZodError) { if (error instanceof ZodError) {
const filteredErrors = error.errors const filteredErrors = error.errors
@ -272,24 +245,15 @@ export class BusinessListingService {
throw error; throw error;
} }
} }
// #### UPDATE Business ########################################
async updateBusinessListing(id: string, data: BusinessListing, user: JwtUser): Promise<BusinessListing> { async updateBusinessListing(id: string, data: BusinessListing): Promise<BusinessListing> {
try { try {
const [existingListing] = await this.conn.select().from(businesses_json).where(eq(businesses_json.id, id));
if (!existingListing) {
throw new NotFoundException(`Business listing with id ${id} not found`);
}
data.updated = new Date(); data.updated = new Date();
data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date(); data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date();
if (existingListing.email === user?.email) {
data.favoritesForUser = (<BusinessListing>existingListing.data).favoritesForUser || [];
}
BusinessListingSchema.parse(data); BusinessListingSchema.parse(data);
const { id: _, email, ...rest } = data; const convertedBusinessListing = data;
const convertedBusinessListing = { email, data: rest }; const [updateListing] = await this.conn.update(businesses).set(convertedBusinessListing).where(eq(businesses.id, id)).returning();
const [updateListing] = await this.conn.update(businesses_json).set(convertedBusinessListing).where(eq(businesses_json.id, id)).returning(); return updateListing;
return { id: updateListing.id, email: updateListing.email, ...(updateListing.data as BusinessListing) };
} catch (error) { } catch (error) {
if (error instanceof ZodError) { if (error instanceof ZodError) {
const filteredErrors = error.errors const filteredErrors = error.errors
@ -303,17 +267,17 @@ export class BusinessListingService {
throw error; throw error;
} }
} }
// #### DELETE ########################################
async deleteListing(id: string): Promise<void> { async deleteListing(id: string): Promise<void> {
await this.conn.delete(businesses_json).where(eq(businesses_json.id, id)); await this.conn.delete(businesses).where(eq(businesses.id, id));
} }
// #### DELETE Favorite ###################################
async deleteFavorite(id: string, user: JwtUser): Promise<void> { async deleteFavorite(id: string, user: JwtUser): Promise<void> {
await this.conn await this.conn
.update(businesses_json) .update(businesses)
.set({ .set({
data: sql`jsonb_set(${businesses_json.data}, '{favoritesForUser}', array_remove((${businesses_json.data}->>'favoritesForUser')::jsonb, ${user.email}))`, favoritesForUser: sql`array_remove(${businesses.favoritesForUser}, ${user.username})`,
}) })
.where(eq(businesses_json.id, id)); .where(sql`${businesses.id} = ${id}`);
} }
} }

View File

@ -50,8 +50,8 @@ export class BusinessListingsController {
@UseGuards(OptionalAuthGuard) @UseGuards(OptionalAuthGuard)
@Put() @Put()
async update(@Request() req, @Body() listing: any) { async update(@Body() listing: any) {
return await this.listingsService.updateBusinessListing(listing.id, listing, req.user as JwtUser); return await this.listingsService.updateBusinessListing(listing.id, listing);
} }
@UseGuards(OptionalAuthGuard) @UseGuards(OptionalAuthGuard)

View File

@ -54,8 +54,8 @@ export class CommercialPropertyListingsController {
@UseGuards(OptionalAuthGuard) @UseGuards(OptionalAuthGuard)
@Put() @Put()
async update(@Request() req, @Body() listing: any) { async update(@Body() listing: any) {
return await this.listingsService.updateCommercialPropertyListing(listing.id, listing, req.user as JwtUser); return await this.listingsService.updateCommercialPropertyListing(listing.id, listing);
} }
@UseGuards(OptionalAuthGuard) @UseGuards(OptionalAuthGuard)

View File

@ -1,11 +1,11 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { and, arrayContains, asc, count, desc, eq, gte, inArray, lte, or, SQL, sql } from 'drizzle-orm'; import { and, arrayContains, asc, count, desc, eq, gte, ilike, inArray, lte, ne, or, SQL, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; 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 { commercials_json, PG_CONNECTION } from '../drizzle/schema'; import { commercials, PG_CONNECTION } from '../drizzle/schema';
import { FileService } from '../file/file.service'; 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';
@ -24,33 +24,33 @@ export class CommercialPropertyService {
const whereConditions: SQL[] = []; const whereConditions: SQL[] = [];
if (criteria.city && criteria.searchType === 'exact') { if (criteria.city && criteria.searchType === 'exact') {
whereConditions.push(sql`(${commercials_json.data}->'location'->>'name') ILIKE ${criteria.city.name}`); whereConditions.push(sql`${commercials.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) {
const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name); const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name);
whereConditions.push(sql`${getDistanceQuery(commercials_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`); whereConditions.push(sql`${getDistanceQuery(commercials, 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)); whereConditions.push(inArray(schema.commercials.type, criteria.types));
} }
if (criteria.state) { if (criteria.state) {
whereConditions.push(sql`(${commercials_json.data}->'location'->>'state') = ${criteria.state}`); whereConditions.push(sql`${schema.commercials.location}->>'state' = ${criteria.state}`);
} }
if (criteria.minPrice) { if (criteria.minPrice) {
whereConditions.push(gte(sql`(${commercials_json.data}->>'price')::double precision`, criteria.minPrice)); whereConditions.push(gte(schema.commercials.price, criteria.minPrice));
} }
if (criteria.maxPrice) { if (criteria.maxPrice) {
whereConditions.push(lte(sql`(${commercials_json.data}->>'price')::double precision`, criteria.maxPrice)); whereConditions.push(lte(schema.commercials.price, criteria.maxPrice));
} }
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(or(ilike(schema.commercials.title, `%${criteria.title}%`), ilike(schema.commercials.description, `%${criteria.title}%`)));
} }
if (user?.role !== 'admin') { if (!user?.roles?.includes('ADMIN')) {
whereConditions.push(or(eq(commercials_json.email, user?.email), sql`(${commercials_json.data}->>'draft')::boolean IS NOT TRUE`)); whereConditions.push(or(eq(commercials.email, user?.username), ne(commercials.draft, true)));
} }
// whereConditions.push(and(eq(schema.users.customerType, 'professional'))); // whereConditions.push(and(eq(schema.users.customerType, 'professional')));
return whereConditions; return whereConditions;
@ -59,7 +59,7 @@ export class CommercialPropertyService {
async searchCommercialProperties(criteria: CommercialPropertyListingCriteria, user: JwtUser): Promise<any> { async searchCommercialProperties(criteria: CommercialPropertyListingCriteria, user: JwtUser): Promise<any> {
const start = criteria.start ? criteria.start : 0; const start = criteria.start ? criteria.start : 0;
const length = criteria.length ? criteria.length : 12; const length = criteria.length ? criteria.length : 12;
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 }).from(commercials).leftJoin(schema.users, eq(commercials.email, schema.users.email));
const whereConditions = this.getWhereConditions(criteria, user); const whereConditions = this.getWhereConditions(criteria, user);
if (whereConditions.length > 0) { if (whereConditions.length > 0) {
@ -69,16 +69,16 @@ export class CommercialPropertyService {
// Sortierung // Sortierung
switch (criteria.sortBy) { switch (criteria.sortBy) {
case 'priceAsc': case 'priceAsc':
query.orderBy(asc(sql`(${commercials_json.data}->>'price')::double precision`)); query.orderBy(asc(commercials.price));
break; break;
case 'priceDesc': case 'priceDesc':
query.orderBy(desc(sql`(${commercials_json.data}->>'price')::double precision`)); query.orderBy(desc(commercials.price));
break; break;
case 'creationDateFirst': case 'creationDateFirst':
query.orderBy(asc(sql`${commercials_json.data}->>'created'`)); query.orderBy(asc(commercials.created));
break; break;
case 'creationDateLast': case 'creationDateLast':
query.orderBy(desc(sql`${commercials_json.data}->>'created'`)); query.orderBy(desc(commercials.created));
break; break;
default: default:
// Keine spezifische Sortierung, Standardverhalten kann hier eingefügt werden // Keine spezifische Sortierung, Standardverhalten kann hier eingefügt werden
@ -89,7 +89,7 @@ export class CommercialPropertyService {
query.limit(length).offset(start); query.limit(length).offset(start);
const data = await query; const data = await query;
const results = data.map(r => ({ id: r.commercial.id, email: r.commercial.email, ...(r.commercial.data as CommercialPropertyListing) })); const results = data.map(r => r.commercial);
const totalCount = await this.getCommercialPropertiesCount(criteria, user); const totalCount = await this.getCommercialPropertiesCount(criteria, user);
return { return {
@ -98,7 +98,7 @@ export class CommercialPropertyService {
}; };
} }
async getCommercialPropertiesCount(criteria: CommercialPropertyListingCriteria, user: JwtUser): Promise<number> { async getCommercialPropertiesCount(criteria: CommercialPropertyListingCriteria, user: JwtUser): Promise<number> {
const countQuery = this.conn.select({ value: count() }).from(commercials_json).leftJoin(schema.users_json, eq(commercials_json.email, schema.users_json.email)); const countQuery = this.conn.select({ value: count() }).from(schema.commercials).leftJoin(schema.users, eq(commercials.email, schema.users.email));
const whereConditions = this.getWhereConditions(criteria, user); const whereConditions = this.getWhereConditions(criteria, user);
if (whereConditions.length > 0) { if (whereConditions.length > 0) {
@ -113,16 +113,16 @@ export class CommercialPropertyService {
// #### Find by ID ######################################## // #### Find by ID ########################################
async findCommercialPropertiesById(id: string, user: JwtUser): Promise<CommercialPropertyListing> { async findCommercialPropertiesById(id: string, user: JwtUser): Promise<CommercialPropertyListing> {
const conditions = []; const conditions = [];
if (user?.role !== 'admin') { if (!user?.roles?.includes('ADMIN')) {
conditions.push(or(eq(commercials_json.email, user?.email), sql`(${commercials_json.data}->>'draft')::boolean IS NOT TRUE`)); conditions.push(or(eq(commercials.email, user?.username), ne(commercials.draft, true)));
} }
conditions.push(eq(commercials_json.id, id)); conditions.push(sql`${commercials.id} = ${id}`);
const result = await this.conn const result = await this.conn
.select() .select()
.from(commercials_json) .from(commercials)
.where(and(...conditions)); .where(and(...conditions));
if (result.length > 0) { if (result.length > 0) {
return { id: result[0].id, email: result[0].email, ...(result[0].data as CommercialPropertyListing) } as CommercialPropertyListing; return result[0] as CommercialPropertyListing;
} else { } else {
throw new BadRequestException(`No entry available for ${id}`); throw new BadRequestException(`No entry available for ${id}`);
} }
@ -131,58 +131,42 @@ export class CommercialPropertyService {
// #### Find by User EMail ######################################## // #### Find by User EMail ########################################
async findCommercialPropertiesByEmail(email: string, user: JwtUser): Promise<CommercialPropertyListing[]> { async findCommercialPropertiesByEmail(email: string, user: JwtUser): Promise<CommercialPropertyListing[]> {
const conditions = []; const conditions = [];
conditions.push(eq(commercials_json.email, email)); conditions.push(eq(commercials.email, email));
if (email !== user?.email && user?.role !== 'admin') { if (email !== user?.username && (!user?.roles?.includes('ADMIN'))) {
conditions.push(sql`(${commercials_json.data}->>'draft')::boolean IS NOT TRUE`); conditions.push(ne(commercials.draft, true));
} }
const listings = await this.conn const listings = (await this.conn
.select() .select()
.from(commercials_json) .from(commercials)
.where(and(...conditions)); .where(and(...conditions))) as CommercialPropertyListing[];
return listings.map(l => ({ id: l.id, email: l.email, ...(l.data as CommercialPropertyListing) }) as CommercialPropertyListing); return listings as CommercialPropertyListing[];
} }
// #### Find Favorites ######################################## // #### Find Favorites ########################################
async findFavoriteListings(user: JwtUser): Promise<CommercialPropertyListing[]> { async findFavoriteListings(user: JwtUser): Promise<CommercialPropertyListing[]> {
const userFavorites = await this.conn const userFavorites = await this.conn
.select() .select()
.from(commercials_json) .from(commercials)
.where(arrayContains(sql`${commercials_json.data}->>'favoritesForUser'`, [user.email])); .where(arrayContains(commercials.favoritesForUser, [user.username]));
return userFavorites.map(l => ({ id: l.id, email: l.email, ...(l.data as CommercialPropertyListing) }) as CommercialPropertyListing); return userFavorites;
} }
// #### Find by imagePath ######################################## // #### Find by imagePath ########################################
async findByImagePath(imagePath: string, serial: string): Promise<CommercialPropertyListing> { async findByImagePath(imagePath: string, serial: string): Promise<CommercialPropertyListing> {
const result = await this.conn const result = await this.conn
.select() .select()
.from(commercials_json) .from(commercials)
.where(and(sql`(${commercials_json.data}->>'imagePath') = ${imagePath}`, sql`(${commercials_json.data}->>'serialId')::integer = ${serial}`)); .where(and(sql`${commercials.imagePath} = ${imagePath}`, sql`${commercials.serialId} = ${serial}`));
if (result.length > 0) { return result[0] as CommercialPropertyListing;
return { id: result[0].id, email: result[0].email, ...(result[0].data as CommercialPropertyListing) } as CommercialPropertyListing;
}
} }
// #### CREATE ######################################## // #### CREATE ########################################
async createListing(data: CommercialPropertyListing): Promise<CommercialPropertyListing> { async createListing(data: CommercialPropertyListing): Promise<CommercialPropertyListing> {
try { try {
// Hole die nächste serialId von der Sequence
const sequenceResult = await this.conn.execute(sql`SELECT nextval('commercials_json_serial_id_seq') AS serialid`);
// Prüfe, ob ein gültiger Wert zurückgegeben wurde
if (!sequenceResult.rows || !sequenceResult.rows[0] || sequenceResult.rows[0].serialid === undefined) {
throw new Error('Failed to retrieve serialId from sequence commercials_json_serial_id_seq');
}
const serialId = Number(sequenceResult.rows[0].serialid); // Konvertiere BIGINT zu Number
if (isNaN(serialId)) {
throw new Error('Invalid serialId received from sequence');
}
data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date(); data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date();
data.updated = new Date(); data.updated = new Date();
data.serialId = Number(serialId);
CommercialPropertyListingSchema.parse(data); CommercialPropertyListingSchema.parse(data);
const { id, email, ...rest } = data; const convertedCommercialPropertyListing = data;
const convertedCommercialPropertyListing = { email, data: rest }; delete convertedCommercialPropertyListing.id;
const [createdListing] = await this.conn.insert(commercials_json).values(convertedCommercialPropertyListing).returning(); const [createdListing] = await this.conn.insert(commercials).values(convertedCommercialPropertyListing).returning();
return { id: createdListing.id, email: createdListing.email, ...(createdListing.data as CommercialPropertyListing) }; return createdListing;
} catch (error) { } catch (error) {
if (error instanceof ZodError) { if (error instanceof ZodError) {
const filteredErrors = error.errors const filteredErrors = error.errors
@ -197,18 +181,10 @@ export class CommercialPropertyService {
} }
} }
// #### UPDATE CommercialProps ######################################## // #### UPDATE CommercialProps ########################################
async updateCommercialPropertyListing(id: string, data: CommercialPropertyListing, user: JwtUser): Promise<CommercialPropertyListing> { async updateCommercialPropertyListing(id: string, data: CommercialPropertyListing): Promise<CommercialPropertyListing> {
try { try {
const [existingListing] = await this.conn.select().from(commercials_json).where(eq(commercials_json.id, id));
if (!existingListing) {
throw new NotFoundException(`Business listing with id ${id} not found`);
}
data.updated = new Date(); data.updated = new Date();
data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date(); data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date();
if (existingListing.email === user?.email || !user) {
data.favoritesForUser = (<CommercialPropertyListing>existingListing.data).favoritesForUser || [];
}
CommercialPropertyListingSchema.parse(data); CommercialPropertyListingSchema.parse(data);
const imageOrder = await this.fileService.getPropertyImages(data.imagePath, String(data.serialId)); const imageOrder = await this.fileService.getPropertyImages(data.imagePath, String(data.serialId));
const difference = imageOrder.filter(x => !data.imageOrder.includes(x)).concat(data.imageOrder.filter(x => !imageOrder.includes(x))); const difference = imageOrder.filter(x => !data.imageOrder.includes(x)).concat(data.imageOrder.filter(x => !imageOrder.includes(x)));
@ -216,10 +192,9 @@ export class CommercialPropertyService {
this.logger.warn(`changes between image directory and imageOrder in listing ${data.serialId}: ${difference.join(',')}`); this.logger.warn(`changes between image directory and imageOrder in listing ${data.serialId}: ${difference.join(',')}`);
data.imageOrder = imageOrder; data.imageOrder = imageOrder;
} }
const { id: _, email, ...rest } = data; const convertedCommercialPropertyListing = data;
const convertedCommercialPropertyListing = { email, data: rest }; const [updateListing] = await this.conn.update(commercials).set(convertedCommercialPropertyListing).where(eq(commercials.id, id)).returning();
const [updateListing] = await this.conn.update(commercials_json).set(convertedCommercialPropertyListing).where(eq(commercials_json.id, id)).returning(); return updateListing;
return { id: updateListing.id, email: updateListing.email, ...(updateListing.data as CommercialPropertyListing) };
} catch (error) { } catch (error) {
if (error instanceof ZodError) { if (error instanceof ZodError) {
const filteredErrors = error.errors const filteredErrors = error.errors
@ -237,29 +212,39 @@ export class CommercialPropertyService {
// Images for commercial Properties // Images for commercial Properties
// ############################################################## // ##############################################################
async deleteImage(imagePath: string, serial: string, name: string) { async deleteImage(imagePath: string, serial: string, name: string) {
const listing = await this.findByImagePath(imagePath, serial); const listing = (await this.findByImagePath(imagePath, serial)) as unknown as CommercialPropertyListing;
const index = listing.imageOrder.findIndex(im => im === name); const index = listing.imageOrder.findIndex(im => im === name);
if (index > -1) { if (index > -1) {
listing.imageOrder.splice(index, 1); listing.imageOrder.splice(index, 1);
await this.updateCommercialPropertyListing(listing.id, listing, null); await this.updateCommercialPropertyListing(listing.id, listing);
} }
} }
async addImage(imagePath: string, serial: string, imagename: string) { async addImage(imagePath: string, serial: string, imagename: string) {
const listing = await this.findByImagePath(imagePath, serial); const listing = (await this.findByImagePath(imagePath, serial)) as unknown as CommercialPropertyListing;
listing.imageOrder.push(imagename); listing.imageOrder.push(imagename);
await this.updateCommercialPropertyListing(listing.id, listing, null); await this.updateCommercialPropertyListing(listing.id, listing);
} }
// #### DELETE ######################################## // #### DELETE ########################################
async deleteListing(id: string): Promise<void> { async deleteListing(id: string): Promise<void> {
await this.conn.delete(commercials_json).where(eq(commercials_json.id, id)); await this.conn.delete(commercials).where(eq(commercials.id, id));
} }
// #### DELETE Favorite ################################### // #### DELETE Favorite ###################################
async deleteFavorite(id: string, user: JwtUser): Promise<void> { async deleteFavorite(id: string, user: JwtUser): Promise<void> {
await this.conn await this.conn
.update(commercials_json) .update(commercials)
.set({ .set({
data: sql`jsonb_set(${commercials_json.data}, '{favoritesForUser}', array_remove((${commercials_json.data}->>'favoritesForUser')::jsonb, ${user.email}))`, favoritesForUser: sql`array_remove(${commercials.favoritesForUser}, ${user.username})`,
}) })
.where(eq(commercials_json.id, id)); .where(sql`${commercials.id} = ${id}`);
} }
// ##############################################################
// States
// ##############################################################
// async getStates(): Promise<any[]> {
// return await this.conn
// .select({ state: commercials.state, count: sql<number>`count(${commercials.id})`.mapWith(Number) })
// .from(commercials)
// .groupBy(sql`${commercials.state}`)
// .orderBy(sql`count desc`);
// }
} }

View File

@ -153,10 +153,10 @@ export const GeoSchema = z
zipCode: z.number().optional().nullable(), zipCode: z.number().optional().nullable(),
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (!data.state) { if (!data.name && !data.county) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: 'You need to select at least a state', message: 'You need to select either a city or a county',
path: ['name'], path: ['name'],
}); });
} }
@ -165,8 +165,8 @@ const phoneRegex = /^(\+1|1)?[-.\s]?\(?[2-9]\d{2}\)?[-.\s]?\d{3}[-.\s]?\d{4}$/;
export const UserSchema = z export const UserSchema = z
.object({ .object({
id: z.string().uuid().optional().nullable(), id: z.string().uuid().optional().nullable(),
firstname: z.string().min(3, { message: 'First name must contain at least 2 characters' }), firstname: z.string().min(2, { message: 'First name must contain at least 2 characters' }),
lastname: z.string().min(3, { message: 'Last name must contain at least 2 characters' }), lastname: z.string().min(2, { message: 'Last name must contain at least 2 characters' }),
email: z.string().email({ message: 'Invalid email address' }), email: z.string().email({ message: 'Invalid email address' }),
phoneNumber: z.string().optional().nullable(), phoneNumber: z.string().optional().nullable(),
description: z.string().optional().nullable(), description: z.string().optional().nullable(),
@ -197,13 +197,7 @@ export const UserSchema = z
path: ['customerSubType'], path: ['customerSubType'],
}); });
} }
if (!data.companyName || data.companyName.length < 6) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Company Name must contain at least 6 characters for professional customers',
path: ['companyName'],
});
}
if (!data.phoneNumber || !phoneRegex.test(data.phoneNumber)) { if (!data.phoneNumber || !phoneRegex.test(data.phoneNumber)) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
@ -258,8 +252,7 @@ export type AreasServed = z.infer<typeof AreasServedSchema>;
export type LicensedIn = z.infer<typeof LicensedInSchema>; export type LicensedIn = z.infer<typeof LicensedInSchema>;
export type User = z.infer<typeof UserSchema>; export type User = z.infer<typeof UserSchema>;
export const BusinessListingSchema = z export const BusinessListingSchema = z.object({
.object({
id: z.string().uuid().optional().nullable(), id: z.string().uuid().optional().nullable(),
email: z.string().email(), email: z.string().email(),
type: z.string().refine(val => TypeEnum.safeParse(val).success, { type: z.string().refine(val => TypeEnum.safeParse(val).success, {
@ -268,20 +261,18 @@ export const BusinessListingSchema = z
title: z.string().min(10), title: z.string().min(10),
description: z.string().min(10), description: z.string().min(10),
location: GeoSchema, location: GeoSchema,
price: z.number().positive().optional().nullable(), price: z.number().positive().max(1000000000),
favoritesForUser: z.array(z.string()), favoritesForUser: z.array(z.string()),
draft: z.boolean(), draft: z.boolean(),
listingsCategory: ListingsCategoryEnum, listingsCategory: ListingsCategoryEnum,
realEstateIncluded: z.boolean().optional().nullable(), realEstateIncluded: z.boolean().optional().nullable(),
leasedLocation: z.boolean().optional().nullable(), leasedLocation: z.boolean().optional().nullable(),
franchiseResale: z.boolean().optional().nullable(), franchiseResale: z.boolean().optional().nullable(),
salesRevenue: z.number().positive().nullable(), salesRevenue: z.number().positive().max(100000000),
cashFlow: z.number().optional().nullable(), cashFlow: z.number().positive().max(100000000),
ffe: z.number().optional().nullable(), supportAndTraining: z.string().min(5),
inventory: z.number().optional().nullable(),
supportAndTraining: z.string().min(5).optional().nullable(),
employees: z.number().int().positive().max(100000).optional().nullable(), employees: z.number().int().positive().max(100000).optional().nullable(),
established: z.number().int().min(1).max(250).optional().nullable(), established: z.number().int().min(1800).max(2030).optional().nullable(),
internalListingNumber: z.number().int().positive().optional().nullable(), internalListingNumber: z.number().int().positive().optional().nullable(),
reasonForSale: z.string().min(5).optional().nullable(), reasonForSale: z.string().min(5).optional().nullable(),
brokerLicencing: z.string().optional().nullable(), brokerLicencing: z.string().optional().nullable(),
@ -289,30 +280,7 @@ export const BusinessListingSchema = z
imageName: z.string().optional().nullable(), imageName: z.string().optional().nullable(),
created: z.date(), created: z.date(),
updated: z.date(), updated: z.date(),
}) });
.superRefine((data, ctx) => {
if (data.price && data.price > 1000000000) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Price must less than or equal $1,000,000,000',
path: ['price'],
});
}
if (data.salesRevenue && data.salesRevenue > 100000000) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'SalesRevenue must less than or equal $100,000,000',
path: ['salesRevenue'],
});
}
if (data.cashFlow && data.cashFlow > 100000000) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'CashFlow must less than or equal $100,000,000',
path: ['cashFlow'],
});
}
});
export type BusinessListing = z.infer<typeof BusinessListingSchema>; export type BusinessListing = z.infer<typeof BusinessListingSchema>;
export const CommercialPropertyListingSchema = z export const CommercialPropertyListingSchema = z
@ -326,25 +294,16 @@ export const CommercialPropertyListingSchema = z
title: z.string().min(10), title: z.string().min(10),
description: z.string().min(10), description: z.string().min(10),
location: GeoSchema, location: GeoSchema,
price: z.number().positive().optional().nullable(), price: z.number().positive().max(1000000000),
favoritesForUser: z.array(z.string()), favoritesForUser: z.array(z.string()),
listingsCategory: ListingsCategoryEnum, listingsCategory: ListingsCategoryEnum,
internalListingNumber: z.number().int().positive().optional().nullable(),
draft: z.boolean(), draft: z.boolean(),
imageOrder: z.array(z.string()), imageOrder: z.array(z.string()),
imagePath: z.string().nullable().optional(), imagePath: z.string().nullable().optional(),
created: z.date(), created: z.date(),
updated: z.date(), updated: z.date(),
}) })
.superRefine((data, ctx) => { .strict();
if (data.price && data.price > 1000000000) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Price must less than or equal $1,000,000,000',
path: ['price'],
});
}
});
export type CommercialPropertyListing = z.infer<typeof CommercialPropertyListingSchema>; export type CommercialPropertyListing = z.infer<typeof CommercialPropertyListingSchema>;

View File

@ -69,11 +69,11 @@ export interface ListCriteria {
state: string; state: string;
city: GeoResult; city: GeoResult;
prompt: string; prompt: string;
sortBy: SortByOptions;
searchType: 'exact' | 'radius'; searchType: 'exact' | 'radius';
// radius: '5' | '20' | '50' | '100' | '200' | '300' | '400' | '500'; // radius: '5' | '20' | '50' | '100' | '200' | '300' | '400' | '500';
radius: number; radius: number;
criteriaType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings'; criteriaType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
sortBy?: SortByOptions;
} }
export interface BusinessListingCriteria extends ListCriteria { export interface BusinessListingCriteria extends ListCriteria {
minPrice: number; minPrice: number;
@ -84,13 +84,13 @@ export interface BusinessListingCriteria extends ListCriteria {
maxCashFlow: number; maxCashFlow: number;
minNumberEmployees: number; minNumberEmployees: number;
maxNumberEmployees: number; maxNumberEmployees: number;
establishedMin: number; establishedSince: number;
establishedUntil: number;
realEstateChecked: boolean; realEstateChecked: boolean;
leasedLocation: boolean; leasedLocation: boolean;
franchiseResale: boolean; franchiseResale: boolean;
title: string; title: string;
brokerName: string; brokerName: string;
email: string;
criteriaType: 'businessListings'; criteriaType: 'businessListings';
} }
export interface CommercialPropertyListingCriteria extends ListCriteria { export interface CommercialPropertyListingCriteria extends ListCriteria {
@ -123,9 +123,11 @@ export interface KeycloakUser {
attributes?: Attributes; attributes?: Attributes;
} }
export interface JwtUser { export interface JwtUser {
email: string; userId: string;
role: string; username: string;
uid: string; firstname: string;
lastname: string;
roles: string[];
} }
interface Attributes { interface Attributes {
[key: string]: any; [key: string]: any;

View File

@ -0,0 +1,76 @@
import { Body, Controller, Get, HttpException, HttpStatus, Param, Post, Req, Res, UseGuards } from '@nestjs/common';
import { Request, Response } from 'express';
import { OptionalAuthGuard } from 'src/jwt-auth/optional-auth.guard';
import { Checkout } from 'src/models/main.model';
import Stripe from 'stripe';
import { PaymentService } from './payment.service';
@Controller('payment')
export class PaymentController {
constructor(private readonly paymentService: PaymentService) {}
// @Post()
// async createSubscription(@Body() subscriptionData: any) {
// return this.paymentService.createSubscription(subscriptionData);
// }
// @UseGuards(AdminAuthGuard)
// @Get('user/all')
// async getAllStripeCustomer(): Promise<Stripe.Customer[]> {
// return await this.paymentService.getAllStripeCustomer();
// }
// @UseGuards(AdminAuthGuard)
// @Get('subscription/all')
// async getAllStripeSubscriptions(): Promise<Stripe.Subscription[]> {
// return await this.paymentService.getAllStripeSubscriptions();
// }
// @UseGuards(AdminAuthGuard)
// @Get('paymentmethod/:email')
// async getStripePaymentMethods(@Param('email') email: string): Promise<Stripe.PaymentMethod[]> {
// return await this.paymentService.getStripePaymentMethod(email);
// }
@UseGuards(OptionalAuthGuard)
@Post('create-checkout-session')
async createCheckoutSession(@Body() checkout: Checkout) {
return await this.paymentService.createCheckoutSession(checkout);
}
@Post('webhook')
async handleWebhook(@Req() req: Request, @Res() res: Response): Promise<void> {
const signature = req.headers['stripe-signature'] as string;
try {
// Konvertieren Sie den req.body Buffer in einen lesbaren String
const payload = req.body instanceof Buffer ? req.body.toString('utf8') : req.body;
const event = await this.paymentService.constructEvent(payload, signature);
// const event = await this.paymentService.constructEvent(req.body, signature);
if (event.type === 'checkout.session.completed') {
await this.paymentService.handleCheckoutSessionCompleted(event.data.object as Stripe.Checkout.Session);
}
res.status(200).send('Webhook received');
} catch (error) {
console.error(`Webhook Error: ${error.message}`);
throw new HttpException('Webhook Error', HttpStatus.BAD_REQUEST);
}
}
@UseGuards(OptionalAuthGuard)
@Get('subscriptions/:email')
async findSubscriptionsById(@Param('email') email: string): Promise<any> {
return await this.paymentService.getSubscription(email);
}
/**
* Endpoint zum Löschen eines Stripe-Kunden.
* Beispiel: DELETE /stripe/customer/cus_12345
*/
// @UseGuards(AdminAuthGuard)
// @Delete('customer/:id')
// @HttpCode(HttpStatus.NO_CONTENT)
// async deleteCustomer(@Param('id') customerId: string): Promise<void> {
// await this.paymentService.deleteCustomerCompletely(customerId);
// }
}

View File

@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { FirebaseAdminModule } from 'src/firebase-admin/firebase-admin.module';
import { DrizzleModule } from '../drizzle/drizzle.module';
import { FileService } from '../file/file.service';
import { GeoService } from '../geo/geo.service';
import { MailModule } from '../mail/mail.module';
import { MailService } from '../mail/mail.service';
import { UserModule } from '../user/user.module';
import { UserService } from '../user/user.service';
import { PaymentController } from './payment.controller';
import { PaymentService } from './payment.service';
@Module({
imports: [DrizzleModule, UserModule, MailModule, AuthModule,FirebaseAdminModule],
providers: [PaymentService, UserService, MailService, FileService, GeoService],
controllers: [PaymentController],
})
export class PaymentModule {}

View File

@ -0,0 +1,216 @@
import { BadRequestException, Inject, Injectable, InternalServerErrorException } from '@nestjs/common';
import { NodePgDatabase } from 'drizzle-orm/node-postgres/driver';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import Stripe from 'stripe';
import { Logger } from 'winston';
import * as schema from '../drizzle/schema';
import { PG_CONNECTION } from '../drizzle/schema';
import { MailService } from '../mail/mail.service';
import { Checkout } from '../models/main.model';
import { UserService } from '../user/user.service';
export interface BillingAddress {
country: string;
state: string;
}
@Injectable()
export class PaymentService {
private stripe: Stripe;
constructor(
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
private readonly userService: UserService,
private readonly mailService: MailService,
) {
this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2024-06-20',
});
}
async createCheckoutSession(checkout: Checkout) {
try {
let customerId;
const existingCustomers = await this.stripe.customers.list({
email: checkout.email,
limit: 1,
});
if (existingCustomers.data.length > 0) {
// Kunde existiert
customerId = existingCustomers.data[0].id;
} else {
// Kunde existiert nicht, neuen Kunden erstellen
const newCustomer = await this.stripe.customers.create({
email: checkout.email,
name: checkout.name,
shipping: {
name: checkout.name,
address: {
city: '',
state: '',
country: 'US',
},
},
});
customerId = newCustomer.id;
}
const price = await this.stripe.prices.retrieve(checkout.priceId);
if (price.product) {
const product = await this.stripe.products.retrieve(price.product as string);
const session = await this.stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: checkout.priceId,
quantity: 1,
},
],
success_url: `${process.env.WEB_HOST}/success`,
cancel_url: `${process.env.WEB_HOST}/pricing`,
customer: customerId,
shipping_address_collection: {
allowed_countries: ['US'],
},
client_reference_id: btoa(checkout.name),
locale: 'en',
subscription_data: {
trial_end: Math.floor(new Date().setMonth(new Date().getMonth() + 3) / 1000),
metadata: { plan: product.name },
},
});
return session;
} else {
return null;
}
} catch (e) {
throw new BadRequestException(`error during checkout: ${e}`);
}
}
async constructEvent(body: string | Buffer, signature: string) {
return this.stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
}
async handleCheckoutSessionCompleted(session: Stripe.Checkout.Session): Promise<void> {
// try {
// const keycloakUsers = await this.authService.getUsers();
// const keycloakUser = keycloakUsers.find(u => u.email === session.customer_details.email);
// const user = await this.userService.getUserByMail(session.customer_details.email, {
// userId: keycloakUser.id,
// firstname: keycloakUser.firstName,
// lastname: keycloakUser.lastName,
// username: keycloakUser.email,
// roles: [],
// });
// user.subscriptionId = session.subscription as string;
// const subscription = await this.stripe.subscriptions.retrieve(user.subscriptionId);
// user.customerType = 'professional';
// if (subscription.metadata['plan'] === 'Broker Plan') {
// user.customerSubType = 'broker';
// }
// user.subscriptionPlan = subscription.metadata['plan'] === 'Broker Plan' ? 'broker' : 'professional'; //session.metadata['subscriptionPlan'] as 'free' | 'professional' | 'broker';
// await this.userService.saveUser(user, false);
// await this.mailService.sendSubscriptionConfirmation(user);
// } catch (error) {
// this.logger.error(error);
// }
}
async getSubscription(email: string): Promise<Stripe.Subscription[]> {
const existingCustomers = await this.stripe.customers.list({
email: email,
limit: 1,
});
if (existingCustomers.data.length > 0) {
const subscriptions = await this.stripe.subscriptions.list({
customer: existingCustomers.data[0].id,
status: 'all', // Optional: Gibt Abos in allen Status zurück, wie 'active', 'canceled', etc.
limit: 20, // Optional: Begrenze die Anzahl der zurückgegebenen Abonnements
});
return subscriptions.data.filter(s => s.status === 'active' || s.status === 'trialing');
} else {
return [];
}
}
/**
* Ruft alle Stripe-Kunden ab, indem die Paginierung gehandhabt wird.
* @returns Ein Array von Stripe.Customer Objekten.
*/
async getAllStripeCustomer(): Promise<Stripe.Customer[]> {
const allCustomers: Stripe.Customer[] = [];
let hasMore = true;
let startingAfter: string | undefined = undefined;
try {
while (hasMore) {
const response = await this.stripe.customers.list({
limit: 100, // Maximale Anzahl pro Anfrage
starting_after: startingAfter,
});
allCustomers.push(...response.data);
hasMore = response.has_more;
if (hasMore && response.data.length > 0) {
startingAfter = response.data[response.data.length - 1].id;
}
}
return allCustomers;
} catch (error) {
console.error('Fehler beim Abrufen der Stripe-Kunden:', error);
throw new Error('Kunden konnten nicht abgerufen werden.');
}
}
async getAllStripeSubscriptions(): Promise<Stripe.Subscription[]> {
const allSubscriptions: Stripe.Subscription[] = [];
const response = await this.stripe.subscriptions.list({
limit: 100,
});
allSubscriptions.push(...response.data);
return allSubscriptions;
}
async getStripePaymentMethod(email: string): Promise<Stripe.PaymentMethod[]> {
const existingCustomers = await this.stripe.customers.list({
email: email,
limit: 1,
});
const allPayments: Stripe.PaymentMethod[] = [];
if (existingCustomers.data.length > 0) {
const response = await this.stripe.paymentMethods.list({
customer: existingCustomers.data[0].id,
limit: 10,
});
allPayments.push(...response.data);
}
return allPayments;
}
async deleteCustomerCompletely(customerId: string): Promise<void> {
try {
// 1. Abonnements kündigen und löschen
const subscriptions = await this.stripe.subscriptions.list({
customer: customerId,
limit: 100,
});
for (const subscription of subscriptions.data) {
await this.stripe.subscriptions.cancel(subscription.id);
this.logger.info(`Abonnement ${subscription.id} gelöscht.`);
}
// 2. Zahlungsmethoden entfernen
const paymentMethods = await this.stripe.paymentMethods.list({
customer: customerId,
type: 'card',
});
for (const paymentMethod of paymentMethods.data) {
await this.stripe.paymentMethods.detach(paymentMethod.id);
this.logger.info(`Zahlungsmethode ${paymentMethod.id} entfernt.`);
}
// 4. Kunden löschen
await this.stripe.customers.del(customerId);
this.logger.info(`Kunde ${customerId} erfolgreich gelöscht.`);
} catch (error) {
this.logger.error(`Fehler beim Löschen des Kunden ${customerId}:`, error);
throw new InternalServerErrorException('Fehler beim Löschen des Stripe-Kunden.');
}
}
}

View File

@ -5,7 +5,7 @@ import { ImageType, KeyValue, KeyValueAsSortBy, KeyValueStyle } from '../models/
export class SelectOptionsService { export class SelectOptionsService {
constructor() {} constructor() {}
public typesOfBusiness: Array<KeyValueStyle> = [ public typesOfBusiness: Array<KeyValueStyle> = [
{ name: 'Automotive', value: 'automotive', oldValue: '1', icon: 'fa-solid fa-car', textColorClass: 'text-green-500' }, { name: 'Automotive', value: 'automotive', oldValue: '1', icon: 'fa-solid fa-car', textColorClass: 'text-green-400' },
{ name: 'Industrial Services', value: 'industrialServices', oldValue: '2', icon: 'fa-solid fa-industry', textColorClass: 'text-yellow-400' }, { name: 'Industrial Services', value: 'industrialServices', oldValue: '2', icon: 'fa-solid fa-industry', textColorClass: 'text-yellow-400' },
{ name: 'Food and Restaurant', value: 'foodAndRestaurant', oldValue: '13', icon: 'fa-solid fa-utensils', textColorClass: 'text-amber-700' }, { name: 'Food and Restaurant', value: 'foodAndRestaurant', oldValue: '13', icon: 'fa-solid fa-utensils', textColorClass: 'text-amber-700' },
{ name: 'Real Estate', value: 'realEstate', oldValue: '3', icon: 'fa-solid fa-building', textColorClass: 'text-blue-400' }, { name: 'Real Estate', value: 'realEstate', oldValue: '3', icon: 'fa-solid fa-building', textColorClass: 'text-blue-400' },

View File

@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { and, asc, count, desc, eq, inArray, or, SQL, sql } from 'drizzle-orm'; import { and, asc, count, desc, eq, ilike, inArray, or, SQL, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres/driver'; import { NodePgDatabase } from 'drizzle-orm/node-postgres/driver';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
import { Logger } from 'winston'; import { Logger } from 'winston';
@ -9,7 +9,7 @@ import { FileService } from '../file/file.service';
import { GeoService } from '../geo/geo.service'; import { GeoService } from '../geo/geo.service';
import { User, UserSchema } from '../models/db.model'; import { User, UserSchema } from '../models/db.model';
import { createDefaultUser, emailToDirName, JwtUser, UserListingCriteria } from '../models/main.model'; import { createDefaultUser, emailToDirName, JwtUser, UserListingCriteria } from '../models/main.model';
import { getDistanceQuery, splitName } from '../utils'; import { DrizzleUser, getDistanceQuery, splitName } from '../utils';
type CustomerSubType = (typeof customerSubTypeEnum.enumValues)[number]; type CustomerSubType = (typeof customerSubTypeEnum.enumValues)[number];
@Injectable() @Injectable()
@ -23,45 +23,45 @@ export class UserService {
private getWhereConditions(criteria: UserListingCriteria): SQL[] { private getWhereConditions(criteria: UserListingCriteria): SQL[] {
const whereConditions: SQL[] = []; const whereConditions: SQL[] = [];
whereConditions.push(sql`(${schema.users_json.data}->>'customerType') = 'professional'`); whereConditions.push(eq(schema.users.customerType, 'professional'));
if (criteria.city && criteria.searchType === 'exact') { if (criteria.city && criteria.searchType === 'exact') {
whereConditions.push(sql`(${schema.users_json.data}->'location'->>'name') ILIKE ${criteria.city.name}`); whereConditions.push(sql`${schema.users.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) {
const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name); const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name);
whereConditions.push(sql`${getDistanceQuery(schema.users_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`); whereConditions.push(sql`${getDistanceQuery(schema.users, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`);
} }
if (criteria.types && criteria.types.length > 0) { if (criteria.types && criteria.types.length > 0) {
// whereConditions.push(inArray(schema.users.customerSubType, criteria.types)); // whereConditions.push(inArray(schema.users.customerSubType, criteria.types));
whereConditions.push(inArray(sql`${schema.users_json.data}->>'customerSubType'`, criteria.types as CustomerSubType[])); whereConditions.push(inArray(schema.users.customerSubType, criteria.types as CustomerSubType[]));
} }
if (criteria.brokerName) { if (criteria.brokerName) {
const { firstname, lastname } = splitName(criteria.brokerName); const { firstname, lastname } = splitName(criteria.brokerName);
whereConditions.push(sql`(${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`}`); whereConditions.push(or(ilike(schema.users.firstname, `%${firstname}%`), ilike(schema.users.lastname, `%${lastname}%`)));
} }
if (criteria.companyName) { if (criteria.companyName) {
whereConditions.push(sql`(${schema.users_json.data}->>'companyName') ILIKE ${`%${criteria.companyName}%`}`); whereConditions.push(ilike(schema.users.companyName, `%${criteria.companyName}%`));
} }
if (criteria.counties && criteria.counties.length > 0) { if (criteria.counties && criteria.counties.length > 0) {
whereConditions.push(or(...criteria.counties.map(county => sql`EXISTS (SELECT 1 FROM jsonb_array_elements(${schema.users_json.data}->'areasServed') AS area WHERE area->>'county' ILIKE ${`%${county}%`})`))); whereConditions.push(or(...criteria.counties.map(county => sql`EXISTS (SELECT 1 FROM jsonb_array_elements(${schema.users.areasServed}) AS area WHERE area->>'county' ILIKE ${`%${county}%`})`)));
} }
if (criteria.state) { if (criteria.state) {
whereConditions.push(sql`EXISTS (SELECT 1 FROM jsonb_array_elements(${schema.users_json.data}->'areasServed') AS area WHERE area->>'state' = ${criteria.state})`); whereConditions.push(sql`EXISTS (SELECT 1 FROM jsonb_array_elements(${schema.users.areasServed}) AS area WHERE area->>'state' = ${criteria.state})`);
} }
//never show user which denied //never show user which denied
whereConditions.push(sql`(${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE`); whereConditions.push(eq(schema.users.showInDirectory, true))
return whereConditions; return whereConditions;
} }
async searchUserListings(criteria: UserListingCriteria): Promise<{ results: User[]; totalCount: number }> { async searchUserListings(criteria: UserListingCriteria): Promise<{ results: User[]; totalCount: number }> {
const start = criteria.start ? criteria.start : 0; const start = criteria.start ? criteria.start : 0;
const length = criteria.length ? criteria.length : 12; const length = criteria.length ? criteria.length : 12;
const query = this.conn.select().from(schema.users_json); const query = this.conn.select().from(schema.users);
const whereConditions = this.getWhereConditions(criteria); const whereConditions = this.getWhereConditions(criteria);
if (whereConditions.length > 0) { if (whereConditions.length > 0) {
@ -71,10 +71,10 @@ export class UserService {
// Sortierung // Sortierung
switch (criteria.sortBy) { switch (criteria.sortBy) {
case 'nameAsc': case 'nameAsc':
query.orderBy(asc(sql`${schema.users_json.data}->>'lastname'`)); query.orderBy(asc(schema.users.lastname));
break; break;
case 'nameDesc': case 'nameDesc':
query.orderBy(desc(sql`${schema.users_json.data}->>'lastname'`)); query.orderBy(desc(schema.users.lastname));
break; break;
default: default:
// Keine spezifische Sortierung, Standardverhalten kann hier eingefügt werden // Keine spezifische Sortierung, Standardverhalten kann hier eingefügt werden
@ -84,7 +84,7 @@ export class UserService {
query.limit(length).offset(start); query.limit(length).offset(start);
const data = await query; const data = await query;
const results = data.map(u => ({ id: u.id, email: u.email, ...(u.data as User) }) as User); const results = data;
const totalCount = await this.getUserListingsCount(criteria); const totalCount = await this.getUserListingsCount(criteria);
return { return {
@ -93,7 +93,7 @@ export class UserService {
}; };
} }
async getUserListingsCount(criteria: UserListingCriteria): Promise<number> { async getUserListingsCount(criteria: UserListingCriteria): Promise<number> {
const countQuery = this.conn.select({ value: count() }).from(schema.users_json); const countQuery = this.conn.select({ value: count() }).from(schema.users);
const whereConditions = this.getWhereConditions(criteria); const whereConditions = this.getWhereConditions(criteria);
if (whereConditions.length > 0) { if (whereConditions.length > 0) {
@ -105,29 +105,35 @@ export class UserService {
return totalCount; return totalCount;
} }
async getUserByMail(email: string, jwtuser?: JwtUser) { async getUserByMail(email: string, jwtuser?: JwtUser) {
const users = await this.conn.select().from(schema.users_json).where(eq(schema.users_json.email, email)); const users = (await this.conn
.select()
.from(schema.users)
.where(sql`email = ${email}`)) as User[];
if (users.length === 0) { if (users.length === 0) {
const user: User = { id: undefined, customerType: 'professional', ...createDefaultUser(email, '', '', null) }; const user: User = { id: undefined, customerType: 'professional', ...createDefaultUser(email, jwtuser.firstname ? jwtuser.firstname : '', jwtuser.lastname ? jwtuser.lastname : '', null) };
const u = await this.saveUser(user, false); const u = await this.saveUser(user, false);
return u; return u;
} else { } else {
const user = { id: users[0].id, email: users[0].email, ...(users[0].data as User) } as User; const user = users[0];
user.hasCompanyLogo = this.fileService.hasCompanyLogo(emailToDirName(user.email)); user.hasCompanyLogo = this.fileService.hasCompanyLogo(emailToDirName(user.email));
user.hasProfile = this.fileService.hasProfile(emailToDirName(user.email)); user.hasProfile = this.fileService.hasProfile(emailToDirName(user.email));
return user; return user;
} }
} }
async getUserById(id: string) { async getUserById(id: string) {
const users = await this.conn.select().from(schema.users_json).where(eq(schema.users_json.id, id)); const users = (await this.conn
.select()
.from(schema.users)
.where(sql`id = ${id}`)) as User[];
const user = { id: users[0].id, email: users[0].email, ...(users[0].data as User) } as User; const user = users[0];
user.hasCompanyLogo = this.fileService.hasCompanyLogo(emailToDirName(user.email)); user.hasCompanyLogo = this.fileService.hasCompanyLogo(emailToDirName(user.email));
user.hasProfile = this.fileService.hasProfile(emailToDirName(user.email)); user.hasProfile = this.fileService.hasProfile(emailToDirName(user.email));
return user; return user;
} }
async getAllUser() { async getAllUser() {
const users = await this.conn.select().from(schema.users_json); const users = await this.conn.select().from(schema.users);
return users.map(u => ({ id: u.id, email: u.email, ...(u.data as User) }) as User); return users;
} }
async saveUser(user: User, processValidation = true): Promise<User> { async saveUser(user: User, processValidation = true): Promise<User> {
try { try {
@ -142,14 +148,13 @@ export class UserService {
validatedUser = UserSchema.parse(user); validatedUser = UserSchema.parse(user);
} }
//const drizzleUser = convertUserToDrizzleUser(validatedUser); //const drizzleUser = convertUserToDrizzleUser(validatedUser);
const { id: _, ...rest } = validatedUser; const drizzleUser = validatedUser as DrizzleUser;
const drizzleUser = { email: user.email, data: rest };
if (user.id) { if (user.id) {
const [updateUser] = await this.conn.update(schema.users_json).set(drizzleUser).where(eq(schema.users_json.id, user.id)).returning(); const [updateUser] = await this.conn.update(schema.users).set(drizzleUser).where(eq(schema.users.id, user.id)).returning();
return { id: updateUser.id, email: updateUser.email, ...(updateUser.data as User) } as User; return updateUser as User;
} else { } else {
const [newUser] = await this.conn.insert(schema.users_json).values(drizzleUser).returning(); const [newUser] = await this.conn.insert(schema.users).values(drizzleUser).returning();
return { id: newUser.id, email: newUser.email, ...(newUser.data as User) } as User; return newUser as User;
} }
} catch (error) { } catch (error) {
throw error; throw error;

View File

@ -1,5 +1,5 @@
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import { businesses, businesses_json, commercials, commercials_json, users, users_json } from './drizzle/schema'; import { businesses, commercials, users } from './drizzle/schema';
export const EARTH_RADIUS_KM = 6371; // Erdradius in Kilometern export const EARTH_RADIUS_KM = 6371; // Erdradius in Kilometern
export const EARTH_RADIUS_MILES = 3959; // Erdradius in Meilen export const EARTH_RADIUS_MILES = 3959; // Erdradius in Meilen
export function convertStringToNullUndefined(value) { export function convertStringToNullUndefined(value) {
@ -16,13 +16,21 @@ export function convertStringToNullUndefined(value) {
return value; return value;
} }
export const getDistanceQuery = (schema: typeof businesses_json | typeof commercials_json | typeof users_json, lat: number, lon: number, unit: 'km' | 'miles' = 'miles') => { export const getDistanceQuery = (schema: typeof businesses | typeof commercials | typeof users, lat: number, lon: number, unit: 'km' | 'miles' = 'miles') => {
const radius = unit === 'km' ? EARTH_RADIUS_KM : EARTH_RADIUS_MILES; const radius = unit === 'km' ? EARTH_RADIUS_KM : EARTH_RADIUS_MILES;
// return sql`
// ${radius} * 2 * ASIN(SQRT(
// POWER(SIN((${lat} - ${schema.latitude}) * PI() / 180 / 2), 2) +
// COS(${lat} * PI() / 180) * COS(${schema.latitude} * PI() / 180) *
// POWER(SIN((${lon} - ${schema.longitude}) * PI() / 180 / 2), 2)
// ))
// `;
return sql` return sql`
${radius} * 2 * ASIN(SQRT( ${radius} * 2 * ASIN(SQRT(
POWER(SIN((${lat} - (${schema.data}->'location'->>'latitude')::float) * PI() / 180 / 2), 2) + POWER(SIN((${lat} - (${schema.location}->>'latitude')::float) * PI() / 180 / 2), 2) +
COS(${lat} * PI() / 180) * COS((${schema.data}->'location'->>'latitude')::float * PI() / 180) * COS(${lat} * PI() / 180) * COS((${schema.location}->>'latitude')::float * PI() / 180) *
POWER(SIN((${lon} - (${schema.data}->'location'->>'longitude')::float) * PI() / 180 / 2), 2) POWER(SIN((${lon} - (${schema.location}->>'longitude')::float) * PI() / 180 / 2), 2)
)) ))
`; `;
}; };
@ -30,7 +38,121 @@ export const getDistanceQuery = (schema: typeof businesses_json | typeof commerc
export type DrizzleUser = typeof users.$inferSelect; export type DrizzleUser = typeof users.$inferSelect;
export type DrizzleBusinessListing = typeof businesses.$inferSelect; export type DrizzleBusinessListing = typeof businesses.$inferSelect;
export type DrizzleCommercialPropertyListing = typeof commercials.$inferSelect; export type DrizzleCommercialPropertyListing = typeof commercials.$inferSelect;
// export function convertBusinessToDrizzleBusiness(businessListing: Partial<BusinessListing>): DrizzleBusinessListing {
// const drizzleBusinessListing = flattenObject(businessListing);
// drizzleBusinessListing.city = drizzleBusinessListing.name;
// delete drizzleBusinessListing.name;
// return drizzleBusinessListing;
// }
// export function convertDrizzleBusinessToBusiness(drizzleBusinessListing: Partial<DrizzleBusinessListing>): BusinessListing {
// const o = {
// location: drizzleBusinessListing.city ? undefined : null,
// location_name: drizzleBusinessListing.city ? drizzleBusinessListing.city : undefined,
// location_state: drizzleBusinessListing.state ? drizzleBusinessListing.state : undefined,
// location_latitude: drizzleBusinessListing.latitude ? drizzleBusinessListing.latitude : undefined,
// location_longitude: drizzleBusinessListing.longitude ? drizzleBusinessListing.longitude : undefined,
// ...drizzleBusinessListing,
// };
// Object.keys(o).forEach(key => (o[key] === undefined ? delete o[key] : {}));
// delete o.city;
// delete o.state;
// delete o.latitude;
// delete o.longitude;
// return unflattenObject(o);
// }
// export function convertCommercialToDrizzleCommercial(commercialPropertyListing: Partial<CommercialPropertyListing>): DrizzleCommercialPropertyListing {
// const drizzleCommercialPropertyListing = flattenObject(commercialPropertyListing);
// drizzleCommercialPropertyListing.city = drizzleCommercialPropertyListing.name;
// delete drizzleCommercialPropertyListing.name;
// return drizzleCommercialPropertyListing;
// }
// export function convertDrizzleCommercialToCommercial(drizzleCommercialPropertyListing: Partial<DrizzleCommercialPropertyListing>): CommercialPropertyListing {
// const o = {
// location: drizzleCommercialPropertyListing.city ? undefined : null,
// location_name: drizzleCommercialPropertyListing.city ? drizzleCommercialPropertyListing.city : undefined,
// location_state: drizzleCommercialPropertyListing.state ? drizzleCommercialPropertyListing.state : undefined,
// location_street: drizzleCommercialPropertyListing.street ? drizzleCommercialPropertyListing.street : undefined,
// location_housenumber: drizzleCommercialPropertyListing.housenumber ? drizzleCommercialPropertyListing.housenumber : undefined,
// location_county: drizzleCommercialPropertyListing.county ? drizzleCommercialPropertyListing.county : undefined,
// location_zipCode: drizzleCommercialPropertyListing.zipCode ? drizzleCommercialPropertyListing.zipCode : undefined,
// location_latitude: drizzleCommercialPropertyListing.latitude ? drizzleCommercialPropertyListing.latitude : undefined,
// location_longitude: drizzleCommercialPropertyListing.longitude ? drizzleCommercialPropertyListing.longitude : undefined,
// ...drizzleCommercialPropertyListing,
// };
// Object.keys(o).forEach(key => (o[key] === undefined ? delete o[key] : {}));
// delete o.city;
// delete o.state;
// delete o.street;
// delete o.housenumber;
// delete o.county;
// delete o.zipCode;
// delete o.latitude;
// delete o.longitude;
// return unflattenObject(o);
// }
// export function convertUserToDrizzleUser(user: Partial<User>): DrizzleUser {
// const drizzleUser = flattenObject(user);
// drizzleUser.city = drizzleUser.name;
// delete drizzleUser.name;
// return drizzleUser;
// }
// export function convertDrizzleUserToUser(drizzleUser: Partial<DrizzleUser>): User {
// const o: any = {
// companyLocation: drizzleUser.city ? undefined : null,
// companyLocation_name: drizzleUser.city ? drizzleUser.city : undefined,
// companyLocation_state: drizzleUser.state ? drizzleUser.state : undefined,
// companyLocation_latitude: drizzleUser.latitude ? drizzleUser.latitude : undefined,
// companyLocation_longitude: drizzleUser.longitude ? drizzleUser.longitude : undefined,
// ...drizzleUser,
// };
// Object.keys(o).forEach(key => (o[key] === undefined ? delete o[key] : {}));
// delete o.city;
// delete o.state;
// delete o.latitude;
// delete o.longitude;
// return unflattenObject(o);
// }
// function flattenObject(obj: any, res: any = {}): any {
// for (const key in obj) {
// if (obj.hasOwnProperty(key)) {
// const value = obj[key];
// if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
// if (value instanceof Date) {
// res[key] = value;
// } else {
// flattenObject(value, res);
// }
// } else {
// res[key] = value;
// }
// }
// }
// return res;
// }
// function unflattenObject(obj: any, separator: string = '_'): any {
// const result: any = {};
// for (const key in obj) {
// if (obj.hasOwnProperty(key)) {
// const keys = key.split(separator);
// keys.reduce((acc, curr, idx) => {
// if (idx === keys.length - 1) {
// acc[curr] = obj[key];
// } else {
// if (!acc[curr]) {
// acc[curr] = {};
// }
// }
// return acc[curr];
// }, result);
// }
// }
// return result;
// }
export function splitName(fullName: string): { firstname: string; lastname: string } { export function splitName(fullName: string): { firstname: string; lastname: string } {
const parts = fullName.trim().split(/\s+/); // Teile den Namen am Leerzeichen auf const parts = fullName.trim().split(/\s+/); // Teile den Namen am Leerzeichen auf

View File

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Under Construction</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>Welcome to bizmatch.net!</h1>
<p>We're currently under construction to bring you a new and improved experience. Our website is diligently being developed to ensure that we meet your needs with the highest quality of service.</p>
<p>Please check back soon for updates. In the meantime, feel free to <a href="mailto:info@bizmatch.net">contact us</a> for any inquiries or further information.</p>
<p>Thank you for your patience and support!</p>
<p>The bizmatch.net Team</p>
</div>
</body>
</html>

52
bizmatch-static/style.css Normal file
View File

@ -0,0 +1,52 @@
body, html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #e6f7ff; /* Hintergrundfarbe leicht blau */
color: #05386b; /* Dunkelblau für Text */
display: flex;
align-items: center;
justify-content: center;
text-align: center;
background-image: url(./index-bg.webp);
background-size: cover;
background-position: center;
height: 100vh;
}
.container {
max-width: 600px;
padding: 40px;
background-color: white;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
border-radius: 10px;
border-left: 5px solid #379683; /* Grüne Akzentlinie links */
}
h1 {
color: #379683; /* Grünton für Überschriften */
}
p {
line-height: 1.6;
margin: 20px 0;
}
a {
color: #5cdb95; /* Helles Grün für Links */
text-decoration: none;
font-weight: bold;
}
a:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
.container {
padding: 20px;
}
}

View File

@ -27,10 +27,6 @@
"tsConfig": "tsconfig.app.json", "tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss", "inlineStyleLanguage": "scss",
"assets": [ "assets": [
{
"glob": "**/*",
"input": "public"
},
"src/favicon.ico", "src/favicon.ico",
"src/assets" "src/assets"
], ],
@ -71,17 +67,6 @@
"optimization": false, "optimization": false,
"extractLicenses": false, "extractLicenses": false,
"sourceMap": true "sourceMap": true
},
"prod": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"extractLicenses": false,
"sourceMap": true
} }
}, },
"defaultConfiguration": "production" "defaultConfiguration": "production"

View File

@ -7,7 +7,6 @@
"prebuild": "node version.js", "prebuild": "node version.js",
"build": "node version.js && ng build", "build": "node version.js && ng build",
"build.dev": "node version.js && ng build --configuration dev --output-hashing=all", "build.dev": "node version.js && ng build --configuration dev --output-hashing=all",
"build.prod": "node version.js && ng build --configuration prod --output-hashing=all",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test", "test": "ng test",
"serve:ssr:bizmatch": "node dist/bizmatch/server/server.mjs" "serve:ssr:bizmatch": "node dist/bizmatch/server/server.mjs"
@ -52,8 +51,6 @@
"ngx-sharebuttons": "^15.0.3", "ngx-sharebuttons": "^15.0.3",
"ngx-stripe": "^18.1.0", "ngx-stripe": "^18.1.0",
"on-change": "^5.0.1", "on-change": "^5.0.1",
"posthog-js": "^1.259.0",
"quill": "2.0.2",
"rxjs": "~7.8.1", "rxjs": "~7.8.1",
"tslib": "^2.6.3", "tslib": "^2.6.3",
"urlcat": "^3.1.0", "urlcat": "^3.1.0",

View File

@ -1,6 +1,6 @@
{ {
"/bizmatch": { "/bizmatch": {
"target": "http://localhost:3001", "target": "http://localhost:3000",
"secure": false, "secure": false,
"changeOrigin": true, "changeOrigin": true,
"logLevel": "debug" "logLevel": "debug"

View File

@ -3,16 +3,10 @@
@if (actualRoute !=='home' && actualRoute !=='login' && actualRoute!=='emailVerification' && actualRoute!=='email-authorized'){ @if (actualRoute !=='home' && actualRoute !=='login' && actualRoute!=='emailVerification' && actualRoute!=='email-authorized'){
<header></header> <header></header>
} }
<main class="flex-1 flex"> <main class="flex-1 bg-slate-100">
@if (isFilterRoute()) {
<div class="hidden md:block w-1/4 bg-white shadow-lg p-6 overflow-y-auto">
<app-search-modal [isModal]="false"></app-search-modal>
</div>
}
<div [ngClass]="{ 'w-full': !isFilterRoute(), 'md:w-3/4': isFilterRoute() }">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</div>
</main> </main>
<app-footer></app-footer> <app-footer></app-footer>
</div> </div>
@ -41,6 +35,5 @@
<app-message-container></app-message-container> <app-message-container></app-message-container>
<app-search-modal></app-search-modal> <app-search-modal></app-search-modal>
<app-search-modal-commercial></app-search-modal-commercial>
<app-confirmation></app-confirmation> <app-confirmation></app-confirmation>
<app-email></app-email> <app-email></app-email>

View File

@ -1,3 +1,25 @@
// .progress-spinner {
// position: fixed;
// z-index: 999;
// top: 0;
// left: 0;
// bottom: 0;
// right: 0;
// display: flex;
// flex-direction: column;
// align-items: center;
// }
// .progress-spinner:before {
// content: '';
// display: block;
// position: fixed;
// top: 0;
// left: 0;
// width: 100%;
// height: 100%;
// background-color: rgba(0, 0, 0, 0.3);
// }
.spinner-text { .spinner-text {
margin-top: 20px; /* Abstand zwischen Spinner und Text anpassen */ margin-top: 20px; /* Abstand zwischen Spinner und Text anpassen */
font-size: 20px; /* Schriftgröße nach Bedarf anpassen */ font-size: 20px; /* Schriftgröße nach Bedarf anpassen */

View File

@ -10,7 +10,6 @@ import { EMailComponent } from './components/email/email.component';
import { FooterComponent } from './components/footer/footer.component'; import { FooterComponent } from './components/footer/footer.component';
import { HeaderComponent } from './components/header/header.component'; import { HeaderComponent } from './components/header/header.component';
import { MessageContainerComponent } from './components/message/message-container.component'; import { MessageContainerComponent } from './components/message/message-container.component';
import { SearchModalCommercialComponent } from './components/search-modal/search-modal-commercial.component';
import { SearchModalComponent } from './components/search-modal/search-modal.component'; import { SearchModalComponent } from './components/search-modal/search-modal.component';
import { AuditService } from './services/audit.service'; import { AuditService } from './services/audit.service';
import { GeoService } from './services/geo.service'; import { GeoService } from './services/geo.service';
@ -20,7 +19,7 @@ import { UserService } from './services/user.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
standalone: true, standalone: true,
imports: [CommonModule, RouterOutlet, HeaderComponent, FooterComponent, MessageContainerComponent, SearchModalComponent, SearchModalCommercialComponent, ConfirmationComponent, EMailComponent], imports: [CommonModule, RouterOutlet, HeaderComponent, FooterComponent, MessageContainerComponent, SearchModalComponent, ConfirmationComponent, EMailComponent],
providers: [], providers: [],
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.scss', styleUrl: './app.component.scss',
@ -48,18 +47,42 @@ export class AppComponent {
this.actualRoute = currentRoute.snapshot.url[0].path; this.actualRoute = currentRoute.snapshot.url[0].path;
}); });
} }
ngOnInit() {} ngOnInit() {
// this.keycloakService.keycloakEvents$.subscribe({
// next: event => {
// if (event.type === KeycloakEventType.OnTokenExpired) {
// this.handleTokenExpiration();
// }
// },
// });
}
// private async handleTokenExpiration(): Promise<void> {
// try {
// // Versuche, den Token zu erneuern
// const refreshed = await this.keycloakService.updateToken();
// if (!refreshed) {
// // Wenn der Token nicht erneuert werden kann, leite zur Login-Seite weiter
// this.keycloakService.login({
// redirectUri: window.location.href, // oder eine andere Seite
// });
// }
// } catch (error) {
// if (error.error === 'invalid_grant' && error.error_description === 'Token is not active') {
// // Hier wird der Fehler "invalid_grant" abgefangen
// this.keycloakService.login({
// redirectUri: window.location.href,
// });
// }
// }
// }
@HostListener('window:keydown', ['$event']) @HostListener('window:keydown', ['$event'])
handleKeyboardEvent(event: KeyboardEvent) { handleKeyboardEvent(event: KeyboardEvent) {
if (event.shiftKey && event.ctrlKey && event.key === 'V') { if (event.shiftKey && event.ctrlKey && event.key === 'V') {
this.showVersionDialog(); this.showVersionDialog();
} }
} }
showVersionDialog() { showVersionDialog() {
this.confirmationService.showConfirmation({ message: `App Version: ${this.build.timestamp}`, buttons: 'none' }); this.confirmationService.showConfirmation({ message: `App Version: ${this.build.timestamp}`, buttons: 'none' });
} }
isFilterRoute(): boolean {
const filterRoutes = ['/businessListings', '/commercialPropertyListings', '/brokerListings'];
return filterRoutes.includes(this.actualRoute);
}
} }

View File

@ -16,7 +16,6 @@ import { AuthInterceptor } from './interceptors/auth.interceptor';
import { LoadingInterceptor } from './interceptors/loading.interceptor'; import { LoadingInterceptor } from './interceptors/loading.interceptor';
import { TimeoutInterceptor } from './interceptors/timeout.interceptor'; import { TimeoutInterceptor } from './interceptors/timeout.interceptor';
import { GlobalErrorHandler } from './services/globalErrorHandler'; import { GlobalErrorHandler } from './services/globalErrorHandler';
import { POSTHOG_INIT_PROVIDER } from './services/posthog.factory';
import { SelectOptionsService } from './services/select-options.service'; import { SelectOptionsService } from './services/select-options.service';
import { createLogger } from './utils/utils'; import { createLogger } from './utils/utils';
// provideClientHydration() // provideClientHydration()
@ -68,7 +67,6 @@ export const appConfig: ApplicationConfig = {
anchorScrolling: 'enabled', anchorScrolling: 'enabled',
}), }),
), ),
...(environment.production ? [POSTHOG_INIT_PROVIDER] : []),
provideAnimations(), provideAnimations(),
provideNgxStripe('pk_test_IlpbVQhxAXZypLgnCHOCqlj8'), provideNgxStripe('pk_test_IlpbVQhxAXZypLgnCHOCqlj8'),
provideQuillConfig({ provideQuillConfig({

View File

@ -1,8 +1,20 @@
<div #_container class="container"> <div #_container class="container">
<!-- <div
*ngFor="let item of items"
cdkDrag
(cdkDragEnded)="dragEnded($event)"
(cdkDragStarted)="dragStarted()"
(cdkDragMoved)="dragMoved($event)"
class="item"
[class.animation]="isAnimationActive"
[class.large]="item === 3"
>
Drag Item {{ item }}
</div> -->
<div *ngFor="let item of items" cdkDrag (cdkDragEnded)="dragEnded($event)" (cdkDragStarted)="dragStarted()" (cdkDragMoved)="dragMoved($event)" [class.animation]="isAnimationActive" class="grid-item item"> <div *ngFor="let item of items" cdkDrag (cdkDragEnded)="dragEnded($event)" (cdkDragStarted)="dragStarted()" (cdkDragMoved)="dragMoved($event)" [class.animation]="isAnimationActive" class="grid-item item">
<div class="image-box hover:cursor-pointer"> <div class="image-box hover:cursor-pointer">
<img [src]="getImageUrl(item)" class="w-full h-full object-cover rounded-lg drop-shadow-custom-bg" /> <img [src]="getImageUrl(item)" class="w-full h-full object-cover rounded-lg shadow-md" />
<div class="absolute top-2 right-2 bg-white rounded-full p-1 drop-shadow-custom-bg" (click)="imageToDelete.emit(item)"> <div class="absolute top-2 right-2 bg-white rounded-full p-1 shadow-md" (click)="imageToDelete.emit(item)">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" class="w-4 h-4 text-gray-600"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" class="w-4 h-4 text-gray-600">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>

View File

@ -1,35 +1,16 @@
<div class="container mx-auto py-8 px-4 max-w-md"> <div class="container mx-auto p-4 text-center min-h-screen bg-gray-100">
<div class="bg-white p-6 rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg text-center">
<!-- Loading state -->
<ng-container *ngIf="verificationStatus === 'pending'"> <ng-container *ngIf="verificationStatus === 'pending'">
<div class="flex justify-center mb-4"> <p class="text-lg text-gray-600">Verifying your email...</p>
<div class="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<p class="text-gray-700">Verifying your email address...</p>
</ng-container> </ng-container>
<!-- Success state -->
<ng-container *ngIf="verificationStatus === 'success'"> <ng-container *ngIf="verificationStatus === 'success'">
<div class="flex justify-center mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 class="text-2xl font-bold text-green-600 mb-5">Your email has been verified</h2> <h2 class="text-2xl font-bold text-green-600 mb-5">Your email has been verified</h2>
<p class="text-gray-700 mb-4">You will be redirected to your account page in 5 seconds</p> <!-- <p class="text-gray-700 mb-4">You can now sign in with your new account</p> -->
<a routerLink="/account" class="inline-block px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"> Go to Account Page Now </a> <a routerLink="/account" class="inline-block px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors">Follow this link to access your Account Page </a>
</ng-container> </ng-container>
<!-- Error state -->
<ng-container *ngIf="verificationStatus === 'error'"> <ng-container *ngIf="verificationStatus === 'error'">
<div class="flex justify-center mb-4"> <h2 class="text-2xl font-bold text-red-600 mb-2">Verification failed</h2>
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <p class="text-gray-700">{{ errorMessage }}</p>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h2 class="text-2xl font-bold text-red-600 mb-3">Verification Failed</h2>
<p class="text-gray-700 mb-4">{{ errorMessage }}</p>
<a routerLink="/login" class="inline-block px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"> Return to Login </a>
</ng-container> </ng-container>
</div>
</div> </div>

View File

@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, RouterModule } from '@angular/router';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { AuthService } from '../../services/auth.service'; import { AuthService } from '../../services/auth.service';
import { UserService } from '../../services/user.service'; import { UserService } from '../../services/user.service';
@ -16,7 +16,7 @@ export class EmailAuthorizedComponent implements OnInit {
verificationStatus: 'pending' | 'success' | 'error' = 'pending'; verificationStatus: 'pending' | 'success' | 'error' = 'pending';
errorMessage: string | null = null; errorMessage: string | null = null;
constructor(private route: ActivatedRoute, private router: Router, private http: HttpClient, private authService: AuthService, private userService: UserService) {} constructor(private route: ActivatedRoute, private http: HttpClient, private authService: AuthService, private userService: UserService) {}
ngOnInit(): void { ngOnInit(): void {
const oobCode = this.route.snapshot.queryParamMap.get('oobCode'); const oobCode = this.route.snapshot.queryParamMap.get('oobCode');
@ -32,32 +32,11 @@ export class EmailAuthorizedComponent implements OnInit {
} }
private verifyEmail(oobCode: string, email: string): void { private verifyEmail(oobCode: string, email: string): void {
this.http.post<{ message: string; token: string }>(`${environment.apiBaseUrl}/bizmatch/auth/verify-email`, { oobCode, email }).subscribe({ this.http.post(`${environment.apiBaseUrl}/bizmatch/auth/verify-email`, { oobCode, email }).subscribe({
next: async response => { next: async () => {
this.verificationStatus = 'success'; this.verificationStatus = 'success';
await this.authService.refreshToken();
try {
// Use the custom token from the server to sign in with Firebase
await this.authService.signInWithCustomToken(response.token);
// Try to get user info
try {
const user = await this.userService.getByMail(email); const user = await this.userService.getByMail(email);
console.log('User retrieved:', user);
} catch (userError) {
console.error('Error getting user:', userError);
// Don't change verification status - it's still a success
}
// Redirect to dashboard after a short delay
setTimeout(() => {
this.router.navigate(['/account']);
}, 5000);
} catch (authError) {
console.error('Error signing in with custom token:', authError);
// Keep success status for verification, but add warning about login
this.errorMessage = 'Email verified, but there was an issue signing you in. Please try logging in manually.';
}
}, },
error: err => { error: err => {
this.verificationStatus = 'error'; this.verificationStatus = 'error';

View File

@ -1,5 +1,5 @@
<div class="flex flex-col items-center justify-center min-h-screen bg-gray-100"> <div class="flex flex-col items-center justify-center min-h-screen bg-gray-100">
<div class="bg-white p-8 rounded drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg w-full max-w-md text-center"> <div class="bg-white p-8 rounded shadow-md w-full max-w-md text-center">
<h2 class="text-2xl font-bold mb-4">Email Verification</h2> <h2 class="text-2xl font-bold mb-4">Email Verification</h2>
<p class="mb-4">A verification email has been sent to your email address. Please check your inbox and click the link to verify your account.</p> <p class="mb-4">A verification email has been sent to your email address. Please check your inbox and click the link to verify your account.</p>
<p>Once verified, please return to the application.</p> <p>Once verified, please return to the application.</p>

View File

@ -1,29 +1,43 @@
<nav class="bg-white border-gray-200 dark:bg-gray-900 print:hidden"> <nav class="bg-white border-gray-200 dark:bg-gray-900 print:hidden">
<div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4"> <div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4">
<a routerLink="/home" class="flex items-center space-x-3 rtl:space-x-reverse"> <a routerLink="/home" class="flex items-center space-x-3 rtl:space-x-reverse">
<img src="assets/images/header-logo.png" class="h-10" alt="Flowbite Logo" /> <img src="assets/images/header-logo.png" class="h-8" alt="Flowbite Logo" />
</a> </a>
<div class="flex items-center md:order-2 space-x-3 rtl:space-x-reverse"> <div class="flex items-center md:order-2 space-x-3 rtl:space-x-reverse">
<!-- Filter button --> <!-- Filter button -->
@if(isFilterUrl()){ @if(isFilterUrl()){
<button
type="button"
#triggerButton
(click)="openModal()"
id="filterDropdownButton"
class="max-sm:hidden px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
>
<i class="fas fa-filter mr-2"></i>Filter ({{ getNumberOfFiltersSet() }})
</button>
<!-- Sort button -->
<div class="relative"> <div class="relative">
<button <button
type="button" type="button"
id="sortDropdownButton" id="sortDropdownButton"
class="max-sm:hidden px-4 py-2 text-sm font-medium bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700" class="max-sm:hidden px-4 py-2 text-sm font-medium bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
(click)="toggleSortDropdown()" (click)="toggleSortDropdown()"
[ngClass]="{ 'text-blue-500': selectOptions.getSortByOption(sortBy) !== 'Sort', 'text-gray-900': selectOptions.getSortByOption(sortBy) === 'Sort' }" [ngClass]="{ 'text-blue-500': selectOptions.getSortByOption(criteria?.sortBy) !== 'Sort', 'text-gray-900': selectOptions.getSortByOption(criteria?.sortBy) === 'Sort' }"
> >
<i class="fas fa-sort mr-2"></i>{{ selectOptions.getSortByOption(sortBy) }} <i class="fas fa-sort mr-2"></i>{{ selectOptions.getSortByOption(criteria?.sortBy) }}
</button> </button>
<!-- Sort options dropdown --> <!-- Sort options dropdown -->
<div *ngIf="sortDropdownVisible" class="absolute right-0 z-50 w-48 md:mt-2 max-md:mt-20 max-md:mr-[-2.5rem] bg-white border border-gray-200 rounded-lg drop-shadow-custom-bg dark:bg-gray-800 dark:border-gray-600"> <div *ngIf="sortDropdownVisible" class="absolute right-0 z-50 w-48 md:mt-2 max-md:mt-20 max-md:mr-[-2.5rem] bg-white border border-gray-200 rounded-lg shadow-lg dark:bg-gray-800 dark:border-gray-600">
<ul class="py-1 text-sm text-gray-700 dark:text-gray-200"> <ul class="py-1 text-sm text-gray-700 dark:text-gray-200">
@for(item of sortByOptions; track item){ @for(item of sortByOptions; track item){
<li (click)="sortByFct(item.value)" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">{{ item.selectName ? item.selectName : item.name }}</li> <li (click)="sortBy(item.value)" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">{{ item.selectName ? item.selectName : item.name }}</li>
} }
<!-- <li (click)="sortBy('priceAsc')" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Price Ascending</li>
<li (click)="sortBy('priceDesc')" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Price Descending</li>
<li (click)="sortBy('creationDateFirst')" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Creation Date First</li>
<li (click)="sortBy('creationDateLast')" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Creation Date Last</li>
<li (click)="sortBy(null)" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer">Default Sorting</li> -->
</ul> </ul>
</div> </div>
</div> </div>
@ -37,7 +51,7 @@
data-dropdown-placement="bottom" data-dropdown-placement="bottom"
> >
<span class="sr-only">Open user menu</span> <span class="sr-only">Open user menu</span>
@if(isProfessional || (authService.isAdmin() | async) && user?.hasProfile){ @if(user?.hasProfile){
<img class="w-8 h-8 rounded-full object-cover" src="{{ profileUrl }}" alt="user photo" /> <img class="w-8 h-8 rounded-full object-cover" src="{{ profileUrl }}" alt="user photo" />
} @else { } @else {
<i class="flex justify-center items-center text-stone-50 w-8 h-8 rounded-full fa-solid fa-bars"></i> <i class="flex justify-center items-center text-stone-50 w-8 h-8 rounded-full fa-solid fa-bars"></i>
@ -54,9 +68,9 @@
<li> <li>
<a routerLink="/account" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Account</a> <a routerLink="/account" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Account</a>
</li> </li>
@if((user.customerType==='professional' && user.customerSubType==='broker') || user.customerType==='seller' || (authService.isAdmin() | async)){ @if(user.customerType==='professional' || user.customerType==='seller' || (authService.isAdmin() | async)){
<li> <li>
@if(user.customerType==='professional'){ @if(user.customerSubType==='broker'){
<a routerLink="/createBusinessListing" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white" <a routerLink="/createBusinessListing" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white"
>Create Listing</a >Create Listing</a
> >
@ -69,10 +83,10 @@
<li> <li>
<a routerLink="/myListings" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">My Listings</a> <a routerLink="/myListings" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">My Listings</a>
</li> </li>
}
<li> <li>
<a routerLink="/myFavorites" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">My Favorites</a> <a routerLink="/myFavorites" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">My Favorites</a>
</li> </li>
}
<li> <li>
<a routerLink="/emailUs" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">EMail Us</a> <a routerLink="/emailUs" (click)="closeDropdown()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">EMail Us</a>
</li> </li>
@ -127,7 +141,7 @@
<a routerLink="/login" [queryParams]="{ mode: 'login' }" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Log In</a> <a routerLink="/login" [queryParams]="{ mode: 'login' }" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Log In</a>
</li> </li>
<li> <li>
<a routerLink="/login" [queryParams]="{ mode: 'register' }" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Sign Up</a> <a routerLink="/login" [queryParams]="{ mode: 'register' }" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 dark:text-gray-200 dark:hover:text-white">Register</a>
</li> </li>
</ul> </ul>
<ul class="py-2 md:hidden"> <ul class="py-2 md:hidden">
@ -164,6 +178,18 @@
</ul> </ul>
</div> </div>
} }
<!-- <button
data-collapse-toggle="navbar-user"
type="button"
class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
aria-controls="navbar-user"
aria-expanded="false"
>
<span class="sr-only">Open main menu</span>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" />
</svg>
</button> -->
</div> </div>
<div class="items-center justify-between hidden w-full md:flex md:w-auto md:order-1" id="navbar-user"> <div class="items-center justify-between hidden w-full md:flex md:w-auto md:order-1" id="navbar-user">
<ul <ul
@ -207,15 +233,26 @@
</div> </div>
</div> </div>
<!-- Mobile filter button --> <!-- Mobile filter button -->
@if(isFilterUrl()){
<div class="md:hidden flex justify-center pb-4"> <div class="md:hidden flex justify-center pb-4">
<button
(click)="openModal()"
type="button"
id="filterDropdownMobileButton"
class="w-full mx-4 px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
>
<i class="fas fa-filter mr-2"></i>Filter ({{ getNumberOfFiltersSet() }})
</button>
<!-- Sorting -->
<button <button
(click)="toggleSortDropdown()" (click)="toggleSortDropdown()"
type="button" type="button"
id="sortDropdownMobileButton" id="sortDropdownMobileButton"
class="mx-4 w-1/2 px-4 py-2 text-sm font-medium bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700" class="mx-4 w-1/2 px-4 py-2 text-sm font-medium bg-white border border-gray-200 rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
[ngClass]="{ 'text-blue-500': selectOptions.getSortByOption(sortBy) !== 'Sort', 'text-gray-900': selectOptions.getSortByOption(sortBy) === 'Sort' }" [ngClass]="{ 'text-blue-500': selectOptions.getSortByOption(criteria?.sortBy) !== 'Sort', 'text-gray-900': selectOptions.getSortByOption(criteria?.sortBy) === 'Sort' }"
> >
<i class="fas fa-sort mr-2"></i>{{ selectOptions.getSortByOption(sortBy) }} <i class="fas fa-sort mr-2"></i>{{ selectOptions.getSortByOption(criteria?.sortBy) }}
</button> </button>
</div> </div>
}
</nav> </nav>

View File

@ -1,26 +1,25 @@
import { BreakpointObserver } from '@angular/cdk/layout';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, HostListener, OnDestroy, OnInit } from '@angular/core'; import { Component, HostListener } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { NavigationEnd, Router, RouterModule } from '@angular/router'; import { NavigationEnd, Router, RouterModule } from '@angular/router';
import { faUserGear } from '@fortawesome/free-solid-svg-icons'; import { faUserGear } from '@fortawesome/free-solid-svg-icons';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { Collapse, Dropdown, initFlowbite } from 'flowbite'; import { Collapse, Dropdown, initFlowbite } from 'flowbite';
import { filter, Observable, Subject, takeUntil } from 'rxjs'; import { filter, Observable, Subject, Subscription } from 'rxjs';
import { SortByOptions, User } from '../../../../../bizmatch-server/src/models/db.model'; import { SortByOptions, User } from '../../../../../bizmatch-server/src/models/db.model';
import { emailToDirName, KeycloakUser, KeyValueAsSortBy, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model'; import { BusinessListingCriteria, CommercialPropertyListingCriteria, emailToDirName, KeycloakUser, KeyValueAsSortBy, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { AuthService } from '../../services/auth.service'; import { AuthService } from '../../services/auth.service';
import { FilterStateService } from '../../services/filter-state.service'; import { CriteriaChangeService } from '../../services/criteria-change.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 { SharedService } from '../../services/shared.service'; import { SharedService } from '../../services/shared.service';
import { UserService } from '../../services/user.service'; import { UserService } from '../../services/user.service';
import { map2User } from '../../utils/utils'; import { assignProperties, compareObjects, createEmptyBusinessListingCriteria, createEmptyCommercialPropertyListingCriteria, createEmptyUserListingCriteria, getCriteriaProxy, map2User } from '../../utils/utils';
import { DropdownComponent } from '../dropdown/dropdown.component'; import { DropdownComponent } from '../dropdown/dropdown.component';
import { ModalService } from '../search-modal/modal.service'; import { ModalService } from '../search-modal/modal.service';
@UntilDestroy() @UntilDestroy()
@Component({ @Component({
selector: 'header', selector: 'header',
@ -29,7 +28,7 @@ import { ModalService } from '../search-modal/modal.service';
templateUrl: './header.component.html', templateUrl: './header.component.html',
styleUrl: './header.component.scss', styleUrl: './header.component.scss',
}) })
export class HeaderComponent implements OnInit, OnDestroy { export class HeaderComponent {
public buildVersion = environment.buildVersion; public buildVersion = environment.buildVersion;
user$: Observable<KeycloakUser>; user$: Observable<KeycloakUser>;
keycloakUser: KeycloakUser; keycloakUser: KeycloakUser;
@ -42,31 +41,26 @@ export class HeaderComponent implements OnInit, OnDestroy {
isMobile: boolean = false; isMobile: boolean = false;
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
prompt: string; prompt: string;
private subscription: Subscription;
// Aktueller Listing-Typ basierend auf Route criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
currentListingType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings' | null = null; private routerSubscription: Subscription | undefined;
baseRoute: string;
// Sortierung sortDropdownVisible: boolean;
sortDropdownVisible: boolean = false;
sortByOptions: KeyValueAsSortBy[] = []; sortByOptions: KeyValueAsSortBy[] = [];
sortBy: SortByOptions = null;
// Observable für Anzahl der Listings
numberOfBroker$: Observable<number>; numberOfBroker$: Observable<number>;
numberOfCommercial$: Observable<number>; numberOfCommercial$: Observable<number>;
constructor( constructor(
private router: Router, private router: Router,
private userService: UserService, private userService: UserService,
private sharedService: SharedService, private sharedService: SharedService,
private breakpointObserver: BreakpointObserver,
private modalService: ModalService, private modalService: ModalService,
private searchService: SearchService, private searchService: SearchService,
private filterStateService: FilterStateService, private criteriaChangeService: CriteriaChangeService,
public selectOptions: SelectOptionsService, public selectOptions: SelectOptionsService,
public authService: AuthService, public authService: AuthService,
private listingService: ListingsService, private listingService: ListingsService,
) {} ) {}
@HostListener('document:click', ['$event']) @HostListener('document:click', ['$event'])
handleGlobalClick(event: Event) { handleGlobalClick(event: Event) {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
@ -74,125 +68,64 @@ export class HeaderComponent implements OnInit, OnDestroy {
this.sortDropdownVisible = false; this.sortDropdownVisible = false;
} }
} }
async ngOnInit() { async ngOnInit() {
// User Setup
const token = await this.authService.getToken(); const token = await this.authService.getToken();
this.keycloakUser = map2User(token); this.keycloakUser = map2User(token);
if (this.keycloakUser) { if (this.keycloakUser) {
this.user = await this.userService.getByMail(this.keycloakUser?.email); this.user = await this.userService.getByMail(this.keycloakUser?.email);
this.profileUrl = this.user.hasProfile ? `${this.env.imageBaseUrl}/pictures/profile/${emailToDirName(this.user.email)}.avif?_ts=${new Date().getTime()}` : `/assets/images/placeholder.png`; this.profileUrl = this.user.hasProfile ? `${this.env.imageBaseUrl}/pictures/profile/${emailToDirName(this.user.email)}.avif?_ts=${new Date().getTime()}` : `/assets/images/placeholder.png`;
} }
this.numberOfBroker$ = this.userService.getNumberOfBroker(createEmptyUserListingCriteria());
// Lade Anzahl der Listings this.numberOfCommercial$ = this.listingService.getNumberOfListings(createEmptyCommercialPropertyListingCriteria(), 'commercialProperty');
this.numberOfBroker$ = this.userService.getNumberOfBroker(this.createEmptyUserListingCriteria());
this.numberOfCommercial$ = this.listingService.getNumberOfListings('commercialProperty');
// Flowbite initialisieren
setTimeout(() => { setTimeout(() => {
initFlowbite(); initFlowbite();
}, 10); }, 10);
// Profile Photo Updates this.sharedService.currentProfilePhoto.subscribe(photoUrl => {
this.sharedService.currentProfilePhoto.pipe(untilDestroyed(this)).subscribe(photoUrl => {
this.profileUrl = photoUrl; this.profileUrl = photoUrl;
}); });
// User Updates this.checkCurrentRoute(this.router.url);
this.setupSortByOptions();
this.routerSubscription = this.router.events.pipe(filter(event => event instanceof NavigationEnd)).subscribe((event: any) => {
this.checkCurrentRoute(event.urlAfterRedirects);
this.setupSortByOptions();
});
this.userService.currentUser.pipe(untilDestroyed(this)).subscribe(u => { this.userService.currentUser.pipe(untilDestroyed(this)).subscribe(u => {
this.user = u; this.user = u;
}); });
// Router Events
this.router.events
.pipe(
filter(event => event instanceof NavigationEnd),
untilDestroyed(this),
)
.subscribe((event: NavigationEnd) => {
this.checkCurrentRoute(event.urlAfterRedirects);
});
// Initial Route Check
this.checkCurrentRoute(this.router.url);
} }
private checkCurrentRoute(url: string): void { private checkCurrentRoute(url: string): void {
const baseRoute = url.split('/')[1]; this.baseRoute = url.split('/')[1]; // Nimmt den ersten Teil der Route nach dem ersten '/'
const specialRoutes = [, '', ''];
// Bestimme den aktuellen Listing-Typ this.criteria = getCriteriaProxy(this.baseRoute, this);
if (baseRoute === 'businessListings') { // this.searchService.search(this.criteria);
this.currentListingType = 'businessListings';
} else if (baseRoute === 'commercialPropertyListings') {
this.currentListingType = 'commercialPropertyListings';
} else if (baseRoute === 'brokerListings') {
this.currentListingType = 'brokerListings';
} else {
this.currentListingType = null;
return; // Keine relevante Route für Filter/Sort
} }
setupSortByOptions() {
// Setup für diese Route
this.setupSortByOptions();
this.subscribeToStateChanges();
}
private subscribeToStateChanges(): void {
if (!this.currentListingType) return;
// Abonniere State-Änderungen für den aktuellen Listing-Typ
this.filterStateService
.getState$(this.currentListingType)
.pipe(takeUntil(this.destroy$))
.subscribe(state => {
this.sortBy = state.sortBy;
});
}
private setupSortByOptions(): void {
this.sortByOptions = []; this.sortByOptions = [];
if (this.isProfessionalListing()) {
if (!this.currentListingType) return; this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => s.type === 'professional')];
}
switch (this.currentListingType) { if (this.isBusinessListing()) {
case 'brokerListings': this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => s.type === 'business' || s.type === 'listing')];
this.sortByOptions = [...this.selectOptions.sortByOptions.filter(s => s.type === 'professional')]; }
break; if (this.isCommercialPropertyListing()) {
case 'businessListings': this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => s.type === 'commercial' || s.type === 'listing')];
this.sortByOptions = [...this.selectOptions.sortByOptions.filter(s => s.type === 'business' || s.type === 'listing')];
break;
case 'commercialPropertyListings':
this.sortByOptions = [...this.selectOptions.sortByOptions.filter(s => s.type === 'commercial' || s.type === 'listing')];
break;
} }
// Füge generische Optionen hinzu (ohne type)
this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => !s.type)]; this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => !s.type)];
} }
ngAfterViewInit() {}
sortByFct(selectedSortBy: SortByOptions): void {
if (!this.currentListingType) return;
this.sortDropdownVisible = false;
// Update sortBy im State
this.filterStateService.updateSortBy(this.currentListingType, selectedSortBy);
// Trigger search
this.searchService.search(this.currentListingType);
}
async openModal() { async openModal() {
if (!this.currentListingType) return; const modalResult = await this.modalService.showModal(this.criteria);
const criteria = this.filterStateService.getCriteria(this.currentListingType);
const modalResult = await this.modalService.showModal(criteria);
if (modalResult.accepted) { if (modalResult.accepted) {
this.searchService.search(this.currentListingType); this.searchService.search(this.criteria);
} else {
this.criteria = assignProperties(this.criteria, modalResult.criteria);
} }
} }
navigateWithState(dest: string, state: any) { navigateWithState(dest: string, state: any) {
this.router.navigate([dest], { state: state }); this.router.navigate([dest], { state: state });
} }
@ -200,23 +133,21 @@ export class HeaderComponent implements OnInit, OnDestroy {
isActive(route: string): boolean { isActive(route: string): boolean {
return this.router.url === route; return this.router.url === route;
} }
isFilterUrl(): boolean { isFilterUrl(): boolean {
return ['/businessListings', '/commercialPropertyListings', '/brokerListings'].includes(this.router.url); return ['/businessListings', '/commercialPropertyListings', '/brokerListings'].includes(this.router.url);
} }
isBusinessListing(): boolean { isBusinessListing(): boolean {
return this.router.url === '/businessListings'; return ['/businessListings'].includes(this.router.url);
} }
isCommercialPropertyListing(): boolean { isCommercialPropertyListing(): boolean {
return this.router.url === '/commercialPropertyListings'; return ['/commercialPropertyListings'].includes(this.router.url);
} }
isProfessionalListing(): boolean { isProfessionalListing(): boolean {
return this.router.url === '/brokerListings'; return ['/brokerListings'].includes(this.router.url);
} }
// isSortingUrl(): boolean {
// return ['/businessListings', '/commercialPropertyListings'].includes(this.router.url);
// }
closeDropdown() { closeDropdown() {
const dropdownButton = document.getElementById('user-menu-button'); const dropdownButton = document.getElementById('user-menu-button');
const dropdownMenu = this.user ? document.getElementById('user-login') : document.getElementById('user-unknown'); const dropdownMenu = this.user ? document.getElementById('user-login') : document.getElementById('user-unknown');
@ -226,7 +157,6 @@ export class HeaderComponent implements OnInit, OnDestroy {
dropdown.hide(); dropdown.hide();
} }
} }
closeMobileMenu() { closeMobileMenu() {
const targetElement = document.getElementById('navbar-user'); const targetElement = document.getElementById('navbar-user');
const triggerElement = document.querySelector('[data-collapse-toggle="navbar-user"]'); const triggerElement = document.querySelector('[data-collapse-toggle="navbar-user"]');
@ -236,60 +166,36 @@ export class HeaderComponent implements OnInit, OnDestroy {
collapse.collapse(); collapse.collapse();
} }
} }
closeMenusAndSetCriteria(path: string) { closeMenusAndSetCriteria(path: string) {
this.closeDropdown(); this.closeDropdown();
this.closeMobileMenu(); this.closeMobileMenu();
const criteria = getCriteriaProxy(path, this);
// Bestimme Listing-Typ aus dem Pfad criteria.page = 1;
let listingType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings' | null = null; criteria.start = 0;
if (path === 'businessListings') {
listingType = 'businessListings';
} else if (path === 'commercialPropertyListings') {
listingType = 'commercialPropertyListings';
} else if (path === 'brokerListings') {
listingType = 'brokerListings';
}
if (listingType) {
// Reset Pagination beim Wechsel zwischen Views
this.filterStateService.updateCriteria(listingType, {
page: 1,
start: 0,
});
}
}
toggleSortDropdown() {
this.sortDropdownVisible = !this.sortDropdownVisible;
}
get isProfessional() {
return this.user?.customerType === 'professional';
}
// Helper method für leere UserListingCriteria
private createEmptyUserListingCriteria(): UserListingCriteria {
return {
criteriaType: 'brokerListings',
types: [],
state: null,
city: null,
radius: null,
searchType: 'exact' as const,
brokerName: null,
companyName: null,
counties: [],
prompt: null,
page: 1,
start: 0,
length: 12,
};
} }
ngOnDestroy() { ngOnDestroy() {
this.destroy$.next(); this.destroy$.next();
this.destroy$.complete(); this.destroy$.complete();
} }
getNumberOfFiltersSet() {
if (this.criteria?.criteriaType === 'brokerListings') {
return compareObjects(createEmptyUserListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius', 'sortBy']);
} else if (this.criteria?.criteriaType === 'businessListings') {
return compareObjects(createEmptyBusinessListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius', 'sortBy']);
} else if (this.criteria?.criteriaType === 'commercialPropertyListings') {
return compareObjects(createEmptyCommercialPropertyListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius', 'sortBy']);
} else {
return 0;
}
}
sortBy(sortBy: SortByOptions) {
this.criteria.sortBy = sortBy;
this.sortDropdownVisible = false;
this.searchService.search(this.criteria);
}
toggleSortDropdown() {
this.sortDropdownVisible = !this.sortDropdownVisible;
}
} }

View File

@ -1,5 +1,5 @@
<div class="flex flex-col items-center justify-center min-h-screen"> <div class="flex flex-col items-center justify-center min-h-screen bg-gray-100">
<div class="bg-white p-8 rounded-lg drop-shadow-custom-bg w-full max-w-md"> <div class="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
<h2 class="text-2xl font-bold mb-6 text-center text-gray-800"> <h2 class="text-2xl font-bold mb-6 text-center text-gray-800">
{{ isLoginMode ? 'Login' : 'Sign Up' }} {{ isLoginMode ? 'Login' : 'Sign Up' }}
</h2> </h2>
@ -83,6 +83,12 @@
<!-- Google Button --> <!-- Google Button -->
<button (click)="loginWithGoogle()" class="w-full flex items-center justify-center bg-white border border-gray-300 hover:bg-gray-50 text-gray-700 py-2.5 rounded-lg transition-colors duration-200"> <button (click)="loginWithGoogle()" class="w-full flex items-center justify-center bg-white border border-gray-300 hover:bg-gray-50 text-gray-700 py-2.5 rounded-lg transition-colors duration-200">
<!-- <svg class="h-5 w-5 mr-2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M12.24 10.32V13.8H15.48C15.336 14.688 14.568 16.368 12.24 16.368C10.224 16.368 8.568 14.688 8.568 12.672C8.568 10.656 10.224 8.976 12.24 8.976C13.32 8.976 14.16 9.432 14.688 10.08L16.704 8.208C15.528 7.032 14.04 6.384 12.24 6.384C8.832 6.384 6 9.216 6 12.672C6 16.128 8.832 18.96 12.24 18.96C15.696 18.96 18.12 16.656 18.12 12.672C18.12 11.952 18.024 11.28 17.88 10.656L12.24 10.32Z"
fill="currentColor"
/>
</svg> -->
<svg class="w-6 h-6 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"> <svg class="w-6 h-6 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<path <path
fill="#FFC107" fill="#FFC107"
@ -94,5 +100,17 @@
</svg> </svg>
Continue with Google Continue with Google
</button> </button>
<!-- <button (click)="loginWithGoogle()" class="bg-white text-blue-600 px-6 py-3 rounded-lg shadow-lg hover:bg-gray-100 transition duration-300 flex items-center justify-center">
<svg class="w-6 h-6 mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<path
fill="#FFC107"
d="M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 12.955 4 4 12.955 4 24s8.955 20 20 20 20-8.955 20-20c0-1.341-.138-2.65-.389-3.917z"
/>
<path fill="#FF3D00" d="M6.306 14.691l6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 16.318 4 9.656 8.337 6.306 14.691z" />
<path fill="#4CAF50" d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238A11.91 11.91 0 0124 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44z" />
<path fill="#1976D2" d="M43.611 20.083H42V20H24v8h11.303a12.04 12.04 0 01-4.087 5.571l.003-.002 6.19 5.238C36.971 39.205 44 34 44 24c0-1.341-.138-2.65-.389-3.917z" />
</svg>
Continue with Google
</button> -->
</div> </div>
</div> </div>

View File

@ -1,3 +1,4 @@
// 1. Shared Service (modal.service.ts)
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs'; import { BehaviorSubject, Observable } from 'rxjs';
import { BusinessListingCriteria, CommercialPropertyListingCriteria, ModalResult, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model'; import { BusinessListingCriteria, CommercialPropertyListingCriteria, ModalResult, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
@ -6,33 +7,28 @@ import { BusinessListingCriteria, CommercialPropertyListingCriteria, ModalResult
providedIn: 'root', providedIn: 'root',
}) })
export class ModalService { export class ModalService {
private modalVisibleSubject = new BehaviorSubject<{ visible: boolean; type?: string }>({ visible: false }); private modalVisibleSubject = new BehaviorSubject<boolean>(false);
private messageSubject = new BehaviorSubject<BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria>(null); private messageSubject = new BehaviorSubject<BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria>(null);
private resolvePromise!: (value: ModalResult) => void; private resolvePromise!: (value: ModalResult) => void;
modalVisible$: Observable<{ visible: boolean; type?: string }> = this.modalVisibleSubject.asObservable(); modalVisible$: Observable<boolean> = this.modalVisibleSubject.asObservable();
message$: Observable<BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria> = this.messageSubject.asObservable(); message$: Observable<BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria> = this.messageSubject.asObservable();
showModal(message: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria): Promise<ModalResult> { showModal(message: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria): Promise<ModalResult> {
this.messageSubject.next(message); this.messageSubject.next(message);
this.modalVisibleSubject.next({ visible: true, type: message.criteriaType }); this.modalVisibleSubject.next(true);
return new Promise<ModalResult>(resolve => {
this.resolvePromise = resolve;
});
}
sendCriteria(message: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria): Promise<ModalResult> {
this.messageSubject.next(message);
return new Promise<ModalResult>(resolve => { return new Promise<ModalResult>(resolve => {
this.resolvePromise = resolve; this.resolvePromise = resolve;
}); });
} }
accept(): void { accept(): void {
this.modalVisibleSubject.next({ visible: false }); this.modalVisibleSubject.next(false);
this.resolvePromise({ accepted: true }); this.resolvePromise({ accepted: true });
} }
reject(backupCriteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria): void { reject(backupCriteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria): void {
this.modalVisibleSubject.next({ visible: false }); this.modalVisibleSubject.next(false);
this.resolvePromise({ accepted: false, criteria: backupCriteria }); this.resolvePromise({ accepted: false, criteria: backupCriteria });
} }
} }

View File

@ -1,222 +0,0 @@
<div
*ngIf="isModal && (modalService.modalVisible$ | async)?.visible && (modalService.modalVisible$ | async)?.type === 'commercialPropertyListings'"
class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50"
>
<div class="relative w-full h-screen max-h-screen">
<div class="relative bg-white rounded-lg shadow h-full">
<div class="flex items-start justify-between p-4 border-b rounded-t bg-blue-600">
<h3 class="text-xl font-semibold text-white p-2 rounded">Commercial Property Listing Search</h3>
<button (click)="closeAndSearch()" type="button" class="text-white bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ml-auto inline-flex justify-center items-center">
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
</svg>
<span class="sr-only">Close Modal</span>
</button>
</div>
<div class="p-6 space-y-6">
<div class="flex space-x-4 mb-4">
<button class="text-blue-600 font-medium border-b-2 border-blue-600 pb-2">Filter ({{ numberOfResults$ | async }})</button>
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-blue-500" (click)="clearFilter()"></i>
<div id="tooltip-light" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg shadow-sm opacity-0 tooltip">
Clear all Filter
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
<!-- Display active filters as tags -->
<div class="flex flex-wrap gap-2 mb-4" *ngIf="hasActiveFilters()">
<span *ngIf="criteria.state" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.city" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minPrice || criteria.maxPrice" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.types.length" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.title" class="bg-gray-200 text-gray-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>
</span>
</div>
@if(criteria.criteriaType==='commercialPropertyListings') {
<div class="grid grid-cols-1 gap-6">
<div class="space-y-4">
<div>
<label for="state" class="block mb-2 text-sm font-medium text-gray-900">Location - State</label>
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select>
</div>
<div>
<app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-gray-900 font-medium" [state]="criteria.state"></app-validated-city>
</div>
<div *ngIf="criteria.city">
<label class="block mb-2 text-sm font-medium text-gray-900">Search Type</label>
<div class="flex items-center space-x-4">
<label class="inline-flex items-center">
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="exact" />
<span class="ml-2">Exact City</span>
</label>
<label class="inline-flex items-center">
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
<span class="ml-2">Radius Search</span>
</label>
</div>
</div>
<div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Select Radius (in miles)</label>
<div class="flex flex-wrap">
@for (radius of [5, 20, 50, 100, 200, 300, 400, 500]; track radius) {
<button
type="button"
class="px-3 py-2 text-xs font-medium text-center border border-gray-200 hover:bg-gray-500 hover:text-white"
[ngClass]="criteria.radius === radius ? 'text-white bg-gray-500' : 'text-gray-900 bg-white'"
(click)="setRadius(radius)"
>
{{ radius }}
</button>
}
</div>
</div>
<div>
<label for="price" class="block mb-2 text-sm font-medium text-gray-900">Price</label>
<div class="flex items-center space-x-2">
<app-validated-price name="price-from" [ngModel]="criteria.minPrice" (ngModelChange)="updateCriteria({ minPrice: $event })" placeholder="From" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5">
</app-validated-price>
<span>-</span>
<app-validated-price name="price-to" [ngModel]="criteria.maxPrice" (ngModelChange)="updateCriteria({ maxPrice: $event })" placeholder="To" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5">
</app-validated-price>
</div>
</div>
<div>
<label for="title" class="block mb-2 text-sm font-medium text-gray-900">Title / Description (Free Search)</label>
<input
type="text"
id="title"
[ngModel]="criteria.title"
(ngModelChange)="updateCriteria({ title: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. Office Space"
/>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-gray-900">Category</label>
<ng-select
class="custom"
[items]="selectOptions.typesOfCommercialProperty"
bindLabel="name"
bindValue="value"
[ngModel]="criteria.types"
(ngModelChange)="onCategoryChange($event)"
[multiple]="true"
[closeOnSelect]="true"
placeholder="Select categories"
></ng-select>
</div>
</div>
</div>
}
</div>
</div>
</div>
</div>
<div *ngIf="!isModal" class="space-y-6 pb-10">
<div class="flex space-x-4 mb-4">
<h3 class="text-xl font-semibold text-gray-900">Filter ({{ numberOfResults$ | async }})</h3>
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-blue-500" (click)="clearFilter()"></i>
<div id="tooltip-light" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg shadow-sm opacity-0 tooltip">
Clear all Filter
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
<!-- Display active filters as tags -->
<div class="flex flex-wrap gap-2" *ngIf="hasActiveFilters()">
<span *ngIf="criteria.state" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.city" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minPrice || criteria.maxPrice" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.types.length" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.title" class="bg-gray-200 text-gray-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>
</span>
</div>
@if(criteria.criteriaType==='commercialPropertyListings') {
<div class="space-y-4">
<div>
<label for="state" class="block mb-2 text-sm font-medium text-gray-900">Location - State</label>
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select>
</div>
<div>
<app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-gray-900 font-medium" [state]="criteria.state"></app-validated-city>
</div>
<div *ngIf="criteria.city">
<label class="block mb-2 text-sm font-medium text-gray-900">Search Type</label>
<div class="flex items-center space-x-4">
<label class="inline-flex items-center">
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="exact" />
<span class="ml-2">Exact City</span>
</label>
<label class="inline-flex items-center">
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" />
<span class="ml-2">Radius Search</span>
</label>
</div>
</div>
<div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Select Radius (in miles)</label>
<div class="flex flex-wrap">
@for (radius of [5, 20, 50, 100, 200, 300, 400, 500]; track radius) {
<button
type="button"
class="px-3 py-2 text-xs font-medium text-center border border-gray-200 hover:bg-gray-500 hover:text-white"
[ngClass]="criteria.radius === radius ? 'text-white bg-gray-500' : 'text-gray-900 bg-white'"
(click)="setRadius(radius)"
>
{{ radius }}
</button>
}
</div>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-gray-900">Category</label>
<ng-select
class="custom"
[items]="selectOptions.typesOfCommercialProperty"
bindLabel="name"
bindValue="value"
[ngModel]="criteria.types"
(ngModelChange)="onCategoryChange($event)"
[multiple]="true"
[closeOnSelect]="true"
placeholder="Select categories"
></ng-select>
</div>
<div>
<label for="price" class="block mb-2 text-sm font-medium text-gray-900">Price</label>
<div class="flex items-center space-x-2">
<app-validated-price name="price-from" [ngModel]="criteria.minPrice" (ngModelChange)="updateCriteria({ minPrice: $event })" placeholder="From" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"> </app-validated-price>
<span>-</span>
<app-validated-price name="price-to" [ngModel]="criteria.maxPrice" (ngModelChange)="updateCriteria({ maxPrice: $event })" placeholder="To" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"> </app-validated-price>
</div>
</div>
<div>
<label for="title" class="block mb-2 text-sm font-medium text-gray-900">Title / Description (Free Search)</label>
<input
type="text"
id="title"
[ngModel]="criteria.title"
(ngModelChange)="updateCriteria({ title: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. Office Space"
/>
</div>
</div>
}
</div>

View File

@ -1,301 +0,0 @@
import { CommonModule } from '@angular/common';
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NgSelectModule } from '@ng-select/ng-select';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { catchError, concat, debounceTime, distinctUntilChanged, map, Observable, of, Subject, switchMap, takeUntil, tap } from 'rxjs';
import { CommercialPropertyListingCriteria, CountyResult, GeoResult } from '../../../../../bizmatch-server/src/models/main.model';
import { FilterStateService } from '../../services/filter-state.service';
import { GeoService } from '../../services/geo.service';
import { ListingsService } from '../../services/listings.service';
import { SearchService } from '../../services/search.service';
import { SelectOptionsService } from '../../services/select-options.service';
import { ValidatedCityComponent } from '../validated-city/validated-city.component';
import { ValidatedPriceComponent } from '../validated-price/validated-price.component';
import { ModalService } from './modal.service';
@UntilDestroy()
@Component({
selector: 'app-search-modal-commercial',
standalone: true,
imports: [CommonModule, FormsModule, NgSelectModule, ValidatedCityComponent, ValidatedPriceComponent],
templateUrl: './search-modal-commercial.component.html',
styleUrls: ['./search-modal.component.scss'],
})
export class SearchModalCommercialComponent implements OnInit, OnDestroy {
@Input() isModal: boolean = true;
private destroy$ = new Subject<void>();
private searchDebounce$ = new Subject<void>();
// State
criteria: CommercialPropertyListingCriteria;
backupCriteria: any;
// Geo search
counties$: Observable<CountyResult[]>;
countyLoading = false;
countyInput$ = new Subject<string>();
// Results count
numberOfResults$: Observable<number>;
cancelDisable = false;
constructor(
public selectOptions: SelectOptionsService,
public modalService: ModalService,
private geoService: GeoService,
private filterStateService: FilterStateService,
private listingService: ListingsService,
private searchService: SearchService,
) {}
ngOnInit(): void {
// Load counties
this.loadCounties();
if (this.isModal) {
// Modal mode: Wait for messages from ModalService
this.modalService.message$.pipe(untilDestroyed(this)).subscribe(criteria => {
if (criteria?.criteriaType === 'commercialPropertyListings') {
this.initializeWithCriteria(criteria);
}
});
this.modalService.modalVisible$.pipe(untilDestroyed(this)).subscribe(val => {
if (val.visible && val.type === 'commercialPropertyListings') {
// Reset pagination when modal opens
if (this.criteria) {
this.criteria.page = 1;
this.criteria.start = 0;
}
}
});
} else {
// Embedded mode: Subscribe to state changes
this.subscribeToStateChanges();
}
// Setup debounced search
this.searchDebounce$.pipe(debounceTime(400), takeUntil(this.destroy$)).subscribe(() => {
this.triggerSearch();
});
}
private initializeWithCriteria(criteria: CommercialPropertyListingCriteria): void {
this.criteria = criteria;
this.backupCriteria = JSON.parse(JSON.stringify(criteria));
this.setTotalNumberOfResults();
}
private subscribeToStateChanges(): void {
if (!this.isModal) {
this.filterStateService
.getState$('commercialPropertyListings')
.pipe(takeUntil(this.destroy$))
.subscribe(state => {
this.criteria = { ...state.criteria };
this.setTotalNumberOfResults();
});
}
}
private loadCounties(): void {
this.counties$ = concat(
of([]), // default items
this.countyInput$.pipe(
distinctUntilChanged(),
tap(() => (this.countyLoading = true)),
switchMap(term =>
this.geoService.findCountiesStartingWith(term).pipe(
catchError(() => of([])),
map(counties => counties.map(county => county.name)),
tap(() => (this.countyLoading = false)),
),
),
),
);
}
// Filter removal methods
removeFilter(filterType: string): void {
const updates: any = {};
switch (filterType) {
case 'state':
updates.state = null;
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
break;
case 'city':
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
break;
case 'price':
updates.minPrice = null;
updates.maxPrice = null;
break;
case 'types':
updates.types = [];
break;
case 'title':
updates.title = null;
break;
}
this.updateCriteria(updates);
}
// Category handling
onCategoryChange(selectedCategories: string[]): void {
this.updateCriteria({ types: selectedCategories });
}
categoryClicked(checked: boolean, value: string): void {
const types = [...(this.criteria.types || [])];
if (checked) {
if (!types.includes(value)) {
types.push(value);
}
} else {
const index = types.indexOf(value);
if (index > -1) {
types.splice(index, 1);
}
}
this.updateCriteria({ types });
}
// Location handling
setState(state: string): void {
const updates: any = { state };
if (!state) {
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
}
this.updateCriteria(updates);
}
setCity(city: any): void {
const updates: any = {};
if (city) {
updates.city = city;
updates.state = city.state;
} else {
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
}
this.updateCriteria(updates);
}
setRadius(radius: number): void {
this.updateCriteria({ radius });
}
onCriteriaChange(): void {
this.triggerSearch();
}
// Debounced search for text inputs
debouncedSearch(): void {
this.searchDebounce$.next();
}
// Clear all filters
clearFilter(): void {
if (this.isModal) {
// In modal: Reset locally
const defaultCriteria = this.getDefaultCriteria();
this.criteria = defaultCriteria;
this.setTotalNumberOfResults();
} else {
// Embedded: Use state service
this.filterStateService.clearFilters('commercialPropertyListings');
}
}
// Modal-specific methods
closeAndSearch(): void {
if (this.isModal) {
// Save changes to state
this.filterStateService.setCriteria('commercialPropertyListings', this.criteria);
this.modalService.accept();
this.searchService.search('commercialPropertyListings');
}
}
close(): void {
if (this.isModal) {
// Discard changes
this.modalService.reject(this.backupCriteria);
}
}
// Helper methods
public updateCriteria(updates: any): void {
if (this.isModal) {
// In modal: Update locally only
this.criteria = { ...this.criteria, ...updates };
this.setTotalNumberOfResults();
} else {
// Embedded: Update through state service
this.filterStateService.updateCriteria('commercialPropertyListings', updates);
}
// Trigger search after update
this.debouncedSearch();
}
private triggerSearch(): void {
if (this.isModal) {
// In modal: Only update count
this.setTotalNumberOfResults();
this.cancelDisable = true;
} else {
// Embedded: Full search
this.searchService.search('commercialPropertyListings');
}
}
private setTotalNumberOfResults(): void {
this.numberOfResults$ = this.listingService.getNumberOfListings('commercialProperty', this.criteria);
}
private getDefaultCriteria(): CommercialPropertyListingCriteria {
// Access the private method through a workaround or create it here
return {
criteriaType: 'commercialPropertyListings',
types: [],
state: null,
city: null,
radius: null,
searchType: 'exact' as const,
minPrice: null,
maxPrice: null,
title: null,
prompt: null,
page: 1,
start: 0,
length: 12,
};
}
hasActiveFilters(): boolean {
if (!this.criteria) return false;
return !!(this.criteria.state || this.criteria.city || this.criteria.minPrice || this.criteria.maxPrice || this.criteria.types?.length || this.criteria.title);
}
trackByFn(item: GeoResult): any {
return item.id;
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@ -1,12 +1,15 @@
<div <div *ngIf="modalService.modalVisible$ | async" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center">
*ngIf="isModal && (modalService.modalVisible$ | async)?.visible && (modalService.modalVisible$ | async)?.type === 'businessListings'" <div class="relative w-full max-w-4xl max-h-full">
class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50"
>
<div class="relative w-full max-h-full">
<div class="relative bg-white rounded-lg shadow"> <div class="relative bg-white rounded-lg shadow">
<div class="flex items-start justify-between p-4 border-b rounded-t bg-blue-600"> <div class="flex items-start justify-between p-4 border-b rounded-t">
<h3 class="text-xl font-semibold text-white p-2 rounded">Business Listing Search</h3> @if(criteria.criteriaType==='businessListings'){
<button (click)="closeAndSearch()" type="button" class="text-white bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ml-auto inline-flex justify-center items-center"> <h3 class="text-xl font-semibold text-gray-900">Business Listing Search</h3>
} @else if (criteria.criteriaType==='commercialPropertyListings'){
<h3 class="text-xl font-semibold text-gray-900">Property Listing Search</h3>
} @else {
<h3 class="text-xl font-semibold text-gray-900">Professional Listing Search</h3>
}
<button (click)="close()" type="button" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ml-auto inline-flex justify-center items-center">
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14"> <svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" /> <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
</svg> </svg>
@ -15,71 +18,59 @@
</div> </div>
<div class="p-6 space-y-6"> <div class="p-6 space-y-6">
<div class="flex space-x-4 mb-4"> <div class="flex space-x-4 mb-4">
<button class="text-blue-600 font-medium border-b-2 border-blue-600 pb-2">Filter ({{ numberOfResults$ | async }})</button> <button class="text-blue-600 font-medium border-b-2 border-blue-600 pb-2">Classic Search</button>
<!-- <button class="text-gray-500">AI Search <span class="bg-gray-200 text-xs font-semibold px-2 py-1 rounded">BETA</span></button> -->
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-blue-500" (click)="clearFilter()"></i> <i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-blue-500" (click)="clearFilter()"></i>
<div id="tooltip-light" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg shadow-sm opacity-0 tooltip"> <div id="tooltip-light" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg shadow-sm opacity-0 tooltip">
Clear all Filter Clear all Filter
<div class="tooltip-arrow" data-popper-arrow></div> <div class="tooltip-arrow" data-popper-arrow></div>
</div> </div>
</div> </div>
<!-- Display active filters as tags --> @if(criteria.criteriaType==='businessListings'){
<div class="flex flex-wrap gap-2 mb-4" *ngIf="hasActiveFilters()"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<span *ngIf="criteria.state" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.city" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minPrice || criteria.maxPrice" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minRevenue || criteria.maxRevenue" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Revenue: {{ criteria.minRevenue || 'Any' }} - {{ criteria.maxRevenue || 'Any' }} <button (click)="removeFilter('revenue')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minCashFlow || criteria.maxCashFlow" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Cashflow: {{ criteria.minCashFlow || 'Any' }} - {{ criteria.maxCashFlow || 'Any' }} <button (click)="removeFilter('cashflow')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.title" class="bg-gray-200 text-gray-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>
</span>
<span *ngIf="criteria.types.length" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="selectedPropertyType" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Property Type: {{ getSelectedPropertyTypeName() }} <button (click)="removeFilter('propertyType')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minNumberEmployees || criteria.maxNumberEmployees" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Employees: {{ criteria.minNumberEmployees || 'Any' }} - {{ criteria.maxNumberEmployees || 'Any' }} <button (click)="removeFilter('employees')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.establishedMin" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Established: {{ criteria.establishedMin || 'Any' }} <button (click)="removeFilter('established')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.brokerName" class="bg-gray-200 text-gray-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 class="grid grid-cols-1 gap-6">
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label for="state" class="block mb-2 text-sm font-medium text-gray-900">Location - State</label> <label for="state" class="block mb-2 text-sm font-medium text-gray-900">Location - State</label>
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select> <ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"> </ng-select>
</div> </div>
<div> <div>
<app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-gray-900 font-medium" [state]="criteria.state"></app-validated-city> <app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-gray-900 font-medium" [state]="criteria.state"></app-validated-city>
</div> </div>
<!-- <div>
<label for="city" class="block mb-2 text-sm font-medium text-gray-900">Location - City</label>
<ng-select
class="custom"
[multiple]="false"
[hideSelected]="true"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="cityLoading"
typeToSearchText="Please enter 2 or more characters"
[typeahead]="cityInput$"
[ngModel]="criteria.city"
(ngModelChange)="setCity($event)"
>
@for (city of cities$ | async; track city.id) {
<ng-option [value]="city">{{ city.city }} - {{ city.state }}</ng-option>
}
</ng-select>
</div> -->
<!-- New section for city search type -->
<div *ngIf="criteria.city"> <div *ngIf="criteria.city">
<label class="block mb-2 text-sm font-medium text-gray-900">Search Type</label> <label class="block mb-2 text-sm font-medium text-gray-900">Search Type</label>
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<label class="inline-flex items-center"> <label class="inline-flex items-center">
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="exact" /> <input type="radio" class="form-radio" name="searchType" [(ngModel)]="criteria.searchType" value="exact" />
<span class="ml-2">Exact City</span> <span class="ml-2">Exact City</span>
</label> </label>
<label class="inline-flex items-center"> <label class="inline-flex items-center">
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" /> <input type="radio" class="form-radio" name="searchType" [(ngModel)]="criteria.searchType" value="radius" />
<span class="ml-2">Radius Search</span> <span class="ml-2">Radius Search</span>
</label> </label>
</div> </div>
</div> </div>
<!-- New section for radius selection -->
<div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2"> <div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Select Radius (in miles)</label> <label class="block mb-2 text-sm font-medium text-gray-900">Select Radius (in miles)</label>
<div class="flex flex-wrap"> <div class="flex flex-wrap">
@ -88,7 +79,248 @@
type="button" type="button"
class="px-3 py-2 text-xs font-medium text-center border border-gray-200 hover:bg-gray-500 hover:text-white" class="px-3 py-2 text-xs font-medium text-center border border-gray-200 hover:bg-gray-500 hover:text-white"
[ngClass]="criteria.radius === radius ? 'text-white bg-gray-500' : 'text-gray-900 bg-white'" [ngClass]="criteria.radius === radius ? 'text-white bg-gray-500' : 'text-gray-900 bg-white'"
(click)="setRadius(radius)" (click)="criteria.radius = radius"
>
{{ radius }}
</button>
}
</div>
</div>
<div>
<label for="price" class="block mb-2 text-sm font-medium text-gray-900">Price</label>
<div class="flex items-center space-x-2">
<app-validated-price name="price-from" [(ngModel)]="criteria.minPrice" placeholder="From" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"></app-validated-price>
<!-- <input
type="number"
id="price-from"
[(ngModel)]="criteria.minPrice"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-1/2 p-2.5"
placeholder="From"
/> -->
<span>-</span>
<!-- <input
type="number"
id="price-to"
[(ngModel)]="criteria.maxPrice"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-1/2 p-2.5"
placeholder="To"
/> -->
<app-validated-price name="price-to" [(ngModel)]="criteria.maxPrice" placeholder="To" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"></app-validated-price>
</div>
</div>
<div>
<label for="salesRevenue" class="block mb-2 text-sm font-medium text-gray-900">Sales Revenue</label>
<div class="flex items-center space-x-2">
<!-- <input
type="number"
id="salesRevenue-from"
[(ngModel)]="criteria.minRevenue"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-1/2 p-2.5"
placeholder="From"
/> -->
<app-validated-price name="salesRevenue-from" [(ngModel)]="criteria.minRevenue" placeholder="From" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"></app-validated-price>
<span>-</span>
<!-- <input
type="number"
id="salesRevenue-to"
[(ngModel)]="criteria.maxRevenue"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-1/2 p-2.5"
placeholder="To"
/> -->
<app-validated-price name="salesRevenue-to" [(ngModel)]="criteria.maxRevenue" placeholder="To" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"></app-validated-price>
</div>
</div>
<div>
<label for="cashflow" class="block mb-2 text-sm font-medium text-gray-900">Cashflow</label>
<div class="flex items-center space-x-2">
<!-- <input
type="number"
id="cashflow-from"
[(ngModel)]="criteria.minCashFlow"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-1/2 p-2.5"
placeholder="From"
/> -->
<app-validated-price name="cashflow-from" [(ngModel)]="criteria.minCashFlow" placeholder="From" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"></app-validated-price>
<span>-</span>
<app-validated-price name="cashflow-to" [(ngModel)]="criteria.maxCashFlow" placeholder="To" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"></app-validated-price>
<!-- <input
type="number"
id="cashflow-to"
[(ngModel)]="criteria.maxCashFlow"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-1/2 p-2.5"
placeholder="To"
/> -->
</div>
</div>
<div>
<label for="title" class="block mb-2 text-sm font-medium text-gray-900">Title / Description (Free Search)</label>
<input
type="text"
id="title"
[(ngModel)]="criteria.title"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
placeholder="e.g. Restaurant"
/>
</div>
</div>
<div class="space-y-4">
<div>
<label class="block mb-2 text-sm font-medium text-gray-900">Category</label>
<div class="grid grid-cols-2 gap-2">
@for(tob of selectOptions.typesOfBusiness; track tob){
<div class="flex items-center">
<input
type="checkbox"
id="automotive"
[ngModel]="isTypeOfBusinessClicked(tob)"
(ngModelChange)="categoryClicked($event, tob.value)"
value="{{ tob.value }}"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
<label for="automotive" class="ml-2 text-sm font-medium text-gray-900">{{ tob.name }}</label>
</div>
}
</div>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-gray-900">Type of Property</label>
<div class="space-y-2">
<div class="flex items-center">
<input
[(ngModel)]="criteria.realEstateChecked"
(ngModelChange)="onCheckboxChange('realEstateChecked', $event)"
type="checkbox"
name="realEstateChecked"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
<label for="realEstateChecked" class="ml-2 text-sm font-medium text-gray-900">Real Estate</label>
</div>
<div class="flex items-center">
<input
[(ngModel)]="criteria.leasedLocation"
(ngModelChange)="onCheckboxChange('leasedLocation', $event)"
type="checkbox"
name="leasedLocation"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
<label for="leasedLocation" class="ml-2 text-sm font-medium text-gray-900">Leased Location</label>
</div>
<div class="flex items-center">
<input
[(ngModel)]="criteria.franchiseResale"
(ngModelChange)="onCheckboxChange('franchiseResale', $event)"
type="checkbox"
name="franchiseResale"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
<label for="franchiseResale" class="ml-2 text-sm font-medium text-gray-900">Franchise</label>
</div>
</div>
</div>
<div>
<label for="numberEmployees" class="block mb-2 text-sm font-medium text-gray-900">Number of Employees</label>
<div class="flex items-center space-x-2">
<input
type="number"
id="numberEmployees-from"
[(ngModel)]="criteria.minNumberEmployees"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-1/2 p-2.5"
placeholder="From"
/>
<span>-</span>
<input
type="number"
id="numberEmployees-to"
[(ngModel)]="criteria.maxNumberEmployees"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-1/2 p-2.5"
placeholder="To"
/>
</div>
</div>
<div>
<label for="establishedSince" class="block mb-2 text-sm font-medium text-gray-900">Established Since</label>
<div class="flex items-center space-x-2">
<input
type="number"
id="establishedSince-From"
[(ngModel)]="criteria.establishedSince"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-1/2 p-2.5"
placeholder="YYYY"
/>
<span>-</span>
<input
type="number"
id="establishedSince-To"
[(ngModel)]="criteria.establishedUntil"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-1/2 p-2.5"
placeholder="YYYY"
/>
</div>
</div>
<div>
<label for="brokername" class="block mb-2 text-sm font-medium text-gray-900">Broker Name / Company Name</label>
<input
type="text"
id="brokername"
[(ngModel)]="criteria.brokerName"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
placeholder="e.g. Brokers Invest"
/>
</div>
</div>
</div>
} @if(criteria.criteriaType==='commercialPropertyListings'){
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-4">
<div>
<label for="state" class="block mb-2 text-sm font-medium text-gray-900">Location - State</label>
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"> </ng-select>
</div>
<div>
<!-- <label for="city" class="block mb-2 text-sm font-medium text-gray-900">Location - City</label>
<ng-select
class="custom"
[multiple]="false"
[hideSelected]="true"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="cityLoading"
typeToSearchText="Please enter 2 or more characters"
[typeahead]="cityInput$"
[ngModel]="criteria.city"
(ngModelChange)="setCity($event)"
>
@for (city of cities$ | async; track city.id) {
<ng-option [value]="city">{{ city.city }} - {{ selectOptions.getStateInitials(city.state) }}</ng-option>
}
</ng-select> -->
<app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-gray-900 font-medium" [state]="criteria.state"></app-validated-city>
</div>
<!-- New section for city search type -->
<div *ngIf="criteria.city">
<label class="block mb-2 text-sm font-medium text-gray-900">Search Type</label>
<div class="flex items-center space-x-4">
<label class="inline-flex items-center">
<input type="radio" class="form-radio" name="searchType" [(ngModel)]="criteria.searchType" value="exact" />
<span class="ml-2">Exact City</span>
</label>
<label class="inline-flex items-center">
<input type="radio" class="form-radio" name="searchType" [(ngModel)]="criteria.searchType" value="radius" />
<span class="ml-2">Radius Search</span>
</label>
</div>
</div>
<!-- New section for radius selection -->
<div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Select Radius (in miles)</label>
<div class="flex flex-wrap">
@for (radius of [5, 20, 50, 100, 200, 300, 400, 500]; track radius) {
<button
type="button"
class="px-3 py-2 text-xs font-medium text-center border border-gray-200 hover:bg-gray-500 hover:text-white"
[ngClass]="criteria.radius === radius ? 'text-white bg-gray-500' : 'text-gray-900 bg-white'"
(click)="criteria.radius = radius"
> >
{{ radius }} {{ radius }}
</button> </button>
@ -98,31 +330,23 @@
<div> <div>
<label for="price" class="block mb-2 text-sm font-medium text-gray-900">Price</label> <label for="price" class="block mb-2 text-sm font-medium text-gray-900">Price</label>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<app-validated-price name="price-from" [ngModel]="criteria.minPrice" (ngModelChange)="updateCriteria({ minPrice: $event })" placeholder="From" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"> <!-- <input
</app-validated-price> type="number"
id="price-from"
[(ngModel)]="criteria.minPrice"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-1/2 p-2.5"
placeholder="From"
/> -->
<app-validated-price name="price-from" [(ngModel)]="criteria.minPrice" placeholder="From" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"></app-validated-price>
<span>-</span> <span>-</span>
<app-validated-price name="price-to" [ngModel]="criteria.maxPrice" (ngModelChange)="updateCriteria({ maxPrice: $event })" placeholder="To" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"> <app-validated-price name="price-to" [(ngModel)]="criteria.maxPrice" placeholder="To" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"></app-validated-price>
</app-validated-price> <!-- <input
</div> type="number"
</div> id="price-to"
<div> [(ngModel)]="criteria.maxPrice"
<label for="salesRevenue" class="block mb-2 text-sm font-medium text-gray-900">Sales Revenue</label> class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-1/2 p-2.5"
<div class="flex items-center space-x-2"> placeholder="To"
<app-validated-price name="salesRevenue-from" [ngModel]="criteria.minRevenue" (ngModelChange)="updateCriteria({ minRevenue: $event })" placeholder="From" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"> /> -->
</app-validated-price>
<span>-</span>
<app-validated-price name="salesRevenue-to" [ngModel]="criteria.maxRevenue" (ngModelChange)="updateCriteria({ maxRevenue: $event })" placeholder="To" inputClasses="bg-gray-50 text-sm !mt-0 p.2.5">
</app-validated-price>
</div>
</div>
<div>
<label for="cashflow" class="block mb-2 text-sm font-medium text-gray-900">Cashflow</label>
<div class="flex items-center space-x-2">
<app-validated-price name="cashflow-from" [ngModel]="criteria.minCashFlow" (ngModelChange)="updateCriteria({ minCashFlow: $event })" placeholder="From" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5">
</app-validated-price>
<span>-</span>
<app-validated-price name="cashflow-to" [ngModel]="criteria.maxCashFlow" (ngModelChange)="updateCriteria({ maxCashFlow: $event })" placeholder="To" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5">
</app-validated-price>
</div> </div>
</div> </div>
<div> <div>
@ -130,160 +354,108 @@
<input <input
type="text" type="text"
id="title" id="title"
[ngModel]="criteria.title" [(ngModel)]="criteria.title"
(ngModelChange)="updateCriteria({ title: $event })" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. Restaurant" placeholder="e.g. Restaurant"
/> />
</div> </div>
<div>
<label class="block mb-2 text-sm font-medium text-gray-900">Category</label>
<ng-select
class="custom"
[items]="selectOptions.typesOfBusiness"
bindLabel="name"
bindValue="value"
[ngModel]="criteria.types"
(ngModelChange)="onCategoryChange($event)"
[multiple]="true"
[closeOnSelect]="true"
placeholder="Select categories"
></ng-select>
</div> </div>
<div>
<label class="block mb-2 text-sm font-medium text-gray-900">Type of Property</label>
<ng-select
class="custom"
[items]="propertyTypeOptions"
bindLabel="name"
bindValue="value"
[ngModel]="selectedPropertyType"
(ngModelChange)="onPropertyTypeChange($event)"
placeholder="Select property type"
></ng-select>
</div>
<div>
<label for="numberEmployees" class="block mb-2 text-sm font-medium text-gray-900">Number of Employees</label>
<div class="flex items-center space-x-2">
<input
type="number"
id="numberEmployees-from"
[ngModel]="criteria.minNumberEmployees"
(ngModelChange)="updateCriteria({ minNumberEmployees: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-1/2 p-2.5"
placeholder="From"
/>
<span>-</span>
<input
type="number"
id="numberEmployees-to"
[ngModel]="criteria.maxNumberEmployees"
(ngModelChange)="updateCriteria({ maxNumberEmployees: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-1/2 p-2.5"
placeholder="To"
/>
</div>
</div>
<div>
<label for="establishedMin" class="block mb-2 text-sm font-medium text-gray-900">Minimum years established</label>
<div class="flex items-center space-x-2">
<input
type="number"
id="establishedMin"
[ngModel]="criteria.establishedMin"
(ngModelChange)="updateCriteria({ establishedMin: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-1/2 p-2.5"
placeholder="YY"
/>
</div>
</div>
<div>
<label for="brokername" class="block mb-2 text-sm font-medium text-gray-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>
</div>
</div>
<!-- ################################################################################## -->
<!-- ################################################################################## -->
<!-- ################################################################################## -->
<div *ngIf="!isModal" class="space-y-6">
<div class="flex space-x-4 mb-4">
<h3 class="text-xl font-semibold text-gray-900">Filter ({{ numberOfResults$ | async }})</h3>
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-blue-500" (click)="clearFilter()"></i>
<div id="tooltip-light" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg shadow-sm opacity-0 tooltip">
Clear all Filter
<div class="tooltip-arrow" data-popper-arrow></div>
</div>
</div>
<!-- Display active filters as tags -->
<div class="flex flex-wrap gap-2" *ngIf="hasActiveFilters()">
<span *ngIf="criteria.state" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.city" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minPrice || criteria.maxPrice" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minRevenue || criteria.maxRevenue" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Revenue: {{ criteria.minRevenue || 'Any' }} - {{ criteria.maxRevenue || 'Any' }} <button (click)="removeFilter('revenue')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minCashFlow || criteria.maxCashFlow" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Cashflow: {{ criteria.minCashFlow || 'Any' }} - {{ criteria.maxCashFlow || 'Any' }} <button (click)="removeFilter('cashflow')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.title" class="bg-gray-200 text-gray-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>
</span>
<span *ngIf="criteria.types.length" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="selectedPropertyType" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Property Type: {{ getSelectedPropertyTypeName() }} <button (click)="removeFilter('propertyType')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.minNumberEmployees || criteria.maxNumberEmployees" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Employees: {{ criteria.minNumberEmployees || 'Any' }} - {{ criteria.maxNumberEmployees || 'Any' }} <button (click)="removeFilter('employees')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.establishedMin" class="bg-gray-200 text-gray-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Years established: {{ criteria.establishedMin || 'Any' }} <button (click)="removeFilter('established')" class="ml-1 text-red-500 hover:text-red-700">×</button>
</span>
<span *ngIf="criteria.brokerName" class="bg-gray-200 text-gray-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>
@if(criteria.criteriaType==='businessListings') {
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label for="state" class="block mb-2 text-sm font-medium text-gray-900">Location - State</label> <label class="block mb-2 text-sm font-medium text-gray-900">Category</label>
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select> <div class="grid grid-cols-2 gap-2">
@for(tob of selectOptions.typesOfCommercialProperty; track tob){
<div class="flex items-center">
<input
type="checkbox"
id="automotive"
[ngModel]="isTypeOfBusinessClicked(tob)"
(ngModelChange)="categoryClicked($event, tob.value)"
value="{{ tob.value }}"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
<label for="automotive" class="ml-2 text-sm font-medium text-gray-900">{{ tob.name }}</label>
</div>
}
</div>
</div>
</div>
</div>
} @if(criteria.criteriaType==='brokerListings'){
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-4">
<div>
<label for="states" class="block mb-2 text-sm font-medium text-gray-900">Locations served - States</label>
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state" [multiple]="false"> </ng-select>
</div> </div>
<div> <div>
<app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-gray-900 font-medium" [state]="criteria.state"></app-validated-city> <label for="counties" class="block mb-2 text-sm font-medium text-gray-900">Locations served - Counties</label>
<ng-select
[items]="counties$ | async"
bindLabel="name"
class="custom"
[multiple]="false"
[hideSelected]="true"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="countyLoading"
typeToSearchText="Please enter 2 or more characters"
[typeahead]="countyInput$"
[(ngModel)]="criteria.counties"
>
</ng-select>
</div> </div>
<div>
<!-- <label for="city" class="block mb-2 text-sm font-medium text-gray-900">Location - City</label>
<ng-select
class="custom"
[multiple]="false"
[hideSelected]="true"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="cityLoading"
typeToSearchText="Please enter 2 or more characters"
[typeahead]="cityInput$"
[ngModel]="criteria.city"
(ngModelChange)="setCity($event)"
>
@for (city of cities$ | async; track city.id) {
<ng-option [value]="city">{{ city.name }} - {{ selectOptions.getStateInitials(city.state) }}</ng-option>
}
</ng-select> -->
<app-validated-city
label="Company Location - City"
name="city"
[ngModel]="criteria.city"
(ngModelChange)="setCity($event)"
labelClasses="text-gray-900 font-medium"
[state]="criteria.state"
></app-validated-city>
</div>
<!-- New section for city search type -->
<div *ngIf="criteria.city"> <div *ngIf="criteria.city">
<label class="block mb-2 text-sm font-medium text-gray-900">Search Type</label> <label class="block mb-2 text-sm font-medium text-gray-900">Search Type</label>
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<label class="inline-flex items-center"> <label class="inline-flex items-center">
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="exact" /> <input type="radio" class="form-radio" name="searchType" [(ngModel)]="criteria.searchType" value="exact" />
<span class="ml-2">Exact City</span> <span class="ml-2">Exact City</span>
</label> </label>
<label class="inline-flex items-center"> <label class="inline-flex items-center">
<input type="radio" class="form-radio" name="searchType" [ngModel]="criteria.searchType" (ngModelChange)="updateCriteria({ searchType: $event })" value="radius" /> <input type="radio" class="form-radio" name="searchType" [(ngModel)]="criteria.searchType" value="radius" />
<span class="ml-2">Radius Search</span> <span class="ml-2">Radius Search</span>
</label> </label>
</div> </div>
</div> </div>
<div>
<label for="brokername" class="block mb-2 text-sm font-medium text-gray-900">Name of Professional</label>
<input
type="text"
id="brokername"
[(ngModel)]="criteria.brokerName"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
/>
</div>
<!-- New section for radius selection -->
<div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2"> <div *ngIf="criteria.city && criteria.searchType === 'radius'" class="space-y-2">
<label class="block mb-2 text-sm font-medium text-gray-900">Select Radius (in miles)</label> <label class="block mb-2 text-sm font-medium text-gray-900">Select Radius (in miles)</label>
<div class="flex flex-wrap"> <div class="flex flex-wrap">
@ -292,124 +464,49 @@
type="button" type="button"
class="px-3 py-2 text-xs font-medium text-center border border-gray-200 hover:bg-gray-500 hover:text-white" class="px-3 py-2 text-xs font-medium text-center border border-gray-200 hover:bg-gray-500 hover:text-white"
[ngClass]="criteria.radius === radius ? 'text-white bg-gray-500' : 'text-gray-900 bg-white'" [ngClass]="criteria.radius === radius ? 'text-white bg-gray-500' : 'text-gray-900 bg-white'"
(click)="setRadius(radius)" (click)="criteria.radius = radius"
> >
{{ radius }} {{ radius }}
</button> </button>
} }
</div> </div>
</div> </div>
<div>
<label for="price" class="block mb-2 text-sm font-medium text-gray-900">Price</label>
<div class="flex items-center space-x-2">
<app-validated-price name="price-from" [ngModel]="criteria.minPrice" (ngModelChange)="updateCriteria({ minPrice: $event })" placeholder="From" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"> </app-validated-price>
<span>-</span>
<app-validated-price name="price-to" [ngModel]="criteria.maxPrice" (ngModelChange)="updateCriteria({ maxPrice: $event })" placeholder="To" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"> </app-validated-price>
</div>
</div>
<div>
<label for="salesRevenue" class="block mb-2 text-sm font-medium text-gray-900">Sales Revenue</label>
<div class="flex items-center space-x-2">
<app-validated-price name="salesRevenue-from" [ngModel]="criteria.minRevenue" (ngModelChange)="updateCriteria({ minRevenue: $event })" placeholder="From" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5">
</app-validated-price>
<span>-</span>
<app-validated-price name="salesRevenue-to" [ngModel]="criteria.maxRevenue" (ngModelChange)="updateCriteria({ maxRevenue: $event })" placeholder="To" inputClasses="bg-gray-50 text-sm !mt-0 p.2.5">
</app-validated-price>
</div>
</div>
<div>
<label for="cashflow" class="block mb-2 text-sm font-medium text-gray-900">Cashflow</label>
<div class="flex items-center space-x-2">
<app-validated-price name="cashflow-from" [ngModel]="criteria.minCashFlow" (ngModelChange)="updateCriteria({ minCashFlow: $event })" placeholder="From" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5">
</app-validated-price>
<span>-</span>
<app-validated-price name="cashflow-to" [ngModel]="criteria.maxCashFlow" (ngModelChange)="updateCriteria({ maxCashFlow: $event })" placeholder="To" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5">
</app-validated-price>
</div>
</div>
<div>
<label for="title" class="block mb-2 text-sm font-medium text-gray-900">Title / Description (Free Search)</label>
<input
type="text"
id="title"
[ngModel]="criteria.title"
(ngModelChange)="updateCriteria({ title: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-full p-2.5"
placeholder="e.g. Restaurant"
/>
</div> </div>
<div class="space-y-4">
<div> <div>
<label class="block mb-2 text-sm font-medium text-gray-900">Category</label> <label class="block mb-2 text-sm font-medium text-gray-900">Category</label>
<ng-select <div class="grid grid-cols-2 gap-2">
class="custom" @for(tob of selectOptions.customerSubTypes; track tob){
[items]="selectOptions.typesOfBusiness" <div class="flex items-center">
bindLabel="name"
bindValue="value"
[ngModel]="criteria.types"
(ngModelChange)="onCategoryChange($event)"
[multiple]="true"
[closeOnSelect]="true"
placeholder="Select categories"
></ng-select>
</div>
<div>
<label class="block mb-2 text-sm font-medium text-gray-900">Type of Property</label>
<ng-select
class="custom"
[items]="propertyTypeOptions"
bindLabel="name"
bindValue="value"
[ngModel]="selectedPropertyType"
(ngModelChange)="onPropertyTypeChange($event)"
placeholder="Select property type"
></ng-select>
</div>
<div>
<label for="numberEmployees" class="block mb-2 text-sm font-medium text-gray-900">Number of Employees</label>
<div class="flex items-center space-x-2">
<input <input
type="number" type="checkbox"
id="numberEmployees-from" id="automotive"
[ngModel]="criteria.minNumberEmployees" [ngModel]="isTypeOfProfessionalClicked(tob)"
(ngModelChange)="updateCriteria({ minNumberEmployees: $event })" (ngModelChange)="categoryClicked($event, tob.value)"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-1/2 p-2.5" value="{{ tob.value }}"
placeholder="From" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
<span>-</span>
<input
type="number"
id="numberEmployees-to"
[ngModel]="criteria.maxNumberEmployees"
(ngModelChange)="updateCriteria({ maxNumberEmployees: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-1/2 p-2.5"
placeholder="To"
/> />
<label for="automotive" class="ml-2 text-sm font-medium text-gray-900">{{ tob.name }}</label>
</div>
}
</div> </div>
</div> </div>
<div>
<label for="establishedMin" class="block mb-2 text-sm font-medium text-gray-900">Minimum years established</label>
<div class="flex items-center space-x-2">
<input
type="number"
id="establishedMin"
[ngModel]="criteria.establishedMin"
(ngModelChange)="updateCriteria({ establishedMin: $event })"
class="bg-gray-50 border border-gray-300 text-sm rounded-lg block w-1/2 p-2.5"
placeholder="YY"
/>
</div>
</div>
<div>
<label for="brokername" class="block mb-2 text-sm font-medium text-gray-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>
<div class="flex items-center p-6 space-x-2 border-t border-gray-200 rounded-b">
<button type="button" (click)="modalService.accept()" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center">
Search ({{ numberOfResults$ | async }})
</button>
<button
type="button"
(click)="close()"
class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10"
>
Cancel
</button>
</div>
</div>
</div>
</div> </div>

View File

@ -1,12 +1,9 @@
:host ::ng-deep .ng-select.custom .ng-select-container { :host ::ng-deep .ng-select.custom .ng-select-container {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(249 250 251 / var(--tw-bg-opacity)); background-color: rgb(249 250 251 / var(--tw-bg-opacity));
min-height: 46px; height: 46px;
border-radius: 0.5rem; border-radius: 0.5rem;
.ng-value-container .ng-input { .ng-value-container .ng-input {
top: 10px; top: 10px;
} }
} }
:host ::ng-deep .ng-select.ng-select-multiple .ng-select-container .ng-value-container .ng-placeholder {
position: unset;
}

View File

@ -1,20 +1,19 @@
import { AsyncPipe, NgIf } from '@angular/common'; import { AsyncPipe, NgIf } from '@angular/common';
import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import { Component } from '@angular/core';
import { NgSelectModule } from '@ng-select/ng-select'; import { NgSelectModule } from '@ng-select/ng-select';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { catchError, concat, debounceTime, distinctUntilChanged, map, Observable, of, Subject, switchMap, takeUntil, tap } from 'rxjs'; import { catchError, concat, debounceTime, distinctUntilChanged, map, Observable, of, Subject, Subscription, switchMap, tap } from 'rxjs';
import { BusinessListingCriteria, CountyResult, GeoResult, KeyValue, KeyValueStyle } from '../../../../../bizmatch-server/src/models/main.model'; import { BusinessListingCriteria, CommercialPropertyListingCriteria, CountyResult, GeoResult, KeyValue, KeyValueStyle, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
import { FilterStateService } from '../../services/filter-state.service'; import { CriteriaChangeService } from '../../services/criteria-change.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 { 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 { SharedModule } from '../../shared/shared/shared.module'; import { SharedModule } from '../../shared/shared/shared.module';
import { resetBusinessListingCriteria, resetCommercialPropertyListingCriteria, resetUserListingCriteria } from '../../utils/utils';
import { ValidatedCityComponent } from '../validated-city/validated-city.component'; import { ValidatedCityComponent } from '../validated-city/validated-city.component';
import { ValidatedPriceComponent } from '../validated-price/validated-price.component'; import { ValidatedPriceComponent } from '../validated-price/validated-price.component';
import { ModalService } from './modal.service'; import { ModalService } from './modal.service';
@UntilDestroy() @UntilDestroy()
@Component({ @Component({
selector: 'app-search-modal', selector: 'app-search-modal',
@ -23,105 +22,55 @@ import { ModalService } from './modal.service';
templateUrl: './search-modal.component.html', templateUrl: './search-modal.component.html',
styleUrl: './search-modal.component.scss', styleUrl: './search-modal.component.scss',
}) })
export class SearchModalComponent implements OnInit, OnDestroy { export class SearchModalComponent {
@Input() isModal: boolean = true; // cities$: Observable<GeoResult[]>;
private destroy$ = new Subject<void>();
private searchDebounce$ = new Subject<void>();
// State
criteria: BusinessListingCriteria;
backupCriteria: any;
currentListingType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
// Geo search
counties$: Observable<CountyResult[]>; counties$: Observable<CountyResult[]>;
// cityLoading = false;
countyLoading = false; countyLoading = false;
// cityInput$ = new Subject<string>();
countyInput$ = new Subject<string>(); countyInput$ = new Subject<string>();
private criteriaChangeSubscription: Subscription;
// Property type for business listings public criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
selectedPropertyType: string | null = null; backupCriteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
propertyTypeOptions = [
{ name: 'Real Estate', value: 'realEstateChecked' },
{ name: 'Leased Location', value: 'leasedLocation' },
{ name: 'Franchise', value: 'franchiseResale' },
];
// Results count
numberOfResults$: Observable<number>; numberOfResults$: Observable<number>;
cancelDisable = false;
constructor( constructor(
public selectOptions: SelectOptionsService, public selectOptions: SelectOptionsService,
public modalService: ModalService, public modalService: ModalService,
private geoService: GeoService, private geoService: GeoService,
private filterStateService: FilterStateService, private criteriaChangeService: CriteriaChangeService,
private listingService: ListingsService, private listingService: ListingsService,
private userService: UserService, private userService: UserService,
private searchService: SearchService,
) {} ) {}
ngOnInit() {
ngOnInit(): void { this.setupCriteriaChangeListener();
// Load counties this.modalService.message$.pipe(untilDestroyed(this)).subscribe(msg => {
this.loadCounties(); this.criteria = msg;
this.backupCriteria = JSON.parse(JSON.stringify(msg));
if (this.isModal) { this.setTotalNumberOfResults();
// Modal mode: Wait for messages from ModalService
this.modalService.message$.pipe(untilDestroyed(this)).subscribe(criteria => {
this.initializeWithCriteria(criteria);
}); });
this.modalService.modalVisible$.pipe(untilDestroyed(this)).subscribe(val => { this.modalService.modalVisible$.pipe(untilDestroyed(this)).subscribe(val => {
if (val.visible) { if (val) {
// Reset pagination when modal opens
if (this.criteria) {
this.criteria.page = 1; this.criteria.page = 1;
this.criteria.start = 0; this.criteria.start = 0;
} }
}
}); });
// this.loadCities();
this.loadCounties();
}
ngOnChanges() {}
categoryClicked(checked: boolean, value: string) {
if (checked) {
this.criteria.types.push(value);
} else { } else {
// Embedded mode: Determine type from route and subscribe to state const index = this.criteria.types.findIndex(t => t === value);
this.determineListingType(); if (index > -1) {
this.subscribeToStateChanges(); this.criteria.types.splice(index, 1);
}
// Setup debounced search
this.searchDebounce$.pipe(debounceTime(400), takeUntil(this.destroy$)).subscribe(() => this.triggerSearch());
}
private initializeWithCriteria(criteria: any): void {
this.criteria = criteria;
this.currentListingType = criteria?.criteriaType;
this.backupCriteria = JSON.parse(JSON.stringify(criteria));
this.updateSelectedPropertyType();
this.setTotalNumberOfResults();
}
private determineListingType(): void {
const url = window.location.pathname;
if (url.includes('businessListings')) {
this.currentListingType = 'businessListings';
} else if (url.includes('commercialPropertyListings')) {
this.currentListingType = 'commercialPropertyListings';
} else if (url.includes('brokerListings')) {
this.currentListingType = 'brokerListings';
} }
} }
private subscribeToStateChanges(): void {
if (!this.isModal && this.currentListingType) {
this.filterStateService
.getState$(this.currentListingType)
.pipe(takeUntil(this.destroy$))
.subscribe(state => {
this.criteria = { ...state.criteria };
this.updateSelectedPropertyType();
this.setTotalNumberOfResults();
});
} }
} private loadCounties() {
private loadCounties(): void {
this.counties$ = concat( this.counties$ = concat(
of([]), // default items of([]), // default items
this.countyInput$.pipe( this.countyInput$.pipe(
@ -129,314 +78,87 @@ export class SearchModalComponent implements OnInit, OnDestroy {
tap(() => (this.countyLoading = true)), tap(() => (this.countyLoading = true)),
switchMap(term => switchMap(term =>
this.geoService.findCountiesStartingWith(term).pipe( this.geoService.findCountiesStartingWith(term).pipe(
catchError(() => of([])), catchError(() => of([])), // empty list on error
map(counties => counties.map(county => county.name)), map(counties => counties.map(county => county.name)), // transform the list of objects to a list of city names
tap(() => (this.countyLoading = false)), tap(() => (this.countyLoading = false)),
), ),
), ),
), ),
); );
} }
setCity(city) {
// Filter removal methods
removeFilter(filterType: string): void {
const updates: any = {};
switch (filterType) {
case 'state':
updates.state = null;
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
break;
case 'city':
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
break;
case 'price':
updates.minPrice = null;
updates.maxPrice = null;
break;
case 'revenue':
updates.minRevenue = null;
updates.maxRevenue = null;
break;
case 'cashflow':
updates.minCashFlow = null;
updates.maxCashFlow = null;
break;
case 'types':
updates.types = [];
break;
case 'propertyType':
updates.realEstateChecked = false;
updates.leasedLocation = false;
updates.franchiseResale = false;
this.selectedPropertyType = null;
break;
case 'employees':
updates.minNumberEmployees = null;
updates.maxNumberEmployees = null;
break;
case 'established':
updates.establishedMin = null;
break;
case 'brokerName':
updates.brokerName = null;
break;
case 'title':
updates.title = null;
break;
}
this.updateCriteria(updates);
}
// Category handling
onCategoryChange(selectedCategories: string[]): void {
this.updateCriteria({ types: selectedCategories });
}
categoryClicked(checked: boolean, value: string): void {
const types = [...(this.criteria.types || [])];
if (checked) {
if (!types.includes(value)) {
types.push(value);
}
} else {
const index = types.indexOf(value);
if (index > -1) {
types.splice(index, 1);
}
}
this.updateCriteria({ types });
}
// Property type handling (Business listings only)
onPropertyTypeChange(value: string): void {
const updates: any = {
realEstateChecked: false,
leasedLocation: false,
franchiseResale: false,
};
if (value) {
updates[value] = true;
}
this.selectedPropertyType = value;
this.updateCriteria(updates);
}
onCheckboxChange(checkbox: string, value: boolean): void {
const updates: any = {
realEstateChecked: false,
leasedLocation: false,
franchiseResale: false,
};
updates[checkbox] = value;
this.selectedPropertyType = value ? checkbox : null;
this.updateCriteria(updates);
}
// Location handling
setState(state: string): void {
const updates: any = { state };
if (!state) {
updates.city = null;
updates.radius = null;
updates.searchType = 'exact';
}
this.updateCriteria(updates);
}
setCity(city: any): void {
const updates: any = {};
if (city) { if (city) {
updates.city = city; this.criteria.city = city;
updates.state = city.state; this.criteria.state = city.state;
} else { } else {
updates.city = null; this.criteria.city = null;
updates.radius = null; this.criteria.radius = null;
updates.searchType = 'exact'; this.criteria.searchType = 'exact';
} }
this.updateCriteria(updates);
} }
setState(state: string) {
setRadius(radius: number): void { if (state) {
this.updateCriteria({ radius }); this.criteria.state = state;
} else {
this.criteria.state = null;
this.setCity(null);
} }
onCriteriaChange(): void {
this.triggerSearch();
} }
private setupCriteriaChangeListener() {
// Debounced search for text inputs this.criteriaChangeSubscription = this.criteriaChangeService.criteriaChange$.pipe(debounceTime(400)).subscribe(() => {
debouncedSearch(): void {
this.searchDebounce$.next();
}
// Clear all filters
clearFilter(): void {
if (this.isModal) {
// In modal: Reset locally
const defaultCriteria = this.getDefaultCriteria();
this.criteria = defaultCriteria;
this.updateSelectedPropertyType();
this.setTotalNumberOfResults(); this.setTotalNumberOfResults();
} else { this.cancelDisable = true;
// Embedded: Use state service });
this.filterStateService.clearFilters(this.currentListingType);
} }
} trackByFn(item: GeoResult) {
// Modal-specific methods
closeAndSearch(): void {
if (this.isModal) {
// Save changes to state
this.filterStateService.setCriteria(this.currentListingType, this.criteria);
this.modalService.accept();
this.searchService.search(this.currentListingType);
}
}
close(): void {
if (this.isModal) {
// Discard changes
this.modalService.reject(this.backupCriteria);
}
}
// Helper methods
public updateCriteria(updates: any): void {
if (this.isModal) {
// In modal: Update locally only
this.criteria = { ...this.criteria, ...updates };
this.setTotalNumberOfResults();
} else {
// Embedded: Update through state service
this.filterStateService.updateCriteria(this.currentListingType, updates);
}
// Trigger search after update
this.debouncedSearch();
}
private triggerSearch(): void {
if (this.isModal) {
// In modal: Only update count
this.setTotalNumberOfResults();
} else {
// Embedded: Full search
this.searchService.search(this.currentListingType);
}
}
private updateSelectedPropertyType(): void {
if (this.currentListingType === 'businessListings') {
const businessCriteria = this.criteria as BusinessListingCriteria;
if (businessCriteria.realEstateChecked) {
this.selectedPropertyType = 'realEstateChecked';
} else if (businessCriteria.leasedLocation) {
this.selectedPropertyType = 'leasedLocation';
} else if (businessCriteria.franchiseResale) {
this.selectedPropertyType = 'franchiseResale';
} else {
this.selectedPropertyType = null;
}
}
}
private setTotalNumberOfResults(): void {
if (!this.criteria) return;
switch (this.currentListingType) {
case 'businessListings':
this.numberOfResults$ = this.listingService.getNumberOfListings('business', this.criteria);
break;
case 'commercialPropertyListings':
this.numberOfResults$ = this.listingService.getNumberOfListings('commercialProperty', this.criteria);
break;
case 'brokerListings':
this.numberOfResults$ = this.userService.getNumberOfBroker();
break;
}
}
private getDefaultCriteria(): any {
switch (this.currentListingType) {
case 'businessListings':
return this.filterStateService['createEmptyBusinessListingCriteria']();
case 'commercialPropertyListings':
return this.filterStateService['createEmptyCommercialPropertyListingCriteria']();
case 'brokerListings':
return this.filterStateService['createEmptyUserListingCriteria']();
}
}
hasActiveFilters(): boolean {
if (!this.criteria) return false;
// Check all possible filter properties
const hasBasicFilters = !!(this.criteria.state || this.criteria.city || this.criteria.types?.length);
// Check business-specific filters
if (this.currentListingType === 'businessListings') {
const bc = this.criteria as BusinessListingCriteria;
return (
hasBasicFilters ||
!!(
bc.minPrice ||
bc.maxPrice ||
bc.minRevenue ||
bc.maxRevenue ||
bc.minCashFlow ||
bc.maxCashFlow ||
bc.minNumberEmployees ||
bc.maxNumberEmployees ||
bc.establishedMin ||
bc.brokerName ||
bc.title ||
this.selectedPropertyType
)
);
}
// Check commercial property filters
// if (this.currentListingType === 'commercialPropertyListings') {
// const cc = this.criteria as CommercialPropertyListingCriteria;
// return hasBasicFilters || !!(cc.minPrice || cc.maxPrice || cc.title);
// }
// Check user/broker filters
// if (this.currentListingType === 'brokerListings') {
// const uc = this.criteria as UserListingCriteria;
// return hasBasicFilters || !!(uc.brokerName || uc.companyName || uc.counties?.length);
// }
return hasBasicFilters;
}
getSelectedPropertyTypeName(): string | null {
return this.selectedPropertyType ? this.propertyTypeOptions.find(opt => opt.value === this.selectedPropertyType)?.name || null : null;
}
isTypeOfBusinessClicked(v: KeyValueStyle): boolean {
return !!this.criteria.types?.find(t => t === v.value);
}
isTypeOfProfessionalClicked(v: KeyValue): boolean {
return !!this.criteria.types?.find(t => t === v.value);
}
trackByFn(item: GeoResult): any {
return item.id; return item.id;
} }
search() {
console.log('Search criteria:', this.criteria);
}
getCounties() {
this.geoService.findCountiesStartingWith('');
}
closeModal() {
console.log('Closing modal');
}
isTypeOfBusinessClicked(v: KeyValueStyle) {
return this.criteria.types.find(t => t === v.value);
}
isTypeOfProfessionalClicked(v: KeyValue) {
return this.criteria.types.find(t => t === v.value);
}
setTotalNumberOfResults() {
if (this.criteria) {
console.log(`Getting total number of results for ${this.criteria.criteriaType}`);
if (this.criteria.criteriaType === 'businessListings' || this.criteria.criteriaType === 'commercialPropertyListings') {
this.numberOfResults$ = this.listingService.getNumberOfListings(this.criteria, this.criteria.criteriaType === 'businessListings' ? 'business' : 'commercialProperty');
} else if (this.criteria.criteriaType === 'brokerListings') {
this.numberOfResults$ = this.userService.getNumberOfBroker(this.criteria);
} else {
this.numberOfResults$ = of();
}
}
}
clearFilter() {
if (this.criteria.criteriaType === 'businessListings') {
resetBusinessListingCriteria(this.criteria);
} else if (this.criteria.criteriaType === 'commercialPropertyListings') {
resetCommercialPropertyListingCriteria(this.criteria);
} else {
resetUserListingCriteria(this.criteria);
}
}
close() {
this.modalService.reject(this.backupCriteria);
}
onCheckboxChange(checkbox: string, value: boolean) {
// Deaktivieren Sie alle Checkboxes
(<BusinessListingCriteria>this.criteria).realEstateChecked = false;
(<BusinessListingCriteria>this.criteria).leasedLocation = false;
(<BusinessListingCriteria>this.criteria).franchiseResale = false;
ngOnDestroy(): void { // Aktivieren Sie nur die aktuell ausgewählte Checkbox
this.destroy$.next(); this.criteria[checkbox] = value;
this.destroy$.complete();
} }
} }

View File

@ -32,7 +32,7 @@
</div> </div>
<!-- Benutzertabelle --> <!-- Benutzertabelle -->
<div class="overflow-x-auto drop-shadow-custom-bg rounded-lg bg-white"> <div class="overflow-x-auto shadow-md rounded-lg bg-white">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
@ -107,7 +107,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg> </svg>
</button> </button>
<div *ngIf="dropdown.classList.contains('active')" class="origin-top-right absolute right-0 mt-2 w-48 rounded-md drop-shadow-custom-bg bg-white ring-1 ring-black ring-opacity-5 z-10"> <div *ngIf="dropdown.classList.contains('active')" class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-10">
<div class="py-1" role="menu" aria-orientation="vertical"> <div class="py-1" role="menu" aria-orientation="vertical">
<a (click)="changeUserRole(user, 'admin'); dropdown.classList.remove('active')" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer">Admin</a> <a (click)="changeUserRole(user, 'admin'); dropdown.classList.remove('active')" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer">Admin</a>
<a (click)="changeUserRole(user, 'pro'); dropdown.classList.remove('active')" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer">Pro</a> <a (click)="changeUserRole(user, 'pro'); dropdown.classList.remove('active')" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer">Pro</a>

View File

@ -1,5 +1,5 @@
<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 overflow-hidden relative"> <div class="bg-white rounded-lg shadow-lg overflow-hidden relative">
<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 print:hidden" class="absolute top-4 right-4 bg-red-500 text-white rounded-full w-8 h-8 flex items-center justify-center hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 print:hidden"
@ -14,17 +14,13 @@
<p class="mb-4" [innerHTML]="description"></p> <p class="mb-4" [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" [ngClass]="{ 'bg-gray-100': i % 2 === 0 }"> <div class="flex flex-col sm:flex-row" [ngClass]="{ 'bg-gray-100': i % 2 === 0 }" *ngFor="let item of listingDetails; let i = index">
<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">{{ item.label }}</div>
@if(item.label==='Category'){
<div class="w-full sm:w-2/3 p-2" *ngIf="!detail.isHtml && !detail.isListingBy">{{ detail.value }}</div> <span class="bg-blue-100 text-blue-800 font-medium me-2 px-2.5 py-0.5 rounded-full dark:bg-blue-900 dark:text-blue-300 my-1">{{ item.value }}</span>
} @else {
<div class="w-full sm:w-2/3 p-2 flex space-x-2" [innerHTML]="detail.value" *ngIf="detail.isHtml && !detail.isListingBy"></div> <div class="w-full sm:w-2/3 p-2">{{ item.value }}</div>
}
<div class="w-full sm:w-2/3 p-2 flex space-x-2" *ngIf="detail.isListingBy">
<a routerLink="/details-user/{{ listingUser.id }}" class="text-blue-600 dark:text-blue-500 hover:underline">{{ listingUser.firstname }} {{ listingUser.lastname }}</a>
<img src="{{ env.imageBaseUrl }}/pictures/logo/{{ listing.imageName }}.avif?_ts={{ ts }}" class="mr-5 lg:mb-0" style="max-height: 30px; max-width: 100px" />
</div>
</div> </div>
</div> </div>
<div class="py-4 print:hidden"> <div class="py-4 print:hidden">
@ -88,6 +84,17 @@
<div> <div>
<app-validated-textarea label="Questions/Comments" name="comments" [(ngModel)]="mailinfo.sender.comments"></app-validated-textarea> <app-validated-textarea label="Questions/Comments" name="comments" [(ngModel)]="mailinfo.sender.comments"></app-validated-textarea>
</div> </div>
@if(listingUser){
<div class="flex items-center space-x-2">
<p>Listing by</p>
<!-- <p class="text-sm font-semibold">Noah Nguyen</p> -->
<a routerLink="/details-user/{{ listingUser.id }}" class="text-blue-600 dark:text-blue-500 hover:underline">{{ listingUser.firstname }} {{ listingUser.lastname }}</a>
<!-- <img src="https://placehold.co/20x20" alt="Broker logo" class="w-5 h-5" /> -->
@if(listingUser.hasCompanyLogo){
<img src="{{ env.imageBaseUrl }}/pictures/logo/{{ listing.imageName }}.avif?_ts={{ ts }}" class="mr-5 lg:mb-0" style="max-height: 30px; max-width: 100px" />
}
</div>
}
<button (click)="mail()" class="w-full sm:w-auto px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50">Submit</button> <button (click)="mail()" class="w-full sm:w-auto px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50">Submit</button>
</form> </form>
</div> </div>

View File

@ -1,4 +1,4 @@
import { ChangeDetectorRef, Component } from '@angular/core'; import { Component } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { LeafletModule } from '@bluehalo/ngx-leaflet'; import { LeafletModule } from '@bluehalo/ngx-leaflet';
@ -24,7 +24,6 @@ import { SharedModule } from '../../../shared/shared/shared.module';
import { createMailInfo, map2User } from '../../../utils/utils'; import { createMailInfo, map2User } from '../../../utils/utils';
// Import für Leaflet // Import für Leaflet
// Benannte Importe für Leaflet // Benannte Importe für Leaflet
import dayjs from 'dayjs';
import { AuthService } from '../../../services/auth.service'; import { AuthService } from '../../../services/auth.service';
import { BaseDetailsComponent } from '../base-details.component'; import { BaseDetailsComponent } from '../base-details.component';
@Component({ @Component({
@ -81,7 +80,6 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent {
public emailService: EMailService, public emailService: EMailService,
private geoService: GeoService, private geoService: GeoService,
public authService: AuthService, public authService: AuthService,
private cdref: ChangeDetectorRef,
) { ) {
super(); super();
this.router.events.subscribe(event => { this.router.events.subscribe(event => {
@ -123,10 +121,8 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent {
this.mailinfo.email = this.listingUser.email; this.mailinfo.email = this.listingUser.email;
this.mailinfo.listing = this.listing; this.mailinfo.listing = this.listing;
await this.mailService.mail(this.mailinfo); await this.mailService.mail(this.mailinfo);
this.validationMessagesService.clearMessages();
this.auditService.createEvent(this.listing.id, 'contact', this.user?.email, this.mailinfo.sender); this.auditService.createEvent(this.listing.id, 'contact', this.user?.email, this.mailinfo.sender);
this.messageService.addMessage({ severity: 'success', text: 'Your message has been sent to the creator of the listing', duration: 3000 }); this.messageService.addMessage({ severity: 'success', text: 'Your message has been sent to the creator of the listing', duration: 3000 });
this.mailinfo = createMailInfo(this.user);
} catch (error) { } catch (error) {
this.messageService.addMessage({ this.messageService.addMessage({
severity: 'danger', severity: 'danger',
@ -137,6 +133,9 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent {
this.validationMessagesService.updateMessages(error.error.message); this.validationMessagesService.updateMessages(error.error.message);
} }
} }
if (this.user) {
this.mailinfo = createMailInfo(this.user);
}
} }
get listingDetails() { get listingDetails() {
let typeOfRealEstate = ''; let typeOfRealEstate = '';
@ -149,48 +148,16 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent {
} }
const result = [ const result = [
{ label: 'Category', value: this.selectOptions.getBusiness(this.listing.type) }, { label: 'Category', value: this.selectOptions.getBusiness(this.listing.type) },
{ { label: 'Located in', value: `${this.listing.location.name ? this.listing.location.name : this.listing.location.county}, ${this.selectOptions.getState(this.listing.location.state)}` },
label: 'Located in', { label: 'Asking Price', value: `$${this.listing.price?.toLocaleString()}` },
value: `${this.listing.location.name ? this.listing.location.name : this.listing.location.county ? this.listing.location.county : ''} ${ { label: 'Sales revenue', value: `$${this.listing.salesRevenue?.toLocaleString()}` },
this.listing.location.name || this.listing.location.county ? ', ' : '' { label: 'Cash flow', value: `$${this.listing.cashFlow?.toLocaleString()}` },
}${this.selectOptions.getState(this.listing.location.state)}`,
},
{ label: 'Asking Price', value: `${this.listing.price ? `$${this.listing.price.toLocaleString()}` : 'undisclosed '}` },
{ label: 'Sales revenue', value: `${this.listing.salesRevenue ? `$${this.listing.salesRevenue.toLocaleString()}` : 'undisclosed '}` },
{ label: 'Cash flow', value: `${this.listing.cashFlow ? `$${this.listing.cashFlow.toLocaleString()}` : 'undisclosed '}` },
...(this.listing.ffe
? [
{
label: 'Furniture, Fixtures / Equipment Value (FFE)',
value: `$${this.listing.ffe.toLocaleString()}`,
},
]
: []),
...(this.listing.inventory
? [
{
label: 'Inventory at Cost Value',
value: `$${this.listing.inventory.toLocaleString()}`,
},
]
: []),
{ label: 'Type of Real Estate', value: typeOfRealEstate }, { label: 'Type of Real Estate', value: typeOfRealEstate },
{ label: 'Employees', value: this.listing.employees }, { label: 'Employees', value: this.listing.employees },
{ label: 'Years established', value: this.listing.established }, { label: 'Established since', value: this.listing.established },
{ label: 'Support & Training', value: this.listing.supportAndTraining }, { label: 'Support & Training', value: this.listing.supportAndTraining },
{ label: 'Reason for Sale', value: this.listing.reasonForSale }, { label: 'Reason for Sale', value: this.listing.reasonForSale },
{ label: 'Broker licensing', value: this.listing.brokerLicencing }, { label: 'Broker licensing', value: this.listing.brokerLicencing },
{ label: 'Listed since', value: `${this.dateInserted()} - ${this.getDaysListed()} days` },
{
label: 'Listing by',
value: null, // Wird nicht verwendet
isHtml: true,
isListingBy: true, // Flag für den speziellen Fall
user: this.listingUser, // Übergebe das User-Objekt
imagePath: this.listing.imageName,
imageBaseUrl: this.env.imageBaseUrl,
ts: this.ts,
},
]; ];
if (this.listing.draft) { if (this.listing.draft) {
result.push({ label: 'Draft', value: this.listing.draft ? 'Yes' : 'No' }); result.push({ label: 'Draft', value: this.listing.draft ? 'Yes' : 'No' });
@ -227,10 +194,4 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent {
createEvent(eventType: EventTypeEnum) { createEvent(eventType: EventTypeEnum) {
this.auditService.createEvent(this.listing.id, eventType, this.user?.email); this.auditService.createEvent(this.listing.id, eventType, this.user?.email);
} }
getDaysListed() {
return dayjs().diff(this.listing.created, 'day');
}
dateInserted() {
return dayjs(this.listing.created).format('DD/MM/YYYY');
}
} }

View File

@ -1,5 +1,5 @@
<div class="container mx-auto p-4"> <div class="container mx-auto p-4">
<div class="bg-white drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg rounded-lg overflow-hidden"> <div class="bg-white shadow-md 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">{{ listing?.title }}</h1>
@ -16,18 +16,7 @@
<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" [ngClass]="{ 'bg-gray-100': i % 2 === 0 }"> <div *ngFor="let detail of propertyDetails; let i = index" class="flex flex-col sm:flex-row" [ngClass]="{ 'bg-gray-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">{{ detail.value }}</div>
<!-- Standard Text -->
<div class="w-full sm:w-2/3 p-2" *ngIf="!detail.isHtml && !detail.isListingBy">{{ detail.value }}</div>
<!-- HTML Content (nicht für RouterLink) -->
<div class="w-full sm:w-2/3 p-2 flex space-x-2" [innerHTML]="detail.value" *ngIf="detail.isHtml && !detail.isListingBy"></div>
<!-- Speziell für Listing By mit RouterLink -->
<div class="w-full sm:w-2/3 p-2 flex space-x-2" *ngIf="detail.isListingBy">
<a [routerLink]="['/details-user', detail.user.id]" class="text-blue-600 dark:text-blue-500 hover:underline"> {{ detail.user.firstname }} {{ detail.user.lastname }} </a>
<img *ngIf="detail.user.hasCompanyLogo" [src]="detail.imageBaseUrl + '/pictures/logo/' + detail.imagePath + '.avif?_ts=' + detail.ts" class="mr-5 lg:mb-0" style="max-height: 30px; max-width: 100px" />
</div>
</div> </div>
</div> </div>
<div class="py-4 print:hidden"> <div class="py-4 print:hidden">
@ -81,7 +70,7 @@
@if(this.images.length>0){ @if(this.images.length>0){
<h2 class="text-xl font-semibold">Contact the Author of this Listing</h2> <h2 class="text-xl font-semibold">Contact the Author of this Listing</h2>
}@else { }@else {
<div class="text-2xl font-bold mb-4">Contact the Author of this Listing</div> <div class="md:mt-[-60px] text-2xl font-bold mb-4">Contact the Author of this Listing</div>
} }
<p class="text-sm text-gray-600 mb-4">Please include your contact info below</p> <p class="text-sm text-gray-600 mb-4">Please include your contact info below</p>
<form class="space-y-4"> <form class="space-y-4">
@ -99,6 +88,15 @@
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@if(listingUser){
<div class="flex items-center space-x-2">
<p>Listing by</p>
<a routerLink="/details-user/{{ listingUser.id }}" class="text-blue-600 dark:text-blue-500 hover:underline">{{ listingUser.firstname }} {{ listingUser.lastname }}</a>
@if(listingUser.hasCompanyLogo){
<img src="{{ env.imageBaseUrl }}/pictures/logo/{{ listing.imagePath }}.avif?_ts={{ ts }}" class="mr-5 lg:mb-0" style="max-height: 30px; max-width: 100px" />
}
</div>
}
<button (click)="mail()" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">Submit</button> <button (click)="mail()" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">Submit</button>
</div> </div>
</form> </form>

View File

@ -3,7 +3,6 @@ import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { LeafletModule } from '@bluehalo/ngx-leaflet'; import { LeafletModule } from '@bluehalo/ngx-leaflet';
import { faTimes } from '@fortawesome/free-solid-svg-icons'; import { faTimes } from '@fortawesome/free-solid-svg-icons';
import dayjs from 'dayjs';
import { GalleryModule, ImageItem } from 'ng-gallery'; import { GalleryModule, ImageItem } from 'ng-gallery';
import { ShareButton } from 'ngx-sharebuttons/button'; import { ShareButton } from 'ngx-sharebuttons/button';
import { lastValueFrom } from 'rxjs'; import { lastValueFrom } from 'rxjs';
@ -110,17 +109,6 @@ export class DetailsCommercialPropertyListingComponent extends BaseDetailsCompon
{ label: 'Located in', value: this.selectOptions.getState(this.listing.location.state) }, { label: 'Located in', value: this.selectOptions.getState(this.listing.location.state) },
{ label: this.listing.location.name ? 'City' : 'County', value: this.listing.location.name ? this.listing.location.name : this.listing.location.county }, { label: this.listing.location.name ? 'City' : 'County', value: this.listing.location.name ? this.listing.location.name : this.listing.location.county },
{ label: 'Asking Price:', value: `$${this.listing.price?.toLocaleString()}` }, { label: 'Asking Price:', value: `$${this.listing.price?.toLocaleString()}` },
{ label: 'Listed since', value: `${this.dateInserted()} - ${this.getDaysListed()} days` },
{
label: 'Listing by',
value: null, // Wird nicht verwendet
isHtml: true,
isListingBy: true, // Flag für den speziellen Fall
user: this.listingUser, // Übergebe das User-Objekt
imagePath: this.listing.imagePath,
imageBaseUrl: this.env.imageBaseUrl,
ts: this.ts,
},
]; ];
if (this.listing.draft) { if (this.listing.draft) {
this.propertyDetails.push({ label: 'Draft', value: this.listing.draft ? 'Yes' : 'No' }); this.propertyDetails.push({ label: 'Draft', value: this.listing.draft ? 'Yes' : 'No' });
@ -156,10 +144,8 @@ export class DetailsCommercialPropertyListingComponent extends BaseDetailsCompon
this.mailinfo.email = this.listingUser.email; this.mailinfo.email = this.listingUser.email;
this.mailinfo.listing = this.listing; this.mailinfo.listing = this.listing;
await this.mailService.mail(this.mailinfo); await this.mailService.mail(this.mailinfo);
this.validationMessagesService.clearMessages();
this.auditService.createEvent(this.listing.id, 'contact', this.user?.email, this.mailinfo.sender); this.auditService.createEvent(this.listing.id, 'contact', this.user?.email, this.mailinfo.sender);
this.messageService.addMessage({ severity: 'success', text: 'Your message has been sent to the creator of the listing', duration: 3000 }); this.messageService.addMessage({ severity: 'success', text: 'Your message has been sent to the creator of the listing', duration: 3000 });
this.mailinfo = createMailInfo(this.user);
} catch (error) { } catch (error) {
this.messageService.addMessage({ this.messageService.addMessage({
severity: 'danger', severity: 'danger',
@ -170,6 +156,9 @@ export class DetailsCommercialPropertyListingComponent extends BaseDetailsCompon
this.validationMessagesService.updateMessages(error.error.message); this.validationMessagesService.updateMessages(error.error.message);
} }
} }
if (this.user) {
this.mailinfo = createMailInfo(this.user);
}
} }
containsError(fieldname: string) { containsError(fieldname: string) {
return this.errorResponse?.fields.map(f => f.fieldname).includes(fieldname); return this.errorResponse?.fields.map(f => f.fieldname).includes(fieldname);
@ -206,10 +195,4 @@ export class DetailsCommercialPropertyListingComponent extends BaseDetailsCompon
createEvent(eventType: EventTypeEnum) { createEvent(eventType: EventTypeEnum) {
this.auditService.createEvent(this.listing.id, eventType, this.user?.email); this.auditService.createEvent(this.listing.id, eventType, this.user?.email);
} }
getDaysListed() {
return dayjs().diff(this.listing.created, 'day');
}
dateInserted() {
return dayjs(this.listing.created).format('DD/MM/YYYY');
}
} }

View File

@ -1,6 +1,6 @@
<div class="container mx-auto p-4"> <div class="container mx-auto p-4">
@if(user){ @if(user){
<div class="bg-white drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg rounded-lg overflow-hidden"> <div class="bg-white shadow-md rounded-lg overflow-hidden">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between p-4 border-b relative"> <div class="flex items-center justify-between p-4 border-b relative">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">

View File

@ -1,12 +1,11 @@
:host ::ng-deep p { :host ::ng-deep p {
display: block; display: block;
//margin-top: 1em; // margin-top: 1em;
//margin-bottom: 1em; // margin-bottom: 1em;
margin-left: 0; margin-left: 0;
margin-right: 0; margin-right: 0;
font-size: 1rem; /* oder 1rem, abhängig vom Browser und den Standardeinstellungen */ font-size: 1rem; /* oder 1rem, abhängig vom Browser und den Standardeinstellungen */
line-height: 1.5; line-height: 1.5;
min-height: 1.5em;
} }
:host ::ng-deep h1 { :host ::ng-deep h1 {
display: block; display: block;

View File

@ -34,35 +34,17 @@
</div> </div>
</div> </div>
<!-- ==== ANPASSUNGEN START ==== --> <main class="flex flex-col items-center justify-center lg:px-4 w-full flex-grow">
<!-- 1. px-4 für <main> (vorher px-2 sm:px-4) --> <div class="bg-cover-custom py-20 md:py-40 flex flex-col w-full">
<main class="flex flex-col items-center justify-center px-4 w-full flex-grow">
<div
class="bg-cover-custom pb-12 md:py-20 flex flex-col w-full rounded-xl lg:rounded-2xl md:drop-shadow-custom-md lg:drop-shadow-custom-lg min-h-[calc(100vh_-_20rem)] lg:min-h-[calc(100vh_-_10rem)] max-sm:bg-contain max-sm:bg-bottom max-sm:bg-no-repeat max-sm:min-h-[calc(100vh_-_7rem)] max-sm:bg-blue-600"
>
<div class="flex justify-center w-full"> <div class="flex justify-center w-full">
<!-- 3. Für Mobile: m-2 statt max-w-xs; ab sm: wieder max-width und kein Margin --> <div class="w-11/12 md:w-2/3 lg:w-1/2">
<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"> <h1 class="text-3xl md:text-4xl lg:text-5xl font-bold text-blue-900 mb-4 text-center">Find businesses for sale.</h1>
<!-- Hero-Container --> <p class="text-base md:text-lg lg:text-xl text-blue-600 mb-8 text-center">Unlocking Exclusive Opportunities - Empowering Entrepreneurial Dreams</p>
<section class="relative"> <div class="bg-white bg-opacity-80 pb-6 pt-2 px-2 rounded-lg shadow-lg w-full" [ngClass]="{ 'pt-6': aiSearch }">
<!-- Dein Hintergrundbild liegt hier per CSS oder absolutem <img> -->
<!-- 1) Overlay: sorgt für Kontrast auf hellem Himmel -->
<div aria-hidden="true" class="pointer-events-none absolute inset-0"></div>
<!-- 2) Textblock -->
<div class="relative z-10 mx-auto max-w-4xl px-6 sm:px-6 py-4 sm:py-16 text-center text-white">
<h1 class="text-[1.55rem] sm:text-4xl md:text-5xl lg:text-6xl font-extrabold tracking-tight leading-tight drop-shadow-[0_2px_6px_rgba(0,0,0,0.55)]">Find businesses for Sale</h1>
<p class="mt-3 sm:mt-4 text-base sm:text-lg md:text-xl lg:text-2xl font-medium text-white/90 drop-shadow-[0_1.5px_4px_rgba(0,0,0,0.6)]">Unlocking Opportunities - Empowering Entrepreneurial Dreams</p>
</div>
</section>
<!-- Restliche Anpassungen (Innenabstände, Button-Paddings etc.) bleiben wie im vorherigen Schritt -->
<div class="bg-white bg-opacity-80 pb-4 md:pb-6 pt-2 px-3 sm:px-4 md:px-6 rounded-lg shadow-lg w-full" [ngClass]="{ 'pt-6': aiSearch }">
@if(!aiSearch){ @if(!aiSearch){
<div class="text-sm lg:text-base mb-1 text-center text-gray-500 border-gray-200 dark:text-gray-400 dark:border-gray-700 flex justify-between"> <div class="text-sm lg:text-base mb-1 text-center text-gray-500 border-gray-200 dark:text-gray-400 dark:border-gray-700 flex justify-center">
<ul class="flex flex-wrap -mb-px w-full"> <ul class="flex flex-wrap -mb-px">
<li class="w-[33%]"> <li class="me-2">
<a <a
(click)="changeTab('business')" (click)="changeTab('business')"
[ngClass]=" [ngClass]="
@ -70,12 +52,12 @@
? ['text-blue-600', 'border-blue-600', 'active', 'dark:text-blue-500', 'dark:border-blue-500'] ? ['text-blue-600', 'border-blue-600', 'active', 'dark:text-blue-500', 'dark:border-blue-500']
: ['border-transparent', 'hover:text-gray-600', 'hover:border-gray-300', 'dark:hover:text-gray-300'] : ['border-transparent', 'hover:text-gray-600', 'hover:border-gray-300', 'dark:hover:text-gray-300']
" "
class="hover:cursor-pointer inline-block px-1 py-2 md:p-4 border-b-2 rounded-t-lg" class="hover:cursor-pointer inline-block p-4 border-b-2 rounded-t-lg"
>Businesses</a >Businesses</a
> >
</li> </li>
@if ((numberOfCommercial$ | async) > 0) { @if ((numberOfCommercial$ | async) > 0) {
<li class="w-[33%]"> <li class="me-2">
<a <a
(click)="changeTab('commercialProperty')" (click)="changeTab('commercialProperty')"
[ngClass]=" [ngClass]="
@ -83,12 +65,13 @@
? ['text-blue-600', 'border-blue-600', 'active', 'dark:text-blue-500', 'dark:border-blue-500'] ? ['text-blue-600', 'border-blue-600', 'active', 'dark:text-blue-500', 'dark:border-blue-500']
: ['border-transparent', 'hover:text-gray-600', 'hover:border-gray-300', 'dark:hover:text-gray-300'] : ['border-transparent', 'hover:text-gray-600', 'hover:border-gray-300', 'dark:hover:text-gray-300']
" "
class="hover:cursor-pointer inline-block px-1 py-2 md:p-4 border-b-2 rounded-t-lg" class="hover:cursor-pointer inline-block p-4 border-b-2 rounded-t-lg"
>Properties</a >Properties</a
> >
</li> </li>
} @if ((numberOfBroker$ | async) > 0) { }
<li class="w-[33%]"> @if ((numberOfBroker$ | async) > 0) {
<li class="me-2">
<a <a
(click)="changeTab('broker')" (click)="changeTab('broker')"
[ngClass]=" [ngClass]="
@ -96,14 +79,49 @@
? ['text-blue-600', 'border-blue-600', 'active', 'dark:text-blue-500', 'dark:border-blue-500'] ? ['text-blue-600', 'border-blue-600', 'active', 'dark:text-blue-500', 'dark:border-blue-500']
: ['border-transparent', 'hover:text-gray-600', 'hover:border-gray-300', 'dark:hover:text-gray-300'] : ['border-transparent', 'hover:text-gray-600', 'hover:border-gray-300', 'dark:hover:text-gray-300']
" "
class="hover:cursor-pointer inline-block px-1 py-2 md:p-4 border-b-2 rounded-t-lg" class="hover:cursor-pointer inline-block p-4 border-b-2 rounded-t-lg"
>Professionals</a >Professionals</a
> >
</li> </li>
} }
</ul> </ul>
</div> </div>
} @if(criteria && !aiSearch){ } @if(aiSearch){
<div class="w-full max-w-3xl mx-auto bg-white rounded-lg flex flex-col md:flex-row md:border md:border-gray-300">
<div class="md:w-48 flex-1 md:border-r border-gray-300 overflow-hidden mb-2 md:mb-0">
<div class="relative max-sm:border border-gray-300 rounded-md">
<input #aiSearchInput type="text" [(ngModel)]="aiSearchText" name="aiSearchText" class="w-full p-2 border border-gray-300 rounded-md" (focus)="stopTypingEffect()" (blur)="startTypingEffect()" />
</div>
</div>
<div class="bg-blue-600 hover:bg-blue-500 transition-colors duration-200 max-sm:rounded-md">
<button
type="button"
class="w-full h-full text-white font-semibold py-3 px-6 focus:outline-none rounded-md md:rounded-none flex items-center justify-center min-w-[180px] min-h-[48px]"
(click)="generateAiResponse()"
>
<span class="flex items-center">
@if(loadingAi){
<svg aria-hidden="true" role="status" class="w-4 h-4 mr-3 text-white animate-spin" viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="#E5E7EB"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentColor"
/>
</svg>
<span>Loading ...</span>
} @else {
<span>Search</span>
}
</span>
</button>
</div>
</div>
@if(aiSearchFailed){
<div id="error-message" class="w-full max-w-3xl mx-auto mt-2 text-red-600 text-center">Search timed out. Please try again or use classic Search</div>
} } @if(criteria && !aiSearch){
<div class="w-full max-w-3xl mx-auto bg-white rounded-lg flex flex-col md:flex-row md:border md:border-gray-300"> <div class="w-full max-w-3xl mx-auto bg-white rounded-lg flex flex-col md:flex-row md:border md:border-gray-300">
<div class="md:flex-none md:w-48 flex-1 md:border-r border-gray-300 overflow-hidden mb-2 md:mb-0"> <div class="md:flex-none md:w-48 flex-1 md:border-r border-gray-300 overflow-hidden mb-2 md:mb-0">
<div class="relative max-sm:border border-gray-300 rounded-md"> <div class="relative max-sm:border border-gray-300 rounded-md">
@ -167,19 +185,26 @@
</div> </div>
} }
<div class="bg-blue-600 hover:bg-blue-500 transition-colors duration-200 max-sm:rounded-md"> <div class="bg-blue-600 hover:bg-blue-500 transition-colors duration-200 max-sm:rounded-md">
@if( numberOfResults$){ @if(getNumberOfFiltersSet()>0 && numberOfResults$){
<button class="w-full h-full text-white font-semibold py-2 px-4 md:py-3 md:px-6 focus:outline-none rounded-md md:rounded-none min-h-[48px]" (click)="search()"> <button class="w-full h-full text-white font-semibold py-3 px-6 focus:outline-none rounded-md md:rounded-none" (click)="search()">Search ({{ numberOfResults$ | async }})</button>
Search ({{ numberOfResults$ | async }})
</button>
}@else { }@else {
<button class="w-full h-full text-white font-semibold py-2 px-4 md:py-3 md:px-6 focus:outline-none rounded-md md:rounded-none min-h-[48px]" (click)="search()">Search</button> <button class="w-full h-full text-white font-semibold py-3 px-6 focus:outline-none rounded-md md:rounded-none" (click)="search()">Search</button>
} }
</div> </div>
</div> </div>
} }
<!-- <div class="mt-4 flex items-center justify-center text-gray-700">
<span class="mr-2">AI-Search</span>
<span [attr.data-tooltip-target]="tooltipTargetBeta" class="bg-sky-300 text-teal-800 text-xs font-semibold px-2 py-1 rounded">BETA</span>
<app-tooltip [id]="tooltipTargetBeta" text="AI will convert your input into filter criteria. Please check them in the filter menu after search"></app-tooltip>
<span class="ml-2">- Try now</span>
<div class="ml-4 relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
<input (click)="toggleAiSearch()" type="checkbox" name="toggle" id="toggle" class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 border-gray-300 appearance-none cursor-pointer" />
<label for="toggle" class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"></label>
</div>
</div> -->
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</main> </main>
<!-- ==== ANPASSUNGEN ENDE ==== -->

View File

@ -1,8 +1,10 @@
.bg-cover-custom { .bg-cover-custom {
background-image: url('/assets/images/flags_bg.avif'); background-image: url('/assets/images/index-bg.webp');
background-size: cover; background-size: cover;
background-position: center; background-position: center;
border-radius: 20px; border-radius: 20px;
box-shadow: 0 10px 15px rgba(0, 0, 0, 0.3);
min-height: calc(100vh - 4rem);
} }
select:not([size]) { select:not([size]) {
background-image: unset; background-image: unset;

View File

@ -3,22 +3,30 @@ import { ChangeDetectorRef, Component, ElementRef, ViewChild } from '@angular/co
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, untilDestroyed } from '@ngneat/until-destroy';
import { initFlowbite } from 'flowbite'; import { initFlowbite } from 'flowbite';
import { catchError, concat, distinctUntilChanged, Observable, of, Subject, switchMap, tap } from 'rxjs'; import { catchError, concat, debounceTime, distinctUntilChanged, lastValueFrom, Observable, of, Subject, Subscription, 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 { 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 { CriteriaChangeService } from '../../services/criteria-change.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 { UserService } from '../../services/user.service'; import { UserService } from '../../services/user.service';
import { map2User } from '../../utils/utils'; import {
assignProperties,
compareObjects,
createEmptyBusinessListingCriteria,
createEmptyCommercialPropertyListingCriteria,
createEmptyUserListingCriteria,
createEnhancedProxy,
getCriteriaStateObject,
map2User,
} from '../../utils/utils';
@UntilDestroy() @UntilDestroy()
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
@ -42,6 +50,7 @@ export class HomeComponent {
cityLoading = false; cityLoading = false;
cityInput$ = new Subject<string>(); cityInput$ = new Subject<string>();
cityOrState = undefined; cityOrState = undefined;
private criteriaChangeSubscription: Subscription;
numberOfResults$: Observable<number>; numberOfResults$: Observable<number>;
numberOfBroker$: Observable<number>; numberOfBroker$: Observable<number>;
numberOfCommercial$: Observable<number>; numberOfCommercial$: Observable<number>;
@ -50,156 +59,126 @@ export class HomeComponent {
aiSearchFailed = false; aiSearchFailed = false;
loadingAi = false; loadingAi = false;
@ViewChild('aiSearchInput', { static: false }) searchInput!: ElementRef; @ViewChild('aiSearchInput', { static: false }) searchInput!: ElementRef;
typingSpeed: number = 100; typingSpeed: number = 100; // Geschwindigkeit des Tippens (ms)
pauseTime: number = 2000; pauseTime: number = 2000; // Pausezeit, bevor der Text verschwindet (ms)
index: number = 0; index: number = 0;
charIndex: number = 0; charIndex: number = 0;
typingInterval: any; typingInterval: any;
showInput: boolean = true; showInput: boolean = true; // Steuerung der Anzeige des Eingabefelds
tooltipTargetBeta = 'tooltipTargetBeta'; tooltipTargetBeta = 'tooltipTargetBeta';
public constructor(
constructor(
private router: Router, private router: Router,
private modalService: ModalService, private modalService: ModalService,
private searchService: SearchService, private searchService: SearchService,
private activatedRoute: ActivatedRoute, private activatedRoute: ActivatedRoute,
public selectOptions: SelectOptionsService, public selectOptions: SelectOptionsService,
private criteriaChangeService: CriteriaChangeService,
private geoService: GeoService, private geoService: GeoService,
public cdRef: ChangeDetectorRef, public cdRef: ChangeDetectorRef,
private listingService: ListingsService, private listingService: ListingsService,
private userService: UserService, private userService: UserService,
private aiService: AiService, private aiService: AiService,
private authService: AuthService, private authService: AuthService,
private filterStateService: FilterStateService,
) {} ) {}
async ngOnInit() { async ngOnInit() {
setTimeout(() => { setTimeout(() => {
initFlowbite(); initFlowbite();
}, 0); }, 0);
this.numberOfBroker$ = this.userService.getNumberOfBroker(createEmptyUserListingCriteria());
// Clear all filters and sort options on initial load this.numberOfCommercial$ = this.listingService.getNumberOfListings(createEmptyCommercialPropertyListingCriteria(), 'commercialProperty');
this.filterStateService.resetCriteria('businessListings');
this.filterStateService.resetCriteria('commercialPropertyListings');
this.filterStateService.resetCriteria('brokerListings');
this.filterStateService.updateSortBy('businessListings', null);
this.filterStateService.updateSortBy('commercialPropertyListings', null);
this.filterStateService.updateSortBy('brokerListings', null);
// 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');
const token = await this.authService.getToken(); const token = await this.authService.getToken();
sessionStorage.removeItem('businessListings');
sessionStorage.removeItem('commercialPropertyListings');
sessionStorage.removeItem('brokerListings');
this.criteria = createEnhancedProxy(getCriteriaStateObject('businessListings'), this);
this.user = map2User(token); this.user = map2User(token);
this.loadCities(); this.loadCities();
this.setTotalNumberOfResults(); this.setupCriteriaChangeListener();
} }
async changeTab(tabname: 'business' | 'commercialProperty' | 'broker') {
changeTab(tabname: 'business' | 'commercialProperty' | 'broker') {
this.activeTabAction = tabname; this.activeTabAction = tabname;
this.cityOrState = null; this.cityOrState = null;
const tabToListingType = { if ('business' === tabname) {
business: 'businessListings', this.criteria = createEnhancedProxy(getCriteriaStateObject('businessListings'), this);
commercialProperty: 'commercialPropertyListings', } else if ('commercialProperty' === tabname) {
broker: 'brokerListings', this.criteria = createEnhancedProxy(getCriteriaStateObject('commercialPropertyListings'), this);
}; } else if ('broker' === tabname) {
this.criteria = this.filterStateService.getCriteria(tabToListingType[tabname] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings'); this.criteria = createEnhancedProxy(getCriteriaStateObject('brokerListings'), this);
this.setTotalNumberOfResults(); } else {
this.criteria = undefined;
}
} }
search() { search() {
this.router.navigate([`${this.activeTabAction}Listings`]); this.router.navigate([`${this.activeTabAction}Listings`]);
} }
private setupCriteriaChangeListener() {
this.criteriaChangeSubscription = this.criteriaChangeService.criteriaChange$.pipe(untilDestroyed(this), debounceTime(400)).subscribe(() => this.setTotalNumberOfResults());
}
toggleMenu() { toggleMenu() {
this.isMenuOpen = !this.isMenuOpen; this.isMenuOpen = !this.isMenuOpen;
} }
onTypesChange(value) { onTypesChange(value) {
const tabToListingType = { if (value === '') {
business: 'businessListings', // Wenn keine Option ausgewählt ist, setzen Sie types zurück auf ein leeres Array
commercialProperty: 'commercialPropertyListings', this.criteria.types = [];
broker: 'brokerListings', } else {
}; this.criteria.types = [value];
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings'; }
this.filterStateService.updateCriteria(listingType, { types: value === '' ? [] : [value] });
this.criteria = this.filterStateService.getCriteria(listingType);
this.setTotalNumberOfResults();
} }
onRadiusChange(value) { onRadiusChange(value) {
const tabToListingType = { if (value === 'null') {
business: 'businessListings', // Wenn keine Option ausgewählt ist, setzen Sie types zurück auf ein leeres Array
commercialProperty: 'commercialPropertyListings', this.criteria.radius = null;
broker: 'brokerListings', } else {
}; this.criteria.radius = parseInt(value);
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings'; }
this.filterStateService.updateCriteria(listingType, { radius: value === 'null' ? null : parseInt(value) });
this.criteria = this.filterStateService.getCriteria(listingType);
this.setTotalNumberOfResults();
} }
async openModal() { async openModal() {
const tabToListingType = {
business: 'businessListings',
commercialProperty: 'commercialPropertyListings',
broker: 'brokerListings',
};
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
const accepted = await this.modalService.showModal(this.criteria); const accepted = await this.modalService.showModal(this.criteria);
if (accepted) { if (accepted) {
this.router.navigate([`${this.activeTabAction}Listings`]); this.router.navigate([`${this.activeTabAction}Listings`]);
} }
} }
private loadCities() { private loadCities() {
this.cities$ = concat( this.cities$ = concat(
of([]), of([]), // default items
this.cityInput$.pipe( this.cityInput$.pipe(
distinctUntilChanged(), distinctUntilChanged(),
tap(() => (this.cityLoading = true)), tap(() => (this.cityLoading = true)),
switchMap(term => switchMap(term =>
//this.geoService.findCitiesStartingWith(term).pipe(
this.geoService.findCitiesAndStatesStartingWith(term).pipe( this.geoService.findCitiesAndStatesStartingWith(term).pipe(
catchError(() => of([])), catchError(() => of([])), // empty list on error
// map(cities => cities.map(city => city.city)), // transform the list of objects to a list of city names
tap(() => (this.cityLoading = false)), tap(() => (this.cityLoading = false)),
), ),
), ),
), ),
); );
} }
trackByFn(item: GeoResult) { trackByFn(item: GeoResult) {
return item.id; return item.id;
} }
setCityOrState(cityOrState: CityAndStateResult) { setCityOrState(cityOrState: CityAndStateResult) {
const tabToListingType = {
business: 'businessListings',
commercialProperty: 'commercialPropertyListings',
broker: 'brokerListings',
};
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
if (cityOrState) { if (cityOrState) {
if (cityOrState.type === 'state') { if (cityOrState.type === 'state') {
this.filterStateService.updateCriteria(listingType, { state: cityOrState.content.state_code, city: null, radius: null, searchType: 'exact' }); this.criteria.state = cityOrState.content.state_code;
} else { } else {
this.filterStateService.updateCriteria(listingType, { this.criteria.city = cityOrState.content as GeoResult;
city: cityOrState.content as GeoResult, this.criteria.state = cityOrState.content.state;
state: cityOrState.content.state, this.criteria.searchType = 'radius';
searchType: 'radius', this.criteria.radius = 20;
radius: 20,
});
} }
} else { } else {
this.filterStateService.updateCriteria(listingType, { state: null, city: null, radius: null, searchType: 'exact' }); this.criteria.state = null;
this.criteria.city = null;
this.criteria.radius = null;
this.criteria.searchType = 'exact';
} }
this.criteria = this.filterStateService.getCriteria(listingType);
this.setTotalNumberOfResults();
} }
getTypes() { getTypes() {
if (this.criteria.criteriaType === 'businessListings') { if (this.criteria.criteriaType === 'businessListings') {
return this.selectOptions.typesOfBusiness; return this.selectOptions.typesOfBusiness;
@ -209,7 +188,6 @@ export class HomeComponent {
return this.selectOptions.customerSubTypes; return this.selectOptions.customerSubTypes;
} }
} }
getPlaceholderLabel() { getPlaceholderLabel() {
if (this.criteria.criteriaType === 'businessListings') { if (this.criteria.criteriaType === 'businessListings') {
return 'Business Type'; return 'Business Type';
@ -219,28 +197,117 @@ export class HomeComponent {
return 'Professional Type'; return 'Professional Type';
} }
} }
setTotalNumberOfResults() { setTotalNumberOfResults() {
if (this.criteria) { if (this.criteria) {
console.log(`Getting total number of results for ${this.criteria.criteriaType}`); console.log(`Getting total number of results for ${this.criteria.criteriaType}`);
const tabToListingType = {
business: 'businessListings',
commercialProperty: 'commercialPropertyListings',
broker: 'brokerListings',
};
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
if (this.criteria.criteriaType === 'businessListings' || this.criteria.criteriaType === 'commercialPropertyListings') { if (this.criteria.criteriaType === 'businessListings' || this.criteria.criteriaType === 'commercialPropertyListings') {
this.numberOfResults$ = this.listingService.getNumberOfListings(this.criteria.criteriaType === 'businessListings' ? 'business' : 'commercialProperty'); this.numberOfResults$ = this.listingService.getNumberOfListings(this.criteria, this.criteria.criteriaType === 'businessListings' ? 'business' : 'commercialProperty');
} else if (this.criteria.criteriaType === 'brokerListings') { } else if (this.criteria.criteriaType === 'brokerListings') {
this.numberOfResults$ = this.userService.getNumberOfBroker(this.filterStateService.getCriteria('brokerListings') as UserListingCriteria); this.numberOfResults$ = this.userService.getNumberOfBroker(this.criteria);
} else { } else {
this.numberOfResults$ = of(); this.numberOfResults$ = of();
} }
} }
} }
getNumberOfFiltersSet() {
if (this.criteria?.criteriaType === 'brokerListings') {
return compareObjects(createEmptyUserListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius']);
} else if (this.criteria?.criteriaType === 'businessListings') {
return compareObjects(createEmptyBusinessListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius']);
} else if (this.criteria?.criteriaType === 'commercialPropertyListings') {
return compareObjects(createEmptyCommercialPropertyListingCriteria(), this.criteria, ['start', 'length', 'page', 'searchType', 'radius']);
} else {
return 0;
}
}
toggleAiSearch() {
this.aiSearch = !this.aiSearch;
this.aiSearchFailed = false;
if (!this.aiSearch) {
this.aiSearchText = '';
this.stopTypingEffect();
} else {
setTimeout(() => this.startTypingEffect(), 0);
}
}
ngOnDestroy(): void { ngOnDestroy(): void {
clearTimeout(this.typingInterval); // Stelle sicher, dass das Intervall gestoppt wird, wenn die Komponente zerstört wird
}
startTypingEffect(): void {
if (!this.aiSearchText) {
this.typePlaceholder();
}
}
stopTypingEffect(): void {
clearTimeout(this.typingInterval); clearTimeout(this.typingInterval);
} }
typePlaceholder(): void {
if (!this.searchInput || !this.searchInput.nativeElement) {
return; // Falls das Eingabefeld nicht verfügbar ist (z.B. durch ngIf)
}
if (this.aiSearchText) {
return; // Stoppe, wenn der Benutzer Text eingegeben hat
}
const inputField = this.searchInput.nativeElement as HTMLInputElement;
if (document.activeElement === inputField) {
this.stopTypingEffect();
return;
}
inputField.placeholder = this.placeholders[this.index].substring(0, this.charIndex);
if (this.charIndex < this.placeholders[this.index].length) {
this.charIndex++;
this.typingInterval = setTimeout(() => this.typePlaceholder(), this.typingSpeed);
} else {
// Nach dem vollständigen Tippen eine Pause einlegen
this.typingInterval = setTimeout(() => {
inputField.placeholder = ''; // Schlagartiges Löschen des Platzhalters
this.charIndex = 0;
this.index = (this.index + 1) % this.placeholders.length;
this.typingInterval = setTimeout(() => this.typePlaceholder(), this.typingSpeed);
}, this.pauseTime);
}
}
async generateAiResponse() {
this.loadingAi = true;
this.aiSearchFailed = false;
try {
const result = await this.aiService.generateAiReponse(this.aiSearchText);
let criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria | any;
if (result.criteriaType === 'businessListings') {
this.changeTab('business');
criteria = result as BusinessListingCriteria;
} else if (result.criteriaType === 'commercialPropertyListings') {
this.changeTab('commercialProperty');
criteria = result as CommercialPropertyListingCriteria;
} else {
this.changeTab('broker');
criteria = result as UserListingCriteria;
}
const city = criteria.city as string;
if (city && city.length > 0) {
let results = await lastValueFrom(this.geoService.findCitiesStartingWith(city, criteria.state));
if (results.length > 0) {
criteria.city = results[0];
} else {
criteria.city = null;
}
}
if (criteria.radius && criteria.radius.length > 0) {
criteria.radius = parseInt(criteria.radius);
}
this.loadingAi = false;
this.criteria = assignProperties(this.criteria, criteria);
this.search();
} catch (error) {
console.log(error);
this.aiSearchFailed = true;
this.loadingAi = false;
}
}
} }

View File

@ -1,9 +1,8 @@
<div class="container mx-auto px-4 py-8"> <div class="container mx-auto px-4 py-8">
@if(users?.length>0){
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Amanda Taylor --> <!-- Amanda Taylor -->
@for (user of users; track user) { @for (user of users; track user) {
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6 flex flex-col justify-between"> <div class="bg-white rounded-lg shadow-md p-6 flex flex-col justify-between">
<div class="flex items-start space-x-4"> <div class="flex items-start space-x-4">
@if(user.hasProfile){ @if(user.hasProfile){
<img src="{{ env.imageBaseUrl }}/pictures/profile/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}" class="rounded-md w-20 h-26 object-cover" /> <img src="{{ env.imageBaseUrl }}/pictures/profile/{{ emailToDirName(user.email) }}.avif?_ts={{ ts }}" class="rounded-md w-20 h-26 object-cover" />
@ -37,59 +36,6 @@
} }
</div> </div>
} @else if (users?.length===0){
<div class="w-full flex items-center flex-wrap justify-center gap-10">
<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">
<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"
fill="#EEF2FF"
/>
<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"
fill="white"
stroke="#E5E7EB"
/>
<ellipse cx="80.0618" cy="81" rx="28.0342" ry="28.0342" fill="#EEF2FF" />
<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"
stroke="#E5E7EB"
/>
<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
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="#A5B4FC"
stroke="#818CF8"
/>
<path
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"
/>
<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"
fill="#4F46E5"
/>
<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"
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" />
<rect x="38.3522" y="29.7179" width="13.6752" height="2.73504" rx="1.36752" fill="#4F46E5" />
<circle cx="56.13" cy="31.0854" r="1.36752" 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" />
</svg>
<div>
<h2 class="text-center text-black text-xl font-semibold leading-loose pb-2">Therere no professionals here</h2>
<p class="text-center text-black text-base font-normal leading-relaxed pb-4">Try changing your filters to <br />see professionals</p>
<div class="flex gap-3">
<button (click)="clearAllFilters()" class="w-full px-3 py-2 rounded-full border border-gray-300 text-gray-900 text-xs font-semibold leading-4">Clear Filter</button>
</div>
</div>
</div>
</div>
}
</div> </div>
@if(pageCount>1){ @if(pageCount>1){
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator> <app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator>

View File

@ -2,20 +2,18 @@ import { CommonModule, NgOptimizedImage } from '@angular/common';
import { ChangeDetectorRef, Component } from '@angular/core'; import { ChangeDetectorRef, Component } 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, untilDestroyed } from '@ngneat/until-destroy';
import { BusinessListing, SortByOptions, User } from '../../../../../../bizmatch-server/src/models/db.model'; import { BusinessListing, 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 } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
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 { ModalService } from '../../../components/search-modal/modal.service';
import { CriteriaChangeService } from '../../../services/criteria-change.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, getCriteriaProxy, resetUserListingCriteria } from '../../../utils/utils'; import { getCriteriaProxy } from '../../../utils/utils';
@UntilDestroy() @UntilDestroy()
@Component({ @Component({
selector: 'app-broker-listings', selector: 'app-broker-listings',
@ -45,7 +43,6 @@ export class BrokerListingsComponent {
emailToDirName = emailToDirName; emailToDirName = emailToDirName;
page = 1; page = 1;
pageCount = 1; pageCount = 1;
sortBy: SortByOptions = null; // Neu: Separate Property
constructor( constructor(
public selectOptions: SelectOptionsService, public selectOptions: SelectOptionsService,
private listingsService: ListingsService, private listingsService: ListingsService,
@ -56,21 +53,15 @@ export class BrokerListingsComponent {
private imageService: ImageService, private imageService: ImageService,
private route: ActivatedRoute, private route: ActivatedRoute,
private searchService: SearchService, private searchService: SearchService,
private modalService: ModalService,
private criteriaChangeService: CriteriaChangeService,
) { ) {
this.criteria = getCriteriaProxy('brokerListings', this) as UserListingCriteria; this.criteria = getCriteriaProxy('brokerListings', this) as UserListingCriteria;
this.init(); this.init();
this.loadSortBy(); this.searchService.currentCriteria.pipe(untilDestroyed(this)).subscribe(criteria => {
// this.searchService.currentCriteria.pipe(untilDestroyed(this)).subscribe(({ criteria }) => { if (criteria && criteria.criteriaType === 'brokerListings') {
// if (criteria.criteriaType === 'brokerListings') { this.criteria = criteria as UserListingCriteria;
// this.search(); this.search();
// }
// });
} }
private loadSortBy() { });
const storedSortBy = sessionStorage.getItem('professionalsSortBy');
this.sortBy = storedSortBy && storedSortBy !== 'null' ? (storedSortBy as SortByOptions) : null;
} }
async ngOnInit() {} async ngOnInit() {}
async init() { async init() {
@ -93,29 +84,4 @@ export class BrokerListingsComponent {
} }
reset() {} reset() {}
// New methods for filter actions
clearAllFilters() {
// Reset criteria to default values
resetUserListingCriteria(this.criteria);
// Reset pagination
this.criteria.page = 1;
this.criteria.start = 0;
this.criteriaChangeService.notifyCriteriaChange();
// Search with cleared filters
this.searchService.search('brokerListings');
}
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);
}
}
} }

View File

@ -1,69 +1,49 @@
<div class="flex flex-col md:flex-row"> <div class="container mx-auto p-4">
<!-- 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">
<app-search-modal [isModal]="false"></app-search-modal>
</div>
<!-- Main Content -->
<div class="w-full p-4">
<div class="container mx-auto">
@if(listings?.length > 0) {
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Anzahl der Spalten auf 3 reduziert und den Abstand erhöht -->
@for (listing of listings; track listing.id) { @for (listing of listings; track listing.id) {
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg overflow-hidden hover:shadow-xl"> <div class="bg-white rounded-lg shadow-lg overflow-hidden hover:shadow-xl">
<!-- Hover-Effekt hinzugefügt -->
<div class="p-6 flex flex-col h-full relative z-[0]"> <div class="p-6 flex flex-col h-full relative z-[0]">
<div class="flex items-center mb-4"> <div class="flex items-center mb-4">
<i [class]="selectOptions.getIconAndTextColorType(listing.type)" class="mr-2 text-xl"></i> <i [class]="selectOptions.getIconAndTextColorType(listing.type)" class="mr-2 text-xl"></i>
<!-- Icon vergrößert -->
<span [class]="selectOptions.getTextColorType(listing.type)" class="font-bold text-lg">{{ selectOptions.getBusiness(listing.type) }}</span> <span [class]="selectOptions.getTextColorType(listing.type)" class="font-bold text-lg">{{ selectOptions.getBusiness(listing.type) }}</span>
<!-- Schriftgröße erhöht -->
</div> </div>
<h2 class="text-xl font-semibold mb-4"> <h2 class="text-xl font-semibold mb-4">
<!-- Überschrift vergrößert -->
{{ listing.title }} {{ listing.title }}
@if(listing.draft) { @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> <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>
} }
</h2> </h2>
<div class="flex justify-between"> <div class="flex justify-between">
<!-- State Badge -->
<span class="w-fit inline-flex items-center justify-center px-2 py-1 mb-4 text-xs font-bold leading-none bg-gray-200 text-gray-700 rounded-full"> <span class="w-fit inline-flex items-center justify-center px-2 py-1 mb-4 text-xs font-bold leading-none bg-gray-200 text-gray-700 rounded-full">
{{ selectOptions.getState(listing.location.state) }} {{ selectOptions.getState(listing.location.state) }}
</span> </span>
<p class="text-sm text-gray-600 mb-4">
@if (getListingBadge(listing); as badge) { <strong>{{ getDaysListed(listing) }} days listed</strong>
<span </p>
class="mb-4 h-fit inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none rounded-full"
[ngClass]="{
'bg-emerald-100 text-emerald-800': badge === 'NEW',
'bg-blue-100 text-blue-800': badge === 'UPDATED'
}"
>
{{ badge }}
</span>
}
</div> </div>
<!-- Asking Price hervorgehoben -->
<p class="text-base font-bold text-gray-800 mb-2"> <p class="text-base font-bold text-gray-800 mb-2">
<strong>Asking price:</strong> <strong>Asking price:</strong> <span class="text-green-600"> {{ listing.price | currency : 'USD' : 'symbol' : '1.0-0' }}</span>
<span class="text-green-600">
{{ listing?.price != null ? (listing.price | currency : 'USD' : 'symbol' : '1.0-0') : 'undisclosed' }}
</span>
</p> </p>
<p class="text-sm text-gray-600 mb-2"> <p class="text-sm text-gray-600 mb-2"><strong>Sales revenue:</strong> {{ listing.salesRevenue | currency : 'USD' : 'symbol' : '1.0-0' }}</p>
<strong>Sales revenue:</strong> <p class="text-sm text-gray-600 mb-2"><strong>Net profit:</strong> {{ listing.cashFlow | currency : 'USD' : 'symbol' : '1.0-0' }}</p>
{{ listing?.salesRevenue != null ? (listing.salesRevenue | currency : 'USD' : 'symbol' : '1.0-0') : 'undisclosed' }} <p class="text-sm text-gray-600 mb-2"><strong>Location:</strong> {{ listing.location.name ? listing.location.name : listing.location.county }}</p>
</p> <p class="text-sm text-gray-600 mb-4"><strong>Established:</strong> {{ listing.established }}</p>
<p class="text-sm text-gray-600 mb-2">
<strong>Net profit:</strong>
{{ listing?.cashFlow != null ? (listing.cashFlow | currency : 'USD' : 'symbol' : '1.0-0') : 'undisclosed' }}
</p>
<p class="text-sm text-gray-600 mb-2">
<strong>Location:</strong> {{ listing.location.name ? listing.location.name : listing.location.county ? listing.location.county : this.selectOptions.getState(listing.location.state) }}
</p>
<p class="text-sm text-gray-600 mb-4"><strong>Years established:</strong> {{ listing.established }}</p>
<img src="{{ env.imageBaseUrl }}/pictures/logo/{{ listing.imageName }}.avif?_ts={{ ts }}" alt="Company logo" class="absolute bottom-[80px] right-[20px] h-[45px] w-auto" /> <img src="{{ env.imageBaseUrl }}/pictures/logo/{{ listing.imageName }}.avif?_ts={{ ts }}" alt="Company logo" class="absolute bottom-[80px] right-[20px] h-[45px] w-auto" />
<!-- Position und Größe des Bildes angepasst -->
<div class="flex-grow"></div> <div class="flex-grow"></div>
<button <button
class="bg-green-500 text-white px-5 py-3 rounded-full w-full flex items-center justify-center mt-4 transition-colors duration-200 hover:bg-green-600" class="bg-green-500 text-white px-5 py-3 rounded-full w-full flex items-center justify-center mt-4 transition-colors duration-200 hover:bg-green-600"
[routerLink]="['/details-business-listing', listing.id]" [routerLink]="['/details-business-listing', listing.id]"
> >
<!-- Button-Größe und Hover-Effekt verbessert -->
View Full Listing View Full Listing
<i class="fas fa-arrow-right ml-2"></i> <i class="fas fa-arrow-right ml-2"></i>
</button> </button>
@ -71,65 +51,8 @@
</div> </div>
} }
</div> </div>
} @else if (listings?.length === 0) {
<div class="w-full flex items-center flex-wrap justify-center gap-10">
<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">
<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"
fill="#EEF2FF"
/>
<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"
fill="white"
stroke="#E5E7EB"
/>
<ellipse cx="80.0618" cy="81" rx="28.0342" ry="28.0342" fill="#EEF2FF" />
<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"
stroke="#E5E7EB"
/>
<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
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="#A5B4FC"
stroke="#818CF8"
/>
<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"
fill="#4F46E5"
/>
<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"
fill="#4F46E5"
/>
<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"
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" />
<rect x="38.3522" y="29.7179" width="13.6752" height="2.73504" rx="1.36752" fill="#4F46E5" />
<circle cx="56.13" cy="31.0854" r="1.36752" 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" />
</svg>
<div>
<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>
<div class="flex gap-3">
<button (click)="clearAllFilters()" class="w-full px-3 py-2 rounded-full border border-gray-300 text-gray-900 text-xs font-semibold leading-4">Clear Filter</button>
</div>
</div>
</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-blue-500 text-white p-3 rounded-full shadow-lg z-20"><i class="fas fa-filter"></i> Filter</button>
</div> </div>
@if(pageCount>1){
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator>
}

View File

@ -1,171 +1,91 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component } 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, untilDestroyed } from '@ngneat/until-destroy';
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 } from '../../../../../../bizmatch-server/src/models/db.model';
import { BusinessListingCriteria, LISTINGS_PER_PAGE, ListingType, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model'; import { BusinessListingCriteria, LISTINGS_PER_PAGE, ListingType, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
import { PaginatorComponent } from '../../../components/paginator/paginator.component'; import { PaginatorComponent } from '../../../components/paginator/paginator.component';
import { ModalService } from '../../../components/search-modal/modal.service';
import { SearchModalComponent } from '../../../components/search-modal/search-modal.component';
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 { getCriteriaProxy } 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], imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent],
templateUrl: './business-listings.component.html', templateUrl: './business-listings.component.html',
styleUrls: ['./business-listings.component.scss', '../../pages.scss'], styleUrls: ['./business-listings.component.scss', '../../pages.scss'],
}) })
export class BusinessListingsComponent implements OnInit, OnDestroy { export class BusinessListingsComponent {
private destroy$ = new Subject<void>();
// Component properties
environment = environment; environment = environment;
env = environment; listings: Array<BusinessListing>;
listings: Array<BusinessListing> = []; filteredListings: Array<BusinessListing>;
filteredListings: Array<ListingType> = [];
criteria: BusinessListingCriteria; criteria: BusinessListingCriteria;
sortBy: SortByOptions | null = null; realEstateChecked: boolean;
maxPrice: string;
// Pagination minPrice: string;
totalRecords = 0; type: string;
state: string;
totalRecords: number = 0;
ts = new Date().getTime();
first: number = 0;
rows: number = 12;
env = environment;
public category: 'business' | 'commercialProperty' | 'professionals_brokers' | undefined;
page = 1; page = 1;
pageCount = 1; pageCount = 1;
first = 0;
rows = LISTINGS_PER_PAGE;
// UI state
ts = new Date().getTime();
emailToDirName = emailToDirName; emailToDirName = emailToDirName;
constructor( constructor(
public selectOptions: SelectOptionsService, public selectOptions: SelectOptionsService,
private listingsService: ListingsService, private listingsService: ListingsService,
private activatedRoute: ActivatedRoute,
private router: Router, private router: Router,
private cdRef: ChangeDetectorRef, private cdRef: ChangeDetectorRef,
private imageService: ImageService, private imageService: ImageService,
private searchService: SearchService,
private modalService: ModalService,
private filterStateService: FilterStateService,
private route: ActivatedRoute, private route: ActivatedRoute,
) {} private searchService: SearchService,
) {
ngOnInit(): void { this.criteria = getCriteriaProxy('businessListings', this) as BusinessListingCriteria;
// Subscribe to state changes this.init();
this.filterStateService this.searchService.currentCriteria.pipe(untilDestroyed(this)).subscribe(criteria => {
.getState$('businessListings') if (criteria && criteria.criteriaType === 'businessListings') {
.pipe(takeUntil(this.destroy$)) this.criteria = criteria as BusinessListingCriteria;
.subscribe(state => {
this.criteria = state.criteria;
this.sortBy = state.sortBy;
// 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 => {
if (type === 'businessListings') {
this.search(); this.search();
} }
}); });
} }
async ngOnInit() {
this.search();
}
async init() {
this.reset();
}
async search(): Promise<void> { async search() {
try { const listingReponse = await this.listingsService.getListings(this.criteria, 'business');
// Get current criteria from service this.listings = listingReponse.results;
this.criteria = this.filterStateService.getCriteria('businessListings') as BusinessListingCriteria; this.totalRecords = listingReponse.totalCount;
this.pageCount = this.totalRecords % LISTINGS_PER_PAGE === 0 ? this.totalRecords / LISTINGS_PER_PAGE : Math.floor(this.totalRecords / LISTINGS_PER_PAGE) + 1;
// Add sortBy if available this.page = this.criteria.page ? this.criteria.page : 1;
const searchCriteria = {
...this.criteria,
sortBy: this.sortBy,
};
// Perform search
const listingsResponse = await this.listingsService.getListings('business');
this.listings = listingsResponse.results;
this.totalRecords = listingsResponse.totalCount;
this.pageCount = Math.ceil(this.totalRecords / LISTINGS_PER_PAGE);
this.page = this.criteria.page || 1;
// Update view
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.cdRef.detectChanges(); this.cdRef.detectChanges();
} catch (error) {
console.error('Search error:', error);
// Handle error appropriately
this.listings = [];
this.totalRecords = 0;
this.cdRef.markForCheck();
} }
onPageChange(page: any) {
this.criteria.start = (page - 1) * LISTINGS_PER_PAGE;
this.criteria.length = LISTINGS_PER_PAGE;
this.criteria.page = page;
this.search();
} }
imageErrorHandler(listing: ListingType) {}
onPageChange(page: number): void { reset() {
// Update only pagination properties this.criteria.title = null;
this.filterStateService.updateCriteria('businessListings', {
page: page,
start: (page - 1) * LISTINGS_PER_PAGE,
length: LISTINGS_PER_PAGE,
});
// Search will be triggered automatically through state subscription
}
clearAllFilters(): void {
// Reset criteria but keep sortBy
this.filterStateService.clearFilters('businessListings');
// Search will be triggered automatically through state subscription
}
async openFilterModal(): Promise<void> {
// Open modal with current criteria
const currentCriteria = this.filterStateService.getCriteria('businessListings');
const modalResult = await this.modalService.showModal(currentCriteria);
if (modalResult.accepted) {
// Modal accepted changes - state is updated by modal
// Search will be triggered automatically through state subscription
} else {
// Modal was cancelled - no action needed
}
}
getListingPrice(listing: BusinessListing): string {
if (!listing.price) return 'Price on Request';
return `$${listing.price.toLocaleString()}`;
}
getListingLocation(listing: BusinessListing): string {
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;
return dayjs().diff(dayjs(date), 'day') < days;
}
getListingBadge(listing: BusinessListing): 'NEW' | 'UPDATED' | null {
if (this.isWithinDays(listing.created, 14)) return 'NEW'; // Priorität
if (this.isWithinDays(listing.updated, 14)) return 'UPDATED';
return null;
}
navigateToDetails(listingId: string): void {
this.router.navigate(['/details-business', listingId]);
} }
getDaysListed(listing: BusinessListing) { getDaysListed(listing: BusinessListing) {
return dayjs().diff(listing.created, 'day'); return dayjs().diff(listing.created, 'day');
} }
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
} }

View File

@ -1,16 +1,7 @@
<div class="flex flex-col md:flex-row"> <div class="container mx-auto px-4 py-8">
<!-- 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">
<app-search-modal-commercial [isModal]="false"></app-search-modal-commercial>
</div>
<!-- Main Content -->
<div class="w-full p-4">
<div class="container mx-auto">
@if(listings?.length > 0) {
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@for (listing of listings; track listing.id) { @for (listing of listings; track listing.id) {
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg overflow-hidden flex flex-col h-full"> <div class="bg-white rounded-lg shadow-md overflow-hidden flex flex-col h-full">
@if (listing.imageOrder?.length>0){ @if (listing.imageOrder?.length>0){
<img src="{{ env.imageBaseUrl }}/pictures/property/{{ listing.imagePath }}/{{ listing.serialId }}/{{ listing.imageOrder[0] }}" alt="Image" class="w-full h-48 object-cover" /> <img src="{{ env.imageBaseUrl }}/pictures/property/{{ listing.imagePath }}/{{ listing.serialId }}/{{ listing.imageOrder[0] }}" alt="Image" class="w-full h-48 object-cover" />
} @else { } @else {
@ -42,65 +33,7 @@
</div> </div>
} }
</div> </div>
} @else if (listings?.length === 0){
<div class="w-full flex items-center flex-wrap justify-center gap-10">
<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">
<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"
fill="#EEF2FF"
/>
<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"
fill="white"
stroke="#E5E7EB"
/>
<ellipse cx="80.0618" cy="81" rx="28.0342" ry="28.0342" fill="#EEF2FF" />
<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"
stroke="#E5E7EB"
/>
<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
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="#A5B4FC"
stroke="#818CF8"
/>
<path
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"
/>
<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"
fill="#4F46E5"
/>
<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"
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" />
<rect x="38.3522" y="29.7179" width="13.6752" height="2.73504" rx="1.36752" fill="#4F46E5" />
<circle cx="56.13" cy="31.0854" r="1.36752" 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" />
</svg>
<div>
<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>
<div class="flex gap-3">
<button (click)="clearAllFilters()" class="w-full px-3 py-2 rounded-full border border-gray-300 text-gray-900 text-xs font-semibold leading-4">Clear Filter</button>
</div>
</div>
</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-blue-500 text-white p-3 rounded-full shadow-lg z-20"><i class="fas fa-filter"></i> Filter</button>
</div> </div>
@if(pageCount>1){
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator>
}

View File

@ -1,165 +1,89 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component } 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, untilDestroyed } from '@ngneat/until-destroy';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { Subject, takeUntil } from 'rxjs'; import { CommercialPropertyListing } from '../../../../../../bizmatch-server/src/models/db.model';
import { CommercialPropertyListing, SortByOptions } from '../../../../../../bizmatch-server/src/models/db.model';
import { CommercialPropertyListingCriteria, LISTINGS_PER_PAGE, ResponseCommercialPropertyListingArray } from '../../../../../../bizmatch-server/src/models/main.model'; import { CommercialPropertyListingCriteria, LISTINGS_PER_PAGE, ResponseCommercialPropertyListingArray } from '../../../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
import { PaginatorComponent } from '../../../components/paginator/paginator.component'; import { PaginatorComponent } from '../../../components/paginator/paginator.component';
import { ModalService } from '../../../components/search-modal/modal.service';
import { SearchModalCommercialComponent } from '../../../components/search-modal/search-modal-commercial.component';
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 { getCriteriaProxy } 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], imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent],
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'],
}) })
export class CommercialPropertyListingsComponent implements OnInit, OnDestroy { export class CommercialPropertyListingsComponent {
private destroy$ = new Subject<void>();
// Component properties
environment = environment; environment = environment;
env = environment; listings: Array<CommercialPropertyListing>;
listings: Array<CommercialPropertyListing> = []; filteredListings: Array<CommercialPropertyListing>;
filteredListings: Array<CommercialPropertyListing> = [];
criteria: CommercialPropertyListingCriteria; criteria: CommercialPropertyListingCriteria;
sortBy: SortByOptions | null = null; realEstateChecked: boolean;
first: number = 0;
// Pagination rows: number = 12;
totalRecords = 0; maxPrice: string;
minPrice: string;
type: string;
statesSet = new Set();
state: string;
totalRecords: number = 0;
env = environment;
page = 1; page = 1;
pageCount = 1; pageCount = 1;
first = 0;
rows = LISTINGS_PER_PAGE;
// UI state
ts = new Date().getTime(); ts = new Date().getTime();
constructor( constructor(
public selectOptions: SelectOptionsService, public selectOptions: SelectOptionsService,
private listingsService: ListingsService, private listingsService: ListingsService,
private activatedRoute: ActivatedRoute,
private router: Router, private router: Router,
private cdRef: ChangeDetectorRef, private cdRef: ChangeDetectorRef,
private imageService: ImageService, private imageService: ImageService,
private searchService: SearchService,
private modalService: ModalService,
private filterStateService: FilterStateService,
private route: ActivatedRoute, private route: ActivatedRoute,
) {} private searchService: SearchService,
) {
ngOnInit(): void { this.criteria = getCriteriaProxy('commercialPropertyListings', this) as CommercialPropertyListingCriteria;
// Subscribe to state changes this.init();
this.filterStateService this.searchService.currentCriteria.pipe(untilDestroyed(this)).subscribe(criteria => {
.getState$('commercialPropertyListings') if (criteria && criteria.criteriaType === 'commercialPropertyListings') {
.pipe(takeUntil(this.destroy$)) this.criteria = criteria as CommercialPropertyListingCriteria;
.subscribe(state => {
this.criteria = state.criteria;
this.sortBy = state.sortBy;
// 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 => {
if (type === 'commercialPropertyListings') {
this.search(); this.search();
} }
}); });
} }
async ngOnInit() {}
async search(): Promise<void> { async init() {
try { this.search();
// Perform search }
const listingResponse = await this.listingsService.getListings('commercialProperty'); async search() {
this.listings = (listingResponse as ResponseCommercialPropertyListingArray).results; const listingReponse = await this.listingsService.getListings(this.criteria, 'commercialProperty');
this.totalRecords = (listingResponse as ResponseCommercialPropertyListingArray).totalCount; this.listings = (<ResponseCommercialPropertyListingArray>listingReponse).results;
this.pageCount = Math.ceil(this.totalRecords / LISTINGS_PER_PAGE); this.totalRecords = (<ResponseCommercialPropertyListingArray>listingReponse).totalCount;
this.page = this.criteria.page || 1; this.pageCount = this.totalRecords % LISTINGS_PER_PAGE === 0 ? this.totalRecords / LISTINGS_PER_PAGE : Math.floor(this.totalRecords / LISTINGS_PER_PAGE) + 1;
this.page = this.criteria.page ? this.criteria.page : 1;
// Update view
this.cdRef.markForCheck(); this.cdRef.markForCheck();
this.cdRef.detectChanges(); this.cdRef.detectChanges();
} catch (error) {
console.error('Search error:', error);
// Handle error appropriately
this.listings = [];
this.totalRecords = 0;
this.cdRef.markForCheck();
} }
onPageChange(page: any) {
this.criteria.start = (page - 1) * LISTINGS_PER_PAGE;
this.criteria.length = LISTINGS_PER_PAGE;
this.criteria.page = page;
this.search();
} }
reset() {
onPageChange(page: number): void { this.criteria.title = null;
// Update only pagination properties
this.filterStateService.updateCriteria('commercialPropertyListings', {
page: page,
start: (page - 1) * LISTINGS_PER_PAGE,
length: LISTINGS_PER_PAGE,
});
// Search will be triggered automatically through state subscription
} }
getTS() {
clearAllFilters(): void {
// Reset criteria but keep sortBy
this.filterStateService.clearFilters('commercialPropertyListings');
// Search will be triggered automatically through state subscription
}
async openFilterModal(): Promise<void> {
// Open modal with current criteria
const currentCriteria = this.filterStateService.getCriteria('commercialPropertyListings');
const modalResult = await this.modalService.showModal(currentCriteria);
if (modalResult.accepted) {
// Modal accepted changes - state is updated by modal
// Search will be triggered automatically through state subscription
} else {
// Modal was cancelled - no action needed
}
}
// Helper methods for template
getTS(): number {
return new Date().getTime(); return new Date().getTime();
} }
getDaysListed(listing: CommercialPropertyListing) {
getDaysListed(listing: CommercialPropertyListing): number {
return dayjs().diff(listing.created, 'day'); return dayjs().diff(listing.created, 'day');
} }
getListingImage(listing: CommercialPropertyListing): string {
if (listing.imageOrder?.length > 0) {
return `${this.env.imageBaseUrl}/pictures/property/${listing.imagePath}/${listing.serialId}/${listing.imageOrder[0]}`;
}
return 'assets/images/placeholder_properties.jpg';
}
getListingPrice(listing: CommercialPropertyListing): string {
if (!listing.price) return 'Price on Request';
return `$${listing.price.toLocaleString()}`;
}
getListingLocation(listing: CommercialPropertyListing): string {
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]);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
} }

View File

@ -1,6 +1,6 @@
<div class="container mx-auto p-4"> <div class="container mx-auto p-4">
@if (user){ @if (user){
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg p-6"> <div class="bg-white rounded-lg shadow-md p-6">
<form #accountForm="ngForm" class="space-y-4"> <form #accountForm="ngForm" class="space-y-4">
<h2 class="text-2xl font-bold mb-4">Account Details</h2> <h2 class="text-2xl font-bold mb-4">Account Details</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
@ -16,7 +16,7 @@
<div class="w-20 h-20 w-full rounded-md flex items-center justify-center relative"> <div class="w-20 h-20 w-full rounded-md flex items-center justify-center relative">
@if(user?.hasCompanyLogo){ @if(user?.hasCompanyLogo){
<img src="{{ companyLogoUrl }}" alt="Company logo" class="max-w-full max-h-full" /> <img src="{{ companyLogoUrl }}" alt="Company logo" class="max-w-full max-h-full" />
<div class="absolute top-[-0.5rem] right-[0rem] bg-white rounded-full p-1 drop-shadow-custom-bg hover:cursor-pointer" (click)="deleteConfirm('logo')"> <div class="absolute top-[-0.5rem] right-[0rem] bg-white rounded-full p-1 shadow-md hover:cursor-pointer" (click)="deleteConfirm('logo')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" class="w-4 h-4 text-gray-600"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" class="w-4 h-4 text-gray-600">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
@ -38,7 +38,7 @@
<div class="w-20 h-20 w-full rounded-md flex items-center justify-center relative"> <div class="w-20 h-20 w-full rounded-md flex items-center justify-center relative">
@if(user?.hasProfile){ @if(user?.hasProfile){
<img src="{{ profileUrl }}" alt="Profile picture" class="max-w-full max-h-full" /> <img src="{{ profileUrl }}" alt="Profile picture" class="max-w-full max-h-full" />
<div class="absolute top-[-0.5rem] right-[0rem] bg-white rounded-full p-1 drop-shadow-custom-bg hover:cursor-pointer" (click)="deleteConfirm('profile')"> <div class="absolute top-[-0.5rem] right-[0rem] bg-white rounded-full p-1 shadow-md hover:cursor-pointer" (click)="deleteConfirm('profile')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" class="w-4 h-4 text-gray-600"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" class="w-4 h-4 text-gray-600">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>

View File

@ -1,5 +1,5 @@
<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 shadow-md p-6">
<h1 class="text-2xl font-semibold mb-6">Edit Listing</h1> <h1 class="text-2xl font-semibold mb-6">Edit Listing</h1>
@if(listing){ @if(listing){
<form #listingForm="ngForm" class="space-y-4"> <form #listingForm="ngForm" class="space-y-4">
@ -17,35 +17,107 @@
</ng-select> </ng-select>
</div> </div>
<!-- <div class="mb-4">
<label for="title" class="block text-sm font-bold text-gray-700 mb-1">Title of Listing</label>
<input type="text" id="title" [(ngModel)]="listing.title" name="title" class="w-full p-2 border border-gray-300 rounded-md" />
</div> -->
<div> <div>
<app-validated-input label="Title of Listing" name="title" [(ngModel)]="listing.title"></app-validated-input> <app-validated-input label="Title of Listing" name="title" [(ngModel)]="listing.title"></app-validated-input>
</div> </div>
<!-- <div class="mb-4">
<label for="description" class="block text-sm font-bold text-gray-700 mb-1">Description</label>
<quill-editor [(ngModel)]="listing.description" name="description" [modules]="quillModules"></quill-editor>
</div> -->
<div> <div>
<app-validated-quill label="Description" name="description" [(ngModel)]="listing.description"></app-validated-quill> <app-validated-quill label="Description" name="description" [(ngModel)]="listing.description"></app-validated-quill>
</div> </div>
<!-- <div class="mb-4">
<label for="type" class="block text-sm font-bold text-gray-700 mb-1">Type of business</label>
<ng-select [items]="typesOfBusiness" bindLabel="name" bindValue="value" [(ngModel)]="listing.type" name="type"> </ng-select>
</div> -->
<div> <div>
<app-validated-ng-select label="Type of business" name="type" [(ngModel)]="listing.type" [items]="typesOfBusiness"></app-validated-ng-select> <app-validated-ng-select label="Type of business" name="type" [(ngModel)]="listing.type" [items]="typesOfBusiness"></app-validated-ng-select>
</div> </div>
<!-- <div class="flex mb-4 space-x-4">
<div class="w-1/2">
<label for="state" class="block text-sm font-bold text-gray-700 mb-1">State</label>
<ng-select [items]="selectOptions?.states" bindLabel="name" bindValue="value" [(ngModel)]="listing.state" name="state"> </ng-select>
</div>
<div class="w-1/2">
<label for="city" class="block text-sm font-bold text-gray-700 mb-1">City</label>
<input type="text" id="city" [(ngModel)]="listing.city" name="city" class="w-full p-2 border border-gray-300 rounded-md" />
</div>
</div> -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- <app-validated-ng-select label="State" name="state" [(ngModel)]="listing.location.state" [items]="selectOptions?.states"></app-validated-ng-select>
<app-validated-input label="City" name="city" [(ngModel)]="listing.location.city"></app-validated-input> -->
<!-- <app-validated-city label="Location" name="location" [(ngModel)]="listing.location"></app-validated-city> -->
<app-validated-location label="Location" name="location" [(ngModel)]="listing.location"></app-validated-location> <app-validated-location label="Location" name="location" [(ngModel)]="listing.location"></app-validated-location>
<app-validated-price label="Price" name="price" [(ngModel)]="listing.price"></app-validated-price> <app-validated-price label="Price" name="price" [(ngModel)]="listing.price"></app-validated-price>
</div> </div>
<!-- <div class="flex mb-4 space-x-4">
<div class="w-1/2">
<label for="price" class="block text-sm font-bold text-gray-700 mb-1">Price</label>
<input
type="text"
id="price"
[(ngModel)]="listing.price"
name="price"
class="w-full p-2 border border-gray-300 rounded-md"
[options]="{ prefix: '$', thousands: ',', decimal: '.', precision: 0, align: 'left' }"
currencyMask
/>
</div>
<div class="w-1/2">
<label for="salesRevenue" class="block text-sm font-bold text-gray-700 mb-1">Sales Revenue</label>
<input
type="text"
id="salesRevenue"
[(ngModel)]="listing.salesRevenue"
name="salesRevenue"
class="w-full p-2 border border-gray-300 rounded-md"
[options]="{ prefix: '$', thousands: ',', decimal: '.', precision: 0, align: 'left' }"
currencyMask
/>
</div>
</div> -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<app-validated-price label="Sales Revenue" name="salesRevenue" [(ngModel)]="listing.salesRevenue"></app-validated-price> <app-validated-price label="Sales Revenue" name="salesRevenue" [(ngModel)]="listing.salesRevenue"></app-validated-price>
<app-validated-price label="Cash Flow" name="cashFlow" [(ngModel)]="listing.cashFlow"></app-validated-price> <app-validated-price label="Cash Flow" name="cashFlow" [(ngModel)]="listing.cashFlow"></app-validated-price>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <!-- <div class="mb-4">
<app-validated-price label="Furniture, Fixtures / Equipment Value (FFE)" name="ffe" [(ngModel)]="listing.ffe"></app-validated-price> <label for="cashFlow" class="block text-sm font-bold text-gray-700 mb-1">Cash Flow</label>
<app-validated-price label="Inventory at Cost Value" name="inventory" [(ngModel)]="listing.inventory"></app-validated-price> <input
</div> type="text"
id="cashFlow"
[(ngModel)]="listing.cashFlow"
name="cashFlow"
class="w-full p-2 border border-gray-300 rounded-md"
[options]="{ prefix: '$', thousands: ',', decimal: '.', precision: 0, align: 'left' }"
currencyMask
/>
</div> -->
<!-- <div>
</div> -->
<!-- <div class="flex mb-4 space-x-4">
<div class="w-1/2">
<label for="established" class="block text-sm font-bold text-gray-700 mb-1">Years Established Since</label>
<input type="number" id="established" [(ngModel)]="listing.established" name="established" class="w-full p-2 border border-gray-300 rounded-md" />
</div>
<div class="w-1/2">
<label for="employees" class="block text-sm font-bold text-gray-700 mb-1">Employees</label>
<input type="number" id="employees" [(ngModel)]="listing.employees" name="employees" class="w-full p-2 border border-gray-300 rounded-md" />
</div>
</div> -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<app-validated-input label="Years established" name="established" [(ngModel)]="listing.established" mask="0000" kind="number"></app-validated-input> <app-validated-input label="Established In" name="established" [(ngModel)]="listing.established" mask="0000" kind="number"></app-validated-input>
<app-validated-input label="Employees" name="employees" [(ngModel)]="listing.employees" mask="0000" kind="number"></app-validated-input> <app-validated-input label="Employees" name="employees" [(ngModel)]="listing.employees" mask="0000" kind="number"></app-validated-input>
</div> </div>
@ -85,23 +157,48 @@
</div> </div>
</div> </div>
<!-- <div class="mb-4">
<label for="supportAndTraining" class="block text-sm font-bold text-gray-700 mb-1">Support & Training</label>
<input type="text" id="supportAndTraining" [(ngModel)]="listing.supportAndTraining" name="supportAndTraining" class="w-full p-2 border border-gray-300 rounded-md" />
</div>
<div class="mb-4">
<label for="reasonForSale" class="block text-sm font-bold text-gray-700 mb-1">Reason for Sale</label>
<input type="text" id="reasonForSale" [(ngModel)]="listing.reasonForSale" name="reasonForSale" class="w-full p-2 border border-gray-300 rounded-md" />
</div>-->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<app-validated-input label="Support & Training" name="supportAndTraining" [(ngModel)]="listing.supportAndTraining"></app-validated-input> <app-validated-input label="Support & Training" name="supportAndTraining" [(ngModel)]="listing.supportAndTraining"></app-validated-input>
<app-validated-input label="Reason for Sale" name="reasonForSale" [(ngModel)]="listing.reasonForSale"></app-validated-input> <app-validated-input label="Reason for Sale" name="reasonForSale" [(ngModel)]="listing.reasonForSale"></app-validated-input>
</div> </div>
<!-- <div class="flex mb-4 space-x-4">
<div class="w-1/2">
<label for="brokerLicencing" class="block text-sm font-bold text-gray-700 mb-1">Broker Licensing</label>
<input type="text" id="brokerLicencing" [(ngModel)]="listing.brokerLicencing" name="brokerLicencing" class="w-full p-2 border border-gray-300 rounded-md" />
</div>
<div class="w-1/2">
<label for="internalListingNumber" class="block text-sm font-bold text-gray-700 mb-1">Internal Listing Number</label>
<input type="number" id="internalListingNumber" [(ngModel)]="listing.internalListingNumber" name="internalListingNumber" class="w-full p-2 border border-gray-300 rounded-md" />
</div>
</div> -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<!-- <app-validated-input label="Broker Licensing" name="brokerLicencing" [(ngModel)]="listing.brokerLicencing"></app-validated-input> -->
<label for="brokerLicencing" class="block text-sm font-bold text-gray-700 mb-1">Broker Licensing (please maintain your license in your account)</label> <label for="brokerLicencing" class="block text-sm font-bold text-gray-700 mb-1">Broker Licensing (please maintain your license in your account)</label>
<!-- @if(listingUser){ -->
<ng-select [(ngModel)]="listing.brokerLicencing" name="brokerLicencing"> <ng-select [(ngModel)]="listing.brokerLicencing" name="brokerLicencing">
@for (licensedIn of listingUser?.licensedIn; track listingUser?.licensedIn) { @for (licensedIn of listingUser?.licensedIn; track listingUser?.licensedIn) {
<ng-option [value]="licensedIn.registerNo">{{ licensedIn.state }} {{ licensedIn.registerNo }}</ng-option> <ng-option [value]="licensedIn.registerNo">{{ licensedIn.state }} {{ licensedIn.registerNo }}</ng-option>
} }
</ng-select> </ng-select>
</div> </div>
<!-- } -->
<app-validated-input label="Internal Listing Number" name="internalListingNumber" [(ngModel)]="listing.internalListingNumber" kind="number" mask="00000000000000000000"></app-validated-input> <app-validated-input label="Internal Listing Number" name="internalListingNumber" [(ngModel)]="listing.internalListingNumber" kind="number" mask="00000000000000000000"></app-validated-input>
</div> </div>
<!-- <div class="mb-4">
<label for="internals" class="block text-sm font-bold text-gray-700 mb-1">Internal Notes (Will not be shown on the listing, for your records only.)</label>
<textarea id="internals" [(ngModel)]="listing.internals" name="internals" class="w-full p-2 border border-gray-300 rounded-md" rows="3"></textarea>
</div> -->
<div> <div>
<app-validated-textarea label="Internal Notes (Will not be shown on the listing, for your records only.)" name="internals" [(ngModel)]="listing.internals"></app-validated-textarea> <app-validated-textarea label="Internal Notes (Will not be shown on the listing, for your records only.)" name="internals" [(ngModel)]="listing.internals"></app-validated-textarea>
</div> </div>
@ -124,3 +221,162 @@
} }
</div> </div>
</div> </div>
<!-- <div class="surface-ground px-4 py-8 md:px-6 lg:px-8">
<div class="p-fluid flex flex-column lg:flex-row">
<menu-account></menu-account>
<p-toast></p-toast>
<div *ngIf="listing" class="surface-card p-5 shadow-2 border-round flex-auto">
<div class="text-900 font-semibold text-lg mt-3">{{ mode === 'create' ? 'New' : 'Edit' }} Listing</div>
<p-divider></p-divider>
<div class="flex gap-5 flex-column-reverse md:flex-row">
<div class="flex-auto p-fluid">
<div class="mb-4">
<label for="listingCategory" class="block font-medium text-900 mb-2">Listing category</label>
<p-dropdown
id="listingCategory"
[options]="selectOptions?.listingCategories"
[ngModel]="listingsCategory"
optionLabel="name"
optionValue="value"
(ngModelChange)="changeListingCategory($event)"
placeholder="Listing category"
[disabled]="mode === 'edit'"
[style]="{ width: '100%' }"
></p-dropdown>
</div>
<div class="mb-4">
<label for="email" class="block font-medium text-900 mb-2">Title of Listing</label>
<input id="email" type="text" pInputText [(ngModel)]="listing.title" />
</div>
<div>
<div class="mb-4">
<label for="description" class="block font-medium text-900 mb-2">Description</label>
<p-editor [(ngModel)]="listing.description" [style]="{ height: '320px' }" [modules]="editorModules">
<ng-template pTemplate="header"></ng-template>
</p-editor>
</div>
</div>
<div class="mb-4">
<label for="type" class="block font-medium text-900 mb-2">Type of business</label>
<p-dropdown
id="type"
[filter]="true"
filterBy="name"
[options]="typesOfBusiness"
[(ngModel)]="listing.type"
optionLabel="name"
optionValue="value"
[showClear]="true"
placeholder="Type of business"
[style]="{ width: '100%' }"
></p-dropdown>
</div>
<div class="grid">
<div class="mb-4 col-12 md:col-6">
<label for="listingCategory" class="block font-medium text-900 mb-2">State</label>
<p-dropdown
id="listingCategory"
[filter]="true"
filterBy="name"
[options]="selectOptions?.states"
[(ngModel)]="listing.state"
optionLabel="name"
optionValue="value"
[showClear]="true"
placeholder="State"
[style]="{ width: '100%' }"
></p-dropdown>
</div>
<div class="mb-4 col-12 md:col-6">
<label for="listingCategory" class="block font-medium text-900 mb-2">City</label>
<p-autoComplete [(ngModel)]="listing.city" [suggestions]="suggestions" (completeMethod)="search($event)"></p-autoComplete>
</div>
</div>
</div>
</div>
<p-divider></p-divider>
<div class="flex gap-5 flex-column-reverse md:flex-row">
<div class="flex-auto p-fluid">
<div class="grid">
<div class="mb-4 col-12 md:col-6">
<label for="price" class="block font-medium text-900 mb-2">Price</label>
<p-inputNumber mode="currency" currency="USD" locale="en-US" inputId="price" [(ngModel)]="listing.price"></p-inputNumber>
</div>
<div class="mb-4 col-12 md:col-6">
<label for="salesRevenue" class="block font-medium text-900 mb-2">Sales Revenue</label>
<p-inputNumber mode="currency" currency="USD" inputId="salesRevenue" [(ngModel)]="listing.salesRevenue"></p-inputNumber>
</div>
</div>
<div class="grid">
<div class="mb-4 col-12 md:col-6">
<label for="cashFlow" class="block font-medium text-900 mb-2">Cash Flow</label>
<p-inputNumber mode="currency" currency="USD" inputId="cashFlow" [(ngModel)]="listing.cashFlow"></p-inputNumber>
</div>
</div>
<div class="grid">
<div class="mb-4 col-12 md:col-6">
<label for="established" class="block font-medium text-900 mb-2">Years Established Since</label>
<p-inputNumber mode="decimal" inputId="established" [(ngModel)]="listing.established" [useGrouping]="false"></p-inputNumber>
</div>
<div class="mb-4 col-12 md:col-6">
<label for="employees" class="block font-medium text-900 mb-2">Employees</label>
<p-inputNumber mode="decimal" inputId="employees" [(ngModel)]="listing.employees" [useGrouping]="false"></p-inputNumber>
</div>
</div>
<div class="grid">
<div class="mb-4 col-12 md:col-4">
<p-checkbox [binary]="true" [(ngModel)]="listing.realEstateIncluded"></p-checkbox>
<span class="ml-2 text-900">Real Estate Included</span>
</div>
<div class="mb-4 col-12 md:col-4">
<p-checkbox [binary]="true" [(ngModel)]="listing.leasedLocation"></p-checkbox>
<span class="ml-2 text-900">Leased Location</span>
</div>
<div class="mb-4 col-12 md:col-4">
<p-checkbox [binary]="true" [(ngModel)]="listing.franchiseResale"></p-checkbox>
<span class="ml-2 text-900">Franchise Re-Sale</span>
</div>
</div>
<div class="mb-4">
<label for="supportAndTraining" class="block font-medium text-900 mb-2">Support & Training</label>
<input id="supportAndTraining" type="text" pInputText [(ngModel)]="listing.supportAndTraining" />
</div>
<div class="mb-4">
<label for="reasonForSale" class="block font-medium text-900 mb-2">Reason for Sale</label>
<textarea id="reasonForSale" type="text" pInputTextarea rows="5" [autoResize]="true" [(ngModel)]="listing.reasonForSale"></textarea>
</div>
<div class="grid">
<div class="mb-4 col-12 md:col-6">
<label for="brokerLicensing" class="block font-medium text-900 mb-2">Broker Licensing</label>
<input id="brokerLicensing" type="text" pInputText [(ngModel)]="listing.brokerLicencing" />
</div>
<div class="mb-4 col-12 md:col-6">
<label for="internalListingNumber" class="block font-medium text-900 mb-2">Internal Listing Number</label>
<p-inputNumber mode="decimal" inputId="internalListingNumber" type="text" [(ngModel)]="listing.internalListingNumber" [useGrouping]="false"></p-inputNumber>
</div>
</div>
<div class="mb-4">
<label for="internalListing" class="block font-medium text-900 mb-2">Internal Notes (Will not be shown on the listing, for your records only.)</label>
<input id="internalListing" type="text" pInputText [(ngModel)]="listing.internals" />
</div>
<div class="grid">
<div class="mb-4 col-12 md:col-6">
<p-inputSwitch inputId="draft" [(ngModel)]="listing.draft"></p-inputSwitch>
<span class="ml-2 text-900 absolute translate-y-5">Draft Mode (Will not be shown as public listing)</span>
</div>
</div>
<div>
@if (mode==='create'){
<button pButton pRipple label="Post Listing" class="w-auto" (click)="save()"></button>
} @else {
<button pButton pRipple label="Update Listing" class="w-auto" (click)="save()"></button>
}
</div>
</div>
</div>
</div>
</div>
</div>
<p-toast></p-toast>
<p-confirmDialog></p-confirmDialog> -->

View File

@ -1,5 +1,5 @@
<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 shadow-md p-6">
<h1 class="text-2xl font-semibold mb-6">Edit Listing</h1> <h1 class="text-2xl font-semibold mb-6">Edit Listing</h1>
@if (listing){ @if (listing){
<form #listingForm="ngForm" class="space-y-4"> <form #listingForm="ngForm" class="space-y-4">
@ -18,23 +18,81 @@
</ng-select> </ng-select>
</div> </div>
<!-- <div class="mb-4">
<label for="title" class="block text-sm font-bold text-gray-700 mb-1">Title of Listing</label>
<input type="text" id="title" [(ngModel)]="listing.title" name="title" class="w-full p-2 border border-gray-300 rounded-md" />
</div> -->
<div> <div>
<app-validated-input label="Title of Listing" name="title" [(ngModel)]="listing.title"></app-validated-input> <app-validated-input label="Title of Listing" name="title" [(ngModel)]="listing.title"></app-validated-input>
</div> </div>
<div> <div>
<!-- <label for="description" class="block text-sm font-bold text-gray-700 mb-1">Description</label>
<quill-editor [(ngModel)]="listing.description" name="description" [modules]="quillModules"></quill-editor> -->
<app-validated-quill label="Description" name="description" [(ngModel)]="listing.description"></app-validated-quill> <app-validated-quill label="Description" name="description" [(ngModel)]="listing.description"></app-validated-quill>
</div> </div>
<!-- <div>
<label for="type" class="block text-sm font-bold text-gray-700 mb-1">Property Category</label>
<ng-select [items]="typesOfCommercialProperty" bindLabel="name" bindValue="value" [(ngModel)]="listing.type" name="type"> </ng-select>
</div> -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<app-validated-ng-select label="Property Category" name="type" [(ngModel)]="listing.type" [items]="typesOfCommercialProperty"></app-validated-ng-select> <app-validated-ng-select label="Property Category" name="type" [(ngModel)]="listing.type" [items]="typesOfCommercialProperty"></app-validated-ng-select>
<!-- <app-validated-city label="Location" name="location" [(ngModel)]="listing.location"></app-validated-city> -->
<app-validated-location label="Location" name="location" [(ngModel)]="listing.location"></app-validated-location> <app-validated-location label="Location" name="location" [(ngModel)]="listing.location"></app-validated-location>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<app-validated-price label="Price" name="price" [(ngModel)]="listing.price"></app-validated-price> <!-- <div class="flex mb-4 space-x-4">
<app-validated-input label="Internal Listing Number" name="internalListingNumber" [(ngModel)]="listing.internalListingNumber" kind="number" mask="00000000000000000000"></app-validated-input> <div class="w-1/2">
<label for="state" class="block text-sm font-bold text-gray-700 mb-1">State</label>
<ng-select [items]="selectOptions?.states" bindLabel="name" bindValue="value" [(ngModel)]="listing.state" name="state"> </ng-select>
</div> </div>
<div class="container mx-auto pt-2"> <div class="w-1/2">
<label for="city" class="block text-sm font-bold text-gray-700 mb-1">City</label>
<input type="text" id="city" [(ngModel)]="listing.city" name="city" class="w-full p-2 border border-gray-300 rounded-md" />
</div>
</div> -->
<!-- <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> -->
<!-- <app-validated-ng-select label="State" name="state" [(ngModel)]="listing.location.state" [items]="selectOptions?.states"></app-validated-ng-select>
<app-validated-input label="City" name="city" [(ngModel)]="listing.location.city"></app-validated-input> -->
<!-- </div> -->
<!-- <div class="flex mb-4 space-x-4">
<div class="w-1/2">
<label for="zipCode" class="block text-sm font-bold text-gray-700 mb-1">Zip Code</label>
<input type="text" id="zipCode" [(ngModel)]="listing.zipCode" name="zipCode" class="w-full p-2 border border-gray-300 rounded-md" />
</div>
<div class="w-1/2">
<label for="county" class="block text-sm font-bold text-gray-700 mb-1">County</label>
<input type="text" id="county" [(ngModel)]="listing.county" name="county" class="w-full p-2 border border-gray-300 rounded-md" />
</div>
</div> -->
<!-- <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<app-validated-input label="Zip Code" name="zipCode" [(ngModel)]="listing.zipCode"></app-validated-input>
<app-validated-input label="County" name="county" [(ngModel)]="listing.county"></app-validated-input>
</div> -->
<!-- <div class="mb-4">
<label for="internals" class="block text-sm font-bold text-gray-700 mb-1">Internal Notes (Will not be shown on the listing, for your records only.)</label>
<textarea id="internals" [(ngModel)]="listing.internals" name="internals" class="w-full p-2 border border-gray-300 rounded-md" rows="3"></textarea>
</div> -->
<!-- <div class="flex items-center mb-4"> -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- <div class="w-1/2">
<label for="price" class="block text-sm font-bold text-gray-700 mb-1">Price</label>
<input
type="text"
id="price"
[(ngModel)]="listing.price"
name="price"
class="w-full p-2 border border-gray-300 rounded-md"
[options]="{ prefix: '$', thousands: ',', decimal: '.', precision: 0, align: 'left' }"
currencyMask
/>
</div> -->
<app-validated-price label="Price" name="price" [(ngModel)]="listing.price"></app-validated-price>
<div class="flex justify-center"> <div class="flex justify-center">
<label class="flex items-center cursor-pointer"> <label class="flex items-center cursor-pointer">
<div class="relative"> <div class="relative">
@ -45,8 +103,36 @@
</label> </label>
</div> </div>
</div> </div>
<!-- <div class="container mx-auto p-4">
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
@for (image of listing.imageOrder; track listing.imageOrder) {
<div class="relative aspect-video cursor-move">
<img src="{{ env.imageBaseUrl }}/pictures/property/{{ listing.imagePath }}/{{ listing.serialId }}/{{ image }}?_ts={{ ts }}" [alt]="image" class="w-full h-full object-cover rounded-lg shadow-md" />
<div class="absolute top-2 right-2 bg-white rounded-full p-1 shadow-md">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" class="w-4 h-4 text-gray-600">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
</div>
}
</div>
</div> -->
<div class="container mx-auto pt-2"> <div class="container mx-auto pt-2">
<!-- <div class="grid-container"> -->
<!-- @for (image of listing.imageOrder; track image) {
<div cdkDrag class="grid-item">
<div class="image-box">
<img [src]="getImageUrl(image)" [alt]="image" class="w-full h-full object-cover rounded-lg shadow-md" />
<div class="absolute top-2 right-2 bg-white rounded-full p-1 shadow-md">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" class="w-4 h-4 text-gray-600">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
</div>
</div>
} -->
<app-drag-drop-mixed [listing]="listing" [ts]="ts" (imageOrderChanged)="imageOrderChanged($event)" (imageToDelete)="deleteConfirm($event)"></app-drag-drop-mixed> <app-drag-drop-mixed [listing]="listing" [ts]="ts" (imageOrderChanged)="imageOrderChanged($event)" (imageToDelete)="deleteConfirm($event)"></app-drag-drop-mixed>
<!-- </div> -->
</div> </div>
<div class="bg-white px-4 pb-4 rounded-lg shadow"> <div class="bg-white px-4 pb-4 rounded-lg shadow">
@ -64,6 +150,7 @@
Upload Upload
</button> </button>
} }
<!-- <input type="file" #fileInput style="display: none" (change)="fileChangeEvent($event)" accept="image/*" /> -->
</div> </div>
@if (mode==='create'){ @if (mode==='create'){
<button (click)="save()" class="bg-blue-500 text-white px-4 py-2 mt-3 rounded-md hover:bg-blue-600">Post Listing</button> <button (click)="save()" class="bg-blue-500 text-white px-4 py-2 mt-3 rounded-md hover:bg-blue-600">Post Listing</button>

View File

@ -150,7 +150,7 @@ export class EditCommercialPropertyListingComponent {
const email = keycloakUser.email; const email = keycloakUser.email;
this.user = await this.userService.getByMail(email); this.user = await this.userService.getByMail(email);
this.listingCategories = this.selectOptions.listingCategories this.listingCategories = this.selectOptions.listingCategories
.filter(lc => lc.value === 'commercialProperty' || this.user.customerType === 'professional') .filter(lc => lc.value === 'commercialProperty' || (this.user.customerSubType === 'broker' && lc.value === 'business'))
.map(e => { .map(e => {
return { name: e.name, value: e.value }; return { name: e.name, value: e.value };
}); });

View File

@ -1,5 +1,5 @@
<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 shadow-md p-6">
<h2 class="text-2xl font-semibold mb-6">Contact Us</h2> <h2 class="text-2xl font-semibold mb-6">Contact Us</h2>
<form #contactForm="ngForm" class="space-y-4"> <form #contactForm="ngForm" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">

View File

@ -1,10 +1,10 @@
<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 shadow-md p-6">
<h1 class="text-2xl font-bold md:mb-4">My Favorites</h1> <h1 class="text-2xl font-bold md:mb-4">My Favorites</h1>
<!-- Desktop view --> <!-- Desktop view -->
<div class="hidden md:block"> <div class="hidden md:block">
<table class="w-full bg-white drop-shadow-inner-faint rounded-lg overflow-hidden"> <table class="w-full bg-white shadow-md rounded-lg overflow-hidden">
<thead class="bg-gray-100"> <thead class="bg-gray-100">
<tr> <tr>
<th class="py-2 px-4 text-left">Title</th> <th class="py-2 px-4 text-left">Title</th>
@ -44,7 +44,7 @@
<!-- 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 shadow-md 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>

View File

@ -1,88 +1,26 @@
<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 shadow-md 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 bg-white shadow-md rounded-lg overflow-hidden">
<colgroup>
<col class="w-auto" />
<!-- Title: restliche Breite -->
<col class="w-40" />
<!-- Category -->
<col class="w-60" />
<!-- Located in -->
<col class="w-32" />
<!-- Price -->
<col class="w-28" />
<!-- Internal # -->
<col class="w-40" />
<!-- Publication Status -->
<col class="w-36" />
<!-- Actions -->
</colgroup>
<thead class="bg-gray-100"> <thead class="bg-gray-100">
<!-- 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">Actions</th>
<th class="py-2 px-4 text-left">Publication Status</th>
<th class="py-2 px-4 text-left whitespace-nowrap">Actions</th>
</tr>
<!-- Filter row (zwischen Header und Inhalt) -->
<tr class="bg-white border-b">
<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()" />
</th>
<!-- Category Filter -->
<th class="py-2 px-4">
<select class="w-full border rounded px-2 py-1" [(ngModel)]="filters.category" (change)="applyFilters()">
<option value="">All</option>
<option value="business">Business</option>
<option value="commercialProperty">Commercial Property</option>
</select>
</th>
<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()" />
</th>
<th class="py-2 px-4">
<!-- Preis nicht gefiltert, daher leer -->
</th>
<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()" />
</th>
<th class="py-2 px-4">
<select class="w-full border rounded px-2 py-1" [(ngModel)]="filters.status" (change)="applyFilters()">
<option value="">All</option>
<option value="published">Published</option>
<option value="draft">Draft</option>
</select>
</th>
<th class="py-2 px-4">
<button class="text-sm underline" (click)="clearFilters()">Clear</button>
</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.state }}</td>
<td class="py-2 px-4">${{ listing.price.toLocaleString() }}</td> <td class="py-2 px-4">${{ listing.price.toLocaleString() }}</td>
<td class="py-2 px-4 flex justify-center">
{{ listing.internalListingNumber ?? '—' }}
</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">
{{ listing.draft ? 'Draft' : 'Published' }}
</span>
</td>
<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">
@ -113,33 +51,11 @@
<!-- Mobile view --> <!-- Mobile view -->
<div class="md:hidden"> <div class="md:hidden">
<!-- Mobile Filter --> <div *ngFor="let listing of myListings" class="bg-white shadow-md rounded-lg p-4 mb-4">
<div class="bg-white drop-shadow-inner-faint rounded-lg p-4 mb-4 border">
<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="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()" />
<select class="w-full border rounded px-3 py-2" [(ngModel)]="filters.status" (change)="applyFilters()">
<option value="">All</option>
<option value="published">Published</option>
<option value="draft">Draft</option>
</select>
<button class="text-sm underline justify-self-start" (click)="clearFilters()">Clear</button>
</div>
</div>
<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>
<div class="flex items-center gap-2 mb-2">
<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">
{{ listing.draft ? 'Draft' : 'Published' }}
</span>
</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]">
@ -166,7 +82,20 @@
</div> </div>
</div> </div>
</div> </div>
<!-- <div class="flex items-center justify-between mt-4">
<p class="text-sm text-gray-600">Showing 1 to 2 of 2 entries</p>
<div class="flex items-center">
<button class="px-2 py-1 border rounded-l-md bg-gray-100">&lt;&lt;</button>
<button class="px-2 py-1 border-t border-b bg-gray-100">&lt;</button>
<button class="px-2 py-1 border bg-blue-500 text-white">1</button>
<button class="px-2 py-1 border-t border-b bg-gray-100">&gt;</button>
<button class="px-2 py-1 border rounded-r-md bg-gray-100">&gt;&gt;</button>
<select class="ml-2 border rounded-md px-2 py-1">
<option>10</option>
</select>
</div>
</div> -->
</div> </div>
</div> </div>
<app-confirmation></app-confirmation> <app-confirmation></app-confirmation>

View File

@ -21,22 +21,9 @@ import { map2User } from '../../../utils/utils';
styleUrl: './my-listing.component.scss', styleUrl: './my-listing.component.scss',
}) })
export class MyListingComponent { export class MyListingComponent {
// Vollständige, ungefilterte Daten listings: Array<ListingType> = []; //dataListings as unknown as Array<BusinessListing>;
listings: Array<ListingType> = []; myListings: Array<ListingType>;
// Aktuell angezeigte (gefilterte) Daten
myListings: Array<ListingType> = [];
user: User; user: User;
// VERY small filter state
filters = {
title: '',
internalListingNumber: '',
location: '',
status: '' as '' | 'published' | 'draft',
category: '' as '' | 'business' | 'commercialProperty', // <── NEU
};
constructor( constructor(
public userService: UserService, public userService: UserService,
private listingsService: ListingsService, private listingsService: ListingsService,
@ -46,64 +33,23 @@ export class MyListingComponent {
private confirmationService: ConfirmationService, private confirmationService: ConfirmationService,
private authService: AuthService, private authService: AuthService,
) {} ) {}
async ngOnInit() { async ngOnInit() {
const token = await this.authService.getToken(); const token = await this.authService.getToken();
const keycloakUser = map2User(token); const keycloakUser = map2User(token);
const email = keycloakUser.email; const email = keycloakUser.email;
this.user = await this.userService.getByMail(email); this.user = await this.userService.getByMail(email);
const result = await Promise.all([await this.listingsService.getListingsByEmail(this.user.email, 'business'), await this.listingsService.getListingsByEmail(this.user.email, 'commercialProperty')]);
const result = await Promise.all([this.listingsService.getListingsByEmail(this.user.email, 'business'), this.listingsService.getListingsByEmail(this.user.email, 'commercialProperty')]); this.myListings = [...result[0], ...result[1]];
this.listings = [...result[0], ...result[1]];
this.myListings = this.listings;
}
private normalize(s: string | undefined | null): string {
return (s ?? '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ') // Kommas, Bindestriche etc. neutralisieren
.trim()
.replace(/\s+/g, ' '); // Mehrfach-Spaces zu einem Space
}
applyFilters() {
const titleQ = this.normalize(this.filters.title);
const locQ = this.normalize(this.filters.location);
const intQ = this.normalize(this.filters.internalListingNumber);
const catQ = this.filters.category; // <── NEU
const status = this.filters.status;
this.myListings = this.listings.filter(l => {
const okTitle = !titleQ || this.normalize(l.title).includes(titleQ);
const locStr = this.normalize(`${l.location?.name ? l.location.name : l.location?.county} ${l.location?.state}`);
const okLoc = !locQ || locStr.includes(locQ);
const ilnStr = this.normalize((l as any).internalListingNumber?.toString());
const okInt = !intQ || ilnStr.includes(intQ);
const okCat = !catQ || l.listingsCategory === catQ; // <── NEU
const isDraft = !!(l as any).draft;
const okStatus = !status || (status === 'published' && !isDraft) || (status === 'draft' && isDraft);
return okTitle && okLoc && okInt && okCat && okStatus; // <── NEU
});
}
clearFilters() {
this.filters = { title: '', internalListingNumber: '', location: '', status: '', category: '' };
this.myListings = this.listings;
} }
async deleteListing(listing: ListingType) { async deleteListing(listing: ListingType) {
if (listing.listingsCategory === 'business') { if (listing.listingsCategory === 'business') {
await this.listingsService.deleteBusinessListing(listing.id); await this.listingsService.deleteBusinessListing(listing.id);
} else { } else {
await this.listingsService.deleteCommercialPropertyListing(listing.id, (listing as CommercialPropertyListing).imagePath); await this.listingsService.deleteCommercialPropertyListing(listing.id, (<CommercialPropertyListing>listing).imagePath);
} }
const result = await Promise.all([this.listingsService.getListingsByEmail(this.user.email, 'business'), this.listingsService.getListingsByEmail(this.user.email, 'commercialProperty')]); const result = await Promise.all([await this.listingsService.getListingsByEmail(this.user.email, 'business'), await this.listingsService.getListingsByEmail(this.user.email, 'commercialProperty')]);
this.listings = [...result[0], ...result[1]]; this.myListings = [...result[0], ...result[1]];
this.applyFilters(); // Filter beibehalten nach Löschen
} }
async confirm(listing: ListingType) { async confirm(listing: ListingType) {

View File

@ -2,7 +2,7 @@
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { FirebaseApp } from '@angular/fire/app'; import { FirebaseApp } from '@angular/fire/app';
import { GoogleAuthProvider, UserCredential, createUserWithEmailAndPassword, getAuth, signInWithCustomToken, signInWithEmailAndPassword, signInWithPopup } from 'firebase/auth'; import { GoogleAuthProvider, UserCredential, createUserWithEmailAndPassword, getAuth, signInWithEmailAndPassword, signInWithPopup } from 'firebase/auth';
import { BehaviorSubject, Observable, catchError, firstValueFrom, map, of, shareReplay, take, tap } from 'rxjs'; import { BehaviorSubject, Observable, catchError, firstValueFrom, map, of, shareReplay, take, tap } from 'rxjs';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
import { MailService } from './mail.service'; import { MailService } from './mail.service';
@ -159,8 +159,7 @@ 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) {
this.lastCacheTime = now; this.lastCacheTime = now;
let headers = new HttpHeaders().set('X-Hide-Loading', 'true').set('Accept-Language', 'en-US'); this.cachedUserRole$ = this.http.get<{ role: UserRole | null }>(`${environment.apiBaseUrl}/bizmatch/auth/me/role`).pipe(
this.cachedUserRole$ = this.http.get<{ role: UserRole | null }>(`${environment.apiBaseUrl}/bizmatch/auth/me/role`, { headers }).pipe(
map(response => response.role), map(response => response.role),
tap(role => this.userRoleSubject.next(role)), tap(role => this.userRoleSubject.next(role)),
catchError(error => { catchError(error => {
@ -275,31 +274,4 @@ export class AuthService {
return await this.refreshToken(); return await this.refreshToken();
} }
} }
// Add this new method to sign in with a custom token
async signInWithCustomToken(token: string): Promise<void> {
try {
// Sign in to Firebase with the custom token
const userCredential = await signInWithCustomToken(this.auth, token);
// Store the authentication token
if (userCredential.user) {
const idToken = await userCredential.user.getIdToken();
localStorage.setItem('authToken', idToken);
localStorage.setItem('refreshToken', userCredential.user.refreshToken);
if (userCredential.user.photoURL) {
localStorage.setItem('photoURL', userCredential.user.photoURL);
}
// Load user role from the token
this.loadRoleFromToken();
}
return;
} catch (error) {
console.error('Error signing in with custom token:', error);
throw error;
}
}
} }

View File

@ -1,245 +0,0 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { SortByOptions } from '../../../../bizmatch-server/src/models/db.model';
import { BusinessListingCriteria, CommercialPropertyListingCriteria, UserListingCriteria } from '../../../../bizmatch-server/src/models/main.model';
type CriteriaType = BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
type ListingType = 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
interface FilterState {
businessListings: {
criteria: BusinessListingCriteria;
sortBy: SortByOptions | null;
};
commercialPropertyListings: {
criteria: CommercialPropertyListingCriteria;
sortBy: SortByOptions | null;
};
brokerListings: {
criteria: UserListingCriteria;
sortBy: SortByOptions | null;
};
}
@Injectable({
providedIn: 'root',
})
export class FilterStateService {
private state: FilterState;
private stateSubjects: Map<ListingType, BehaviorSubject<any>> = new Map();
constructor() {
// Initialize state from sessionStorage or with defaults
this.state = this.loadStateFromStorage();
// Create BehaviorSubjects for each listing type
this.stateSubjects.set('businessListings', new BehaviorSubject(this.state.businessListings));
this.stateSubjects.set('commercialPropertyListings', new BehaviorSubject(this.state.commercialPropertyListings));
this.stateSubjects.set('brokerListings', new BehaviorSubject(this.state.brokerListings));
}
// Get observable for specific listing type
getState$(type: ListingType): Observable<any> {
return this.stateSubjects.get(type)!.asObservable();
}
// Get current criteria
getCriteria(type: ListingType): CriteriaType {
return { ...this.state[type].criteria };
}
// Update criteria
updateCriteria(type: ListingType, criteria: Partial<CriteriaType>): void {
// Type-safe update basierend auf dem Listing-Typ
if (type === 'businessListings') {
this.state.businessListings.criteria = {
...this.state.businessListings.criteria,
...criteria,
} as BusinessListingCriteria;
} else if (type === 'commercialPropertyListings') {
this.state.commercialPropertyListings.criteria = {
...this.state.commercialPropertyListings.criteria,
...criteria,
} as CommercialPropertyListingCriteria;
} else if (type === 'brokerListings') {
this.state.brokerListings.criteria = {
...this.state.brokerListings.criteria,
...criteria,
} as UserListingCriteria;
}
this.saveToStorage(type);
this.emitState(type);
}
// Set complete criteria (for reset operations)
setCriteria(type: ListingType, criteria: CriteriaType): void {
if (type === 'businessListings') {
this.state.businessListings.criteria = criteria as BusinessListingCriteria;
} else if (type === 'commercialPropertyListings') {
this.state.commercialPropertyListings.criteria = criteria as CommercialPropertyListingCriteria;
} else if (type === 'brokerListings') {
this.state.brokerListings.criteria = criteria as UserListingCriteria;
}
this.saveToStorage(type);
this.emitState(type);
}
// Get current sortBy
getSortBy(type: ListingType): SortByOptions | null {
return this.state[type].sortBy;
}
// Update sortBy
updateSortBy(type: ListingType, sortBy: SortByOptions | null): void {
this.state[type].sortBy = sortBy;
this.saveSortByToStorage(type, sortBy);
this.emitState(type);
}
// Reset criteria to defaults
resetCriteria(type: ListingType): void {
if (type === 'businessListings') {
this.state.businessListings.criteria = this.createEmptyBusinessListingCriteria();
} else if (type === 'commercialPropertyListings') {
this.state.commercialPropertyListings.criteria = this.createEmptyCommercialPropertyListingCriteria();
} else if (type === 'brokerListings') {
this.state.brokerListings.criteria = this.createEmptyUserListingCriteria();
}
this.saveToStorage(type);
this.emitState(type);
}
// Clear all filters but keep sortBy
clearFilters(type: ListingType): void {
const sortBy = this.state[type].sortBy;
this.resetCriteria(type);
this.state[type].sortBy = sortBy;
this.emitState(type);
}
private emitState(type: ListingType): void {
this.stateSubjects.get(type)?.next({ ...this.state[type] });
}
private saveToStorage(type: ListingType): void {
sessionStorage.setItem(type, JSON.stringify(this.state[type].criteria));
}
private saveSortByToStorage(type: ListingType, sortBy: SortByOptions | null): void {
const sortByKey = type === 'businessListings' ? 'businessSortBy' : type === 'commercialPropertyListings' ? 'commercialSortBy' : 'professionalsSortBy';
if (sortBy) {
sessionStorage.setItem(sortByKey, sortBy);
} else {
sessionStorage.removeItem(sortByKey);
}
}
private loadStateFromStorage(): FilterState {
return {
businessListings: {
criteria: this.loadCriteriaFromStorage('businessListings') as BusinessListingCriteria,
sortBy: this.loadSortByFromStorage('businessSortBy'),
},
commercialPropertyListings: {
criteria: this.loadCriteriaFromStorage('commercialPropertyListings') as CommercialPropertyListingCriteria,
sortBy: this.loadSortByFromStorage('commercialSortBy'),
},
brokerListings: {
criteria: this.loadCriteriaFromStorage('brokerListings') as UserListingCriteria,
sortBy: this.loadSortByFromStorage('professionalsSortBy'),
},
};
}
private loadCriteriaFromStorage(key: ListingType): CriteriaType {
const stored = sessionStorage.getItem(key);
if (stored) {
return JSON.parse(stored);
}
switch (key) {
case 'businessListings':
return this.createEmptyBusinessListingCriteria();
case 'commercialPropertyListings':
return this.createEmptyCommercialPropertyListingCriteria();
case 'brokerListings':
return this.createEmptyUserListingCriteria();
}
}
private loadSortByFromStorage(key: string): SortByOptions | null {
const stored = sessionStorage.getItem(key);
return stored && stored !== 'null' ? (stored as SortByOptions) : null;
}
// Helper methods to create empty criteria
private createEmptyBusinessListingCriteria(): BusinessListingCriteria {
return {
criteriaType: 'businessListings',
types: [],
state: null,
city: null,
radius: null,
searchType: 'exact' as const,
minPrice: null,
maxPrice: null,
minRevenue: null,
maxRevenue: null,
minCashFlow: null,
maxCashFlow: null,
minNumberEmployees: null,
maxNumberEmployees: null,
establishedMin: null,
brokerName: null,
title: null,
realEstateChecked: false,
leasedLocation: false,
franchiseResale: false,
email: null,
prompt: null,
page: 1,
start: 0,
length: 12,
};
}
private createEmptyCommercialPropertyListingCriteria(): CommercialPropertyListingCriteria {
return {
criteriaType: 'commercialPropertyListings',
types: [],
state: null,
city: null,
radius: null,
searchType: 'exact' as const,
minPrice: null,
maxPrice: null,
title: null,
prompt: null,
page: 1,
start: 0,
length: 12,
};
}
private createEmptyUserListingCriteria(): UserListingCriteria {
return {
criteriaType: 'brokerListings',
types: [],
state: null,
city: null,
radius: null,
searchType: 'exact' as const,
brokerName: null,
companyName: null,
counties: [],
prompt: null,
page: 1,
start: 0,
length: 12,
};
}
}

View File

@ -2,9 +2,8 @@ import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable, lastValueFrom } from 'rxjs'; import { Observable, lastValueFrom } from 'rxjs';
import { BusinessListing, CommercialPropertyListing } from '../../../../bizmatch-server/src/models/db.model'; import { BusinessListing, CommercialPropertyListing } from '../../../../bizmatch-server/src/models/db.model';
import { ListingType, ResponseBusinessListingArray, ResponseCommercialPropertyListingArray } from '../../../../bizmatch-server/src/models/main.model'; import { BusinessListingCriteria, CommercialPropertyListingCriteria, ListingType, ResponseBusinessListingArray, ResponseCommercialPropertyListingArray } from '../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
import { getCriteriaByListingCategory, getSortByListingCategory } from '../utils/utils';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@ -13,21 +12,20 @@ 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(
const criteria = getCriteriaByListingCategory(listingsCategory); criteria: BusinessListingCriteria | CommercialPropertyListingCriteria,
const sortBy = getSortByListingCategory(listingsCategory); listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty',
const body = { ...criteria, sortBy }; // Merge sortBy in Body ): Promise<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray> {
const result = await lastValueFrom(this.http.post<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/find`, body)); const result = await lastValueFrom(this.http.post<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/find`, criteria));
return result; return result;
} }
getNumberOfListings(criteria: BusinessListingCriteria | CommercialPropertyListingCriteria, listingsCategory: 'business' | 'commercialProperty'): Observable<number> {
getNumberOfListings(listingsCategory: 'business' | 'commercialProperty', crit?: any): Observable<number> { return this.http.post<number>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/findTotal`, criteria);
const criteria = crit ? crit : getCriteriaByListingCategory(listingsCategory); }
const sortBy = getSortByListingCategory(listingsCategory); async getListingsByPrompt(criteria: BusinessListingCriteria | CommercialPropertyListingCriteria): Promise<BusinessListing[]> {
const body = { ...criteria, sortBy }; // Merge, falls relevant (wenn Backend sortBy für Count braucht; sonst ignorieren) const result = await lastValueFrom(this.http.post<BusinessListing[]>(`${this.apiBaseUrl}/bizmatch/listings/business/search`, criteria));
return this.http.post<number>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/findTotal`, body); return result;
} }
getListingById(id: string, listingsCategory?: 'business' | 'commercialProperty'): Observable<ListingType> { getListingById(id: string, listingsCategory?: 'business' | 'commercialProperty'): Observable<ListingType> {
const result = this.http.get<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/${id}`); const result = this.http.get<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/${id}`);
return result; return result;

View File

@ -1,26 +0,0 @@
// posthog.factory.ts
import { isPlatformBrowser } from '@angular/common';
import { APP_INITIALIZER, FactoryProvider, PLATFORM_ID, inject } from '@angular/core';
import { environment } from '../../environments/environment';
export function initPosthog() {
const platformId = inject(PLATFORM_ID);
// Nur Browser + nur Production
if (!isPlatformBrowser(platformId) || !environment.production) return () => {};
return async () => {
// Dynamisch laden -> eigener Chunk, wird in Dev nie gezogen
const { default: posthog } = await import('posthog-js');
posthog.init(environment.POSTHOG_KEY, {
api_host: environment.POSTHOG_HOST,
capture_pageview: 'history_change',
});
};
}
export const POSTHOG_INIT_PROVIDER: FactoryProvider = {
provide: APP_INITIALIZER,
useFactory: initPosthog,
multi: true,
};

View File

@ -1,21 +1,17 @@
// Vereinfachter search.service.ts
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { BusinessListingCriteria, CommercialPropertyListingCriteria, UserListingCriteria } from '../../../../bizmatch-server/src/models/main.model';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class SearchService { export class SearchService {
private searchTriggerSubject = new Subject<string>(); private criteriaSource = new Subject<BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria>();
currentCriteria = this.criteriaSource.asObservable();
// Observable für Komponenten zum Abonnieren
searchTrigger$ = this.searchTriggerSubject.asObservable();
constructor() {} constructor() {}
// Trigger eine Suche für einen bestimmten Listing-Typ search(criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria): void {
search(listingType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings'): void { this.criteriaSource.next(criteria);
console.log(`Triggering search for: ${listingType}`);
this.searchTriggerSubject.next(listingType);
} }
} }

View File

@ -1,60 +0,0 @@
import { Injectable } from '@angular/core';
export interface ValidationMessage {
field: string;
message: string;
}
@Injectable({
providedIn: 'root',
})
export class ValidationService {
private messages: ValidationMessage[] = [];
constructor() {}
/**
* Fügt Validierungsnachrichten hinzu oder aktualisiert bestehende
* @param messages Array von Validierungsnachrichten
*/
setMessages(messages: ValidationMessage[]): void {
this.messages = messages;
}
/**
* Löscht alle Validierungsmeldungen
*/
clearMessages(): void {
this.messages = [];
}
/**
* Prüft, ob für ein bestimmtes Feld eine Validierungsmeldung existiert
* @param field Name des Feldes
* @returns true, wenn eine Meldung existiert
*/
hasMessage(field: string): boolean {
return this.messages.some(message => message.field === field);
}
/**
* Gibt die Validierungsmeldung für ein bestimmtes Feld zurück
* @param field Name des Feldes
* @returns ValidationMessage oder null, wenn keine Meldung existiert
*/
getMessage(field: string): ValidationMessage | null {
return this.messages.find(message => message.field === field) || null;
}
/**
* Hilfsmethode zur Verarbeitung von API-Fehlermeldungen
* @param error API-Fehler mit message-Array
*/
handleApiError(error: any): void {
if (error && error.message && Array.isArray(error.message)) {
this.setMessages(error.message);
} else {
this.clearMessages();
}
}
}

View File

@ -2,7 +2,7 @@ import { Router } from '@angular/router';
import { ConsoleFormattedStream, INFO, createLogger as _createLogger, stdSerializers } from 'browser-bunyan'; import { ConsoleFormattedStream, INFO, createLogger as _createLogger, stdSerializers } from 'browser-bunyan';
import { jwtDecode } from 'jwt-decode'; import { jwtDecode } from 'jwt-decode';
import onChange from 'on-change'; import onChange from 'on-change';
import { SortByOptions, User } from '../../../../bizmatch-server/src/models/db.model'; import { User } from '../../../../bizmatch-server/src/models/db.model';
import { BusinessListingCriteria, CommercialPropertyListingCriteria, JwtToken, KeycloakUser, MailInfo, UserListingCriteria } from '../../../../bizmatch-server/src/models/main.model'; import { BusinessListingCriteria, CommercialPropertyListingCriteria, JwtToken, KeycloakUser, MailInfo, UserListingCriteria } from '../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
@ -15,6 +15,7 @@ export function createEmptyBusinessListingCriteria(): BusinessListingCriteria {
city: null, city: null,
types: [], types: [],
prompt: '', prompt: '',
sortBy: null,
criteriaType: 'businessListings', criteriaType: 'businessListings',
minPrice: null, minPrice: null,
maxPrice: null, maxPrice: null,
@ -24,12 +25,12 @@ export function createEmptyBusinessListingCriteria(): BusinessListingCriteria {
maxCashFlow: null, maxCashFlow: null,
minNumberEmployees: null, minNumberEmployees: null,
maxNumberEmployees: null, maxNumberEmployees: null,
establishedMin: null, establishedSince: null,
establishedUntil: null,
realEstateChecked: false, realEstateChecked: false,
leasedLocation: false, leasedLocation: false,
franchiseResale: false, franchiseResale: false,
title: '', title: '',
email: '',
brokerName: '', brokerName: '',
searchType: 'exact', searchType: 'exact',
radius: null, radius: null,
@ -45,6 +46,7 @@ export function createEmptyCommercialPropertyListingCriteria(): CommercialProper
city: null, city: null,
types: [], types: [],
prompt: '', prompt: '',
sortBy: null,
criteriaType: 'commercialPropertyListings', criteriaType: 'commercialPropertyListings',
minPrice: null, minPrice: null,
maxPrice: null, maxPrice: null,
@ -62,6 +64,7 @@ export function createEmptyUserListingCriteria(): UserListingCriteria {
city: null, city: null,
types: [], types: [],
prompt: '', prompt: '',
sortBy: null,
criteriaType: 'brokerListings', criteriaType: 'brokerListings',
brokerName: '', brokerName: '',
companyName: '', companyName: '',
@ -79,6 +82,7 @@ export function resetBusinessListingCriteria(criteria: BusinessListingCriteria)
criteria.city = null; criteria.city = null;
criteria.types = []; criteria.types = [];
criteria.prompt = ''; criteria.prompt = '';
criteria.sortBy = null;
criteria.criteriaType = 'businessListings'; criteria.criteriaType = 'businessListings';
criteria.minPrice = null; criteria.minPrice = null;
criteria.maxPrice = null; criteria.maxPrice = null;
@ -88,7 +92,8 @@ export function resetBusinessListingCriteria(criteria: BusinessListingCriteria)
criteria.maxCashFlow = null; criteria.maxCashFlow = null;
criteria.minNumberEmployees = null; criteria.minNumberEmployees = null;
criteria.maxNumberEmployees = null; criteria.maxNumberEmployees = null;
criteria.establishedMin = null; criteria.establishedSince = null;
criteria.establishedUntil = null;
criteria.realEstateChecked = false; criteria.realEstateChecked = false;
criteria.leasedLocation = false; criteria.leasedLocation = false;
criteria.franchiseResale = false; criteria.franchiseResale = false;
@ -106,6 +111,7 @@ export function resetCommercialPropertyListingCriteria(criteria: CommercialPrope
criteria.city = null; criteria.city = null;
criteria.types = []; criteria.types = [];
criteria.prompt = ''; criteria.prompt = '';
criteria.sortBy = null;
criteria.criteriaType = 'commercialPropertyListings'; criteria.criteriaType = 'commercialPropertyListings';
criteria.minPrice = null; criteria.minPrice = null;
criteria.maxPrice = null; criteria.maxPrice = null;
@ -121,6 +127,7 @@ export function resetUserListingCriteria(criteria: UserListingCriteria) {
criteria.city = null; criteria.city = null;
criteria.types = []; criteria.types = [];
criteria.prompt = ''; criteria.prompt = '';
criteria.sortBy = null;
criteria.criteriaType = 'brokerListings'; criteria.criteriaType = 'brokerListings';
criteria.brokerName = ''; criteria.brokerName = '';
criteria.companyName = ''; criteria.companyName = '';
@ -132,9 +139,7 @@ export function resetUserListingCriteria(criteria: UserListingCriteria) {
export function createMailInfo(user?: User): MailInfo { export function createMailInfo(user?: User): MailInfo {
return { return {
sender: user sender: user ? { name: `${user.firstname} ${user.lastname}`, email: user.email, phoneNumber: user.phoneNumber, state: user.location?.state, comments: null } : {},
? { name: `${user.firstname} ${user.lastname}`, email: user.email, phoneNumber: user.phoneNumber, state: user.location?.state, comments: null }
: { name: '', email: '', phoneNumber: '', state: '', comments: '' },
email: null, email: null,
url: environment.mailinfoUrl, url: environment.mailinfoUrl,
listing: null, listing: null,
@ -294,11 +299,6 @@ export function checkAndUpdate(changed: boolean, condition: boolean, assignment:
} }
return changed || condition; return changed || condition;
} }
export function removeSortByStorage() {
sessionStorage.removeItem('businessSortBy');
sessionStorage.removeItem('commercialSortBy');
sessionStorage.removeItem('professionalsSortBy');
}
// ----------------------------- // -----------------------------
// Criteria Proxy // Criteria Proxy
// ----------------------------- // -----------------------------
@ -340,19 +340,6 @@ export function createEnhancedProxy(obj: BusinessListingCriteria | CommercialPro
} }
}); });
} }
export function getCriteriaByListingCategory(listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty') { // export function isAdmin(email: string) {
const storedState = // return 'andreas.knuth@gmail.com' === email;
listingsCategory === 'business' // }
? sessionStorage.getItem('businessListings')
: listingsCategory === 'commercialProperty'
? sessionStorage.getItem('commercialPropertyListings')
: sessionStorage.getItem('brokerListings');
return JSON.parse(storedState);
}
export function getSortByListingCategory(listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty') {
const storedSortBy =
listingsCategory === 'business' ? sessionStorage.getItem('businessSortBy') : listingsCategory === 'commercialProperty' ? sessionStorage.getItem('commercialSortBy') : sessionStorage.getItem('professionalsSortBy');
const sortBy = storedSortBy && storedSortBy !== 'null' ? (storedSortBy as SortByOptions) : null;
return sortBy;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 667 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Some files were not shown because too many files have changed in this diff Show More