Compare commits
44 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
c2d7a53039 | |
|
|
4fa24c8f3d | |
|
|
351b560bcc | |
|
|
f973b87a2d | |
|
|
995468fa30 | |
|
|
6fa3bea614 | |
|
|
6b12e0cbac | |
|
|
39b579ea4e | |
|
|
8113206e90 | |
|
|
3b51a98dec | |
|
|
fbca2ddab5 | |
|
|
03d075b7d9 | |
|
|
f9d4506bde | |
|
|
571cfb0e61 | |
|
|
d48cd7aa1d | |
|
|
bab898adf4 | |
|
|
8dff7eca6a | |
|
|
418cc3a043 | |
|
|
7b94785a30 | |
|
|
c5c210b616 | |
|
|
4dcff1d883 | |
|
|
4efa6c9d77 | |
|
|
4d74c20c87 | |
|
|
8624c1b8da | |
|
|
93ff8c3378 | |
|
|
738f1d929b | |
|
|
9c88143c04 | |
|
|
569e086bb4 | |
|
|
dda1b2f54d | |
|
|
d14f333991 | |
|
|
388aac5a76 | |
|
|
2ebe6454ec | |
|
|
903ca7dc56 | |
|
|
5619007b0f | |
|
|
f3bf6ff9af | |
|
|
c62af8746f | |
|
|
7d336f975d | |
|
|
e913026f53 | |
|
|
a6f1571b8b | |
|
|
01b5679e54 | |
|
|
24db8927e8 | |
|
|
466e1dcdce | |
|
|
7d64ee11bf | |
|
|
83808263af |
|
|
@ -0,0 +1,195 @@
|
|||
# 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.
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
REALM=bizmatch-dev
|
||||
usersURL=/admin/realms/bizmatch-dev/users
|
||||
WEB_HOST=https://dev.bizmatch.net
|
||||
STRIPE_WEBHOOK_SECRET=whsec_w2yvJY8qFMfO5wJgyNHCn6oYT7o2J5pS
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
REALM=bizmatch
|
||||
WEB_HOST=https://www.bizmatch.net
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# 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"]
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# ~/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
|
||||
|
|
@ -3,7 +3,6 @@ export default defineConfig({
|
|||
schema: './src/drizzle/schema.ts',
|
||||
out: './src/drizzle/migrations',
|
||||
dialect: 'postgresql',
|
||||
// driver: 'pg',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
"@nestjs/common": "^11.0.11",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.11",
|
||||
"@nestjs/cli": "^11.0.11",
|
||||
"@nestjs/platform-express": "^11.0.11",
|
||||
"@types/stripe": "^8.0.417",
|
||||
"body-parser": "^1.20.2",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,13 @@ const { Pool } = pkg;
|
|||
inject: [ConfigService, WINSTON_MODULE_PROVIDER, ClsService],
|
||||
useFactory: async (configService: ConfigService, logger: Logger, cls: ClsService) => {
|
||||
const connectionString = configService.get<string>('DATABASE_URL');
|
||||
console.log('--->',connectionString)
|
||||
// const dbHost = configService.get<string>('DB_HOST');
|
||||
// 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({
|
||||
connectionString,
|
||||
// ssl: true, // Falls benötigt
|
||||
|
|
|
|||
|
|
@ -8,6 +8,56 @@ export const customerSubTypeEnum = pgEnum('customerSubType', ['broker', 'cpa', '
|
|||
export const listingsCategoryEnum = pgEnum('listingsCategory', ['commercialProperty', 'business']);
|
||||
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(
|
||||
'users',
|
||||
{
|
||||
|
|
@ -34,10 +84,6 @@ export const users = pgTable(
|
|||
subscriptionPlan: subscriptionTypeEnum('subscriptionPlan'),
|
||||
location: jsonb('location'),
|
||||
showInDirectory: boolean('showInDirectory').default(true),
|
||||
// city: varchar('city', { length: 255 }),
|
||||
// state: char('state', { length: 2 }),
|
||||
// latitude: doublePrecision('latitude'),
|
||||
// longitude: doublePrecision('longitude'),
|
||||
},
|
||||
table => ({
|
||||
locationUserCityStateIdx: index('idx_user_location_city_state').on(
|
||||
|
|
@ -45,6 +91,7 @@ export const users = pgTable(
|
|||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const businesses = pgTable(
|
||||
'businesses',
|
||||
{
|
||||
|
|
@ -56,7 +103,7 @@ export const businesses = pgTable(
|
|||
price: doublePrecision('price'),
|
||||
favoritesForUser: varchar('favoritesForUser', { length: 30 }).array(),
|
||||
draft: boolean('draft'),
|
||||
listingsCategory: listingsCategoryEnum('listingsCategory'), //varchar('listingsCategory', { length: 255 }),
|
||||
listingsCategory: listingsCategoryEnum('listingsCategory'),
|
||||
realEstateIncluded: boolean('realEstateIncluded'),
|
||||
leasedLocation: boolean('leasedLocation'),
|
||||
franchiseResale: boolean('franchiseResale'),
|
||||
|
|
@ -80,6 +127,7 @@ export const businesses = pgTable(
|
|||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const commercials = pgTable(
|
||||
'commercials',
|
||||
{
|
||||
|
|
@ -91,21 +139,13 @@ export const commercials = pgTable(
|
|||
description: text('description'),
|
||||
price: doublePrecision('price'),
|
||||
favoritesForUser: varchar('favoritesForUser', { length: 30 }).array(),
|
||||
listingsCategory: listingsCategoryEnum('listingsCategory'), //listingsCategory: varchar('listingsCategory', { length: 255 }),
|
||||
listingsCategory: listingsCategoryEnum('listingsCategory'),
|
||||
draft: boolean('draft'),
|
||||
imageOrder: varchar('imageOrder', { length: 200 }).array(),
|
||||
imagePath: varchar('imagePath', { length: 200 }),
|
||||
created: timestamp('created'),
|
||||
updated: timestamp('updated'),
|
||||
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 => ({
|
||||
locationCommercialsCityStateIdx: index('idx_commercials_location_city_state').on(
|
||||
|
|
@ -113,30 +153,19 @@ export const commercials = pgTable(
|
|||
),
|
||||
}),
|
||||
);
|
||||
// export const geo = pgTable('geo', {
|
||||
// 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', {
|
||||
|
||||
export const listing_events = pgTable('listing_events', {
|
||||
id: uuid('id').primaryKey().defaultRandom().notNull(),
|
||||
listingId: varchar('listing_id', { length: 255 }), // Assuming listings are referenced by UUID, adjust as necessary
|
||||
listingId: varchar('listing_id', { length: 255 }),
|
||||
email: varchar('email', { length: 255 }),
|
||||
eventType: varchar('event_type', { length: 50 }), // 'view', 'print', 'email', 'facebook', 'x', 'linkedin', 'contact'
|
||||
eventType: varchar('event_type', { length: 50 }),
|
||||
eventTimestamp: timestamp('event_timestamp').defaultNow(),
|
||||
userIp: varchar('user_ip', { length: 45 }), // Optional if you choose to track IP in frontend or backend
|
||||
userAgent: varchar('user_agent', { length: 255 }), // Store User-Agent as string
|
||||
locationCountry: varchar('location_country', { length: 100 }), // Country from IP
|
||||
locationCity: varchar('location_city', { length: 100 }), // City from IP
|
||||
locationLat: varchar('location_lat', { length: 20 }), // Latitude from IP, stored as varchar
|
||||
locationLng: varchar('location_lng', { length: 20 }), // Longitude from IP, stored as varchar
|
||||
referrer: varchar('referrer', { length: 255 }), // Referrer URL if applicable
|
||||
additionalData: jsonb('additional_data'), // JSON for any other optional data (like email, social shares etc.)
|
||||
userIp: varchar('user_ip', { length: 45 }),
|
||||
userAgent: varchar('user_agent', { length: 255 }),
|
||||
locationCountry: varchar('location_country', { length: 100 }),
|
||||
locationCity: varchar('location_city', { length: 100 }),
|
||||
locationLat: varchar('location_lat', { length: 20 }),
|
||||
locationLng: varchar('location_lng', { length: 20 }),
|
||||
referrer: varchar('referrer', { length: 255 }),
|
||||
additionalData: jsonb('additional_data'),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,17 +2,22 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||
import { ListingEvent } from 'src/models/db.model';
|
||||
import { Logger } from 'winston';
|
||||
import * as schema from '../drizzle/schema';
|
||||
import { listingEvents, PG_CONNECTION } from '../drizzle/schema';
|
||||
import { listing_events_json, PG_CONNECTION } from '../drizzle/schema';
|
||||
|
||||
@Injectable()
|
||||
export class EventService {
|
||||
constructor(
|
||||
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
||||
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
|
||||
) {}
|
||||
|
||||
async createEvent(event: ListingEvent) {
|
||||
// Speichere das Event in der Datenbank
|
||||
event.eventTimestamp = new Date();
|
||||
await this.conn.insert(listingEvents).values(event).execute();
|
||||
const { id, email, ...rest } = event;
|
||||
const convertedEvent = { email, data: rest };
|
||||
await this.conn.insert(listing_events_json).values(convertedEvent).execute();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { and, arrayContains, asc, count, desc, eq, gte, ilike, inArray, lte, ne, or, SQL, sql } from 'drizzle-orm';
|
||||
import { and, arrayContains, asc, count, desc, eq, gte, inArray, lte, or, SQL, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||
import { Logger } from 'winston';
|
||||
import { ZodError } from 'zod';
|
||||
import * as schema from '../drizzle/schema';
|
||||
import { businesses, PG_CONNECTION } from '../drizzle/schema';
|
||||
import { FileService } from '../file/file.service';
|
||||
import { businesses_json, PG_CONNECTION, users_json } from '../drizzle/schema';
|
||||
import { GeoService } from '../geo/geo.service';
|
||||
import { BusinessListing, BusinessListingSchema } from '../models/db.model';
|
||||
import { BusinessListingCriteria, JwtUser } from '../models/main.model';
|
||||
|
|
@ -17,7 +16,6 @@ export class BusinessListingService {
|
|||
constructor(
|
||||
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
|
||||
@Inject(PG_CONNECTION) private conn: NodePgDatabase<typeof schema>,
|
||||
private fileService?: FileService,
|
||||
private geoService?: GeoService,
|
||||
) {}
|
||||
|
||||
|
|
@ -25,101 +23,101 @@ export class BusinessListingService {
|
|||
const whereConditions: SQL[] = [];
|
||||
|
||||
if (criteria.city && criteria.searchType === 'exact') {
|
||||
whereConditions.push(sql`${businesses.location}->>'name' ilike ${criteria.city.name}`);
|
||||
//whereConditions.push(ilike(businesses.location-->'city', `%${criteria.city.name}%`));
|
||||
whereConditions.push(sql`(${businesses_json.data}->'location'->>'name') ILIKE ${criteria.city.name}`);
|
||||
}
|
||||
|
||||
if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) {
|
||||
const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name);
|
||||
whereConditions.push(sql`${getDistanceQuery(businesses, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`);
|
||||
whereConditions.push(sql`${getDistanceQuery(businesses_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`);
|
||||
}
|
||||
if (criteria.types && criteria.types.length > 0) {
|
||||
whereConditions.push(inArray(businesses.type, criteria.types));
|
||||
whereConditions.push(inArray(sql`${businesses_json.data}->>'type'`, criteria.types));
|
||||
}
|
||||
|
||||
if (criteria.state) {
|
||||
whereConditions.push(sql`${businesses.location}->>'state' = ${criteria.state}`);
|
||||
whereConditions.push(sql`(${businesses_json.data}->'location'->>'state') = ${criteria.state}`);
|
||||
}
|
||||
|
||||
if (criteria.minPrice) {
|
||||
whereConditions.push(gte(businesses.price, criteria.minPrice));
|
||||
whereConditions.push(gte(sql`(${businesses_json.data}->>'price')::double precision`, criteria.minPrice));
|
||||
}
|
||||
|
||||
if (criteria.maxPrice) {
|
||||
whereConditions.push(lte(businesses.price, criteria.maxPrice));
|
||||
whereConditions.push(lte(sql`(${businesses_json.data}->>'price')::double precision`, criteria.maxPrice));
|
||||
}
|
||||
|
||||
if (criteria.minRevenue) {
|
||||
whereConditions.push(gte(businesses.salesRevenue, criteria.minRevenue));
|
||||
whereConditions.push(gte(sql`(${businesses_json.data}->>'salesRevenue')::double precision`, criteria.minRevenue));
|
||||
}
|
||||
|
||||
if (criteria.maxRevenue) {
|
||||
whereConditions.push(lte(businesses.salesRevenue, criteria.maxRevenue));
|
||||
whereConditions.push(lte(sql`(${businesses_json.data}->>'salesRevenue')::double precision`, criteria.maxRevenue));
|
||||
}
|
||||
|
||||
if (criteria.minCashFlow) {
|
||||
whereConditions.push(gte(businesses.cashFlow, criteria.minCashFlow));
|
||||
whereConditions.push(gte(sql`(${businesses_json.data}->>'cashFlow')::double precision`, criteria.minCashFlow));
|
||||
}
|
||||
|
||||
if (criteria.maxCashFlow) {
|
||||
whereConditions.push(lte(businesses.cashFlow, criteria.maxCashFlow));
|
||||
whereConditions.push(lte(sql`(${businesses_json.data}->>'cashFlow')::double precision`, criteria.maxCashFlow));
|
||||
}
|
||||
|
||||
if (criteria.minNumberEmployees) {
|
||||
whereConditions.push(gte(businesses.employees, criteria.minNumberEmployees));
|
||||
whereConditions.push(gte(sql`(${businesses_json.data}->>'employees')::integer`, criteria.minNumberEmployees));
|
||||
}
|
||||
|
||||
if (criteria.maxNumberEmployees) {
|
||||
whereConditions.push(lte(businesses.employees, criteria.maxNumberEmployees));
|
||||
whereConditions.push(lte(sql`(${businesses_json.data}->>'employees')::integer`, criteria.maxNumberEmployees));
|
||||
}
|
||||
|
||||
if (criteria.establishedSince) {
|
||||
whereConditions.push(gte(businesses.established, criteria.establishedSince));
|
||||
}
|
||||
|
||||
if (criteria.establishedUntil) {
|
||||
whereConditions.push(lte(businesses.established, criteria.establishedUntil));
|
||||
if (criteria.establishedMin) {
|
||||
whereConditions.push(gte(sql`(${businesses_json.data}->>'established')::integer`, criteria.establishedMin));
|
||||
}
|
||||
|
||||
if (criteria.realEstateChecked) {
|
||||
whereConditions.push(eq(businesses.realEstateIncluded, criteria.realEstateChecked));
|
||||
whereConditions.push(eq(sql`(${businesses_json.data}->>'realEstateIncluded')::boolean`, criteria.realEstateChecked));
|
||||
}
|
||||
|
||||
if (criteria.leasedLocation) {
|
||||
whereConditions.push(eq(businesses.leasedLocation, criteria.leasedLocation));
|
||||
whereConditions.push(eq(sql`(${businesses_json.data}->>'leasedLocation')::boolean`, criteria.leasedLocation));
|
||||
}
|
||||
|
||||
if (criteria.franchiseResale) {
|
||||
whereConditions.push(eq(businesses.franchiseResale, criteria.franchiseResale));
|
||||
whereConditions.push(eq(sql`(${businesses_json.data}->>'franchiseResale')::boolean`, criteria.franchiseResale));
|
||||
}
|
||||
|
||||
if (criteria.title) {
|
||||
whereConditions.push(or(ilike(businesses.title, `%${criteria.title}%`), ilike(businesses.description, `%${criteria.title}%`)));
|
||||
whereConditions.push(sql`(${businesses_json.data}->>'title') ILIKE ${`%${criteria.title}%`} OR (${businesses_json.data}->>'description') ILIKE ${`%${criteria.title}%`}`);
|
||||
}
|
||||
if (criteria.brokerName) {
|
||||
const { firstname, lastname } = splitName(criteria.brokerName);
|
||||
if (firstname === lastname) {
|
||||
whereConditions.push(or(ilike(schema.users.firstname, `%${firstname}%`), ilike(schema.users.lastname, `%${lastname}%`)));
|
||||
whereConditions.push(sql`(${users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${users_json.data}->>'lastname') ILIKE ${`%${lastname}%`}`);
|
||||
} else {
|
||||
whereConditions.push(and(ilike(schema.users.firstname, `%${firstname}%`), ilike(schema.users.lastname, `%${lastname}%`)));
|
||||
whereConditions.push(sql`(${users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} AND (${users_json.data}->>'lastname') ILIKE ${`%${lastname}%`}`);
|
||||
}
|
||||
}
|
||||
if (criteria.email) {
|
||||
whereConditions.push(eq(users_json.email, criteria.email));
|
||||
}
|
||||
if (user?.role !== 'admin') {
|
||||
whereConditions.push(or(eq(businesses.email, user?.email), ne(businesses.draft, true)));
|
||||
whereConditions.push(or(eq(businesses_json.email, user?.email), sql`(${businesses_json.data}->>'draft')::boolean IS NOT TRUE`));
|
||||
}
|
||||
whereConditions.push(and(eq(schema.users.customerType, 'professional'), eq(schema.users.customerSubType, 'broker')));
|
||||
whereConditions.push(and(sql`(${users_json.data}->>'customerType') = 'professional'`, sql`(${users_json.data}->>'customerSubType') = 'broker'`));
|
||||
return whereConditions;
|
||||
}
|
||||
|
||||
async searchBusinessListings(criteria: BusinessListingCriteria, user: JwtUser) {
|
||||
const start = criteria.start ? criteria.start : 0;
|
||||
const length = criteria.length ? criteria.length : 12;
|
||||
const query = this.conn
|
||||
.select({
|
||||
business: businesses,
|
||||
brokerFirstName: schema.users.firstname,
|
||||
brokerLastName: schema.users.lastname,
|
||||
business: businesses_json,
|
||||
brokerFirstName: sql`${users_json.data}->>'firstname'`.as('brokerFirstName'),
|
||||
brokerLastName: sql`${users_json.data}->>'lastname'`.as('brokerLastName'),
|
||||
})
|
||||
.from(businesses)
|
||||
.leftJoin(schema.users, eq(businesses.email, schema.users.email));
|
||||
.from(businesses_json)
|
||||
.leftJoin(users_json, eq(businesses_json.email, users_json.email));
|
||||
|
||||
const whereConditions = this.getWhereConditions(criteria, user);
|
||||
|
||||
|
|
@ -131,39 +129,69 @@ export class BusinessListingService {
|
|||
// Sortierung
|
||||
switch (criteria.sortBy) {
|
||||
case 'priceAsc':
|
||||
query.orderBy(asc(businesses.price));
|
||||
query.orderBy(asc(sql`(${businesses_json.data}->>'price')::double precision`));
|
||||
break;
|
||||
case 'priceDesc':
|
||||
query.orderBy(desc(businesses.price));
|
||||
query.orderBy(desc(sql`(${businesses_json.data}->>'price')::double precision`));
|
||||
break;
|
||||
case 'srAsc':
|
||||
query.orderBy(asc(businesses.salesRevenue));
|
||||
query.orderBy(asc(sql`(${businesses_json.data}->>'salesRevenue')::double precision`));
|
||||
break;
|
||||
case 'srDesc':
|
||||
query.orderBy(desc(businesses.salesRevenue));
|
||||
query.orderBy(desc(sql`(${businesses_json.data}->>'salesRevenue')::double precision`));
|
||||
break;
|
||||
case 'cfAsc':
|
||||
query.orderBy(asc(businesses.cashFlow));
|
||||
query.orderBy(asc(sql`(${businesses_json.data}->>'cashFlow')::double precision`));
|
||||
break;
|
||||
case 'cfDesc':
|
||||
query.orderBy(desc(businesses.cashFlow));
|
||||
query.orderBy(desc(sql`(${businesses_json.data}->>'cashFlow')::double precision`));
|
||||
break;
|
||||
case 'creationDateFirst':
|
||||
query.orderBy(asc(businesses.created));
|
||||
query.orderBy(asc(sql`${businesses_json.data}->>'created'`));
|
||||
break;
|
||||
case 'creationDateLast':
|
||||
query.orderBy(desc(businesses.created));
|
||||
query.orderBy(desc(sql`${businesses_json.data}->>'created'`));
|
||||
break;
|
||||
default:
|
||||
// Keine spezifische Sortierung, Standardverhalten kann hier eingefügt werden
|
||||
default: {
|
||||
// NEU (created < 14 Tage) > UPDATED (updated < 14 Tage) > Rest
|
||||
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;
|
||||
}
|
||||
}
|
||||
// Paginierung
|
||||
query.limit(length).offset(start);
|
||||
|
||||
const data = await query;
|
||||
const totalCount = await this.getBusinessListingsCount(criteria, user);
|
||||
const results = data.map(r => r.business);
|
||||
const results = data.map(r => ({
|
||||
id: r.business.id,
|
||||
email: r.business.email,
|
||||
...(r.business.data as BusinessListing),
|
||||
brokerFirstName: r.brokerFirstName,
|
||||
brokerLastName: r.brokerLastName,
|
||||
}));
|
||||
return {
|
||||
results,
|
||||
totalCount,
|
||||
|
|
@ -171,7 +199,7 @@ export class BusinessListingService {
|
|||
}
|
||||
|
||||
async getBusinessListingsCount(criteria: BusinessListingCriteria, user: JwtUser): Promise<number> {
|
||||
const countQuery = this.conn.select({ value: count() }).from(businesses).leftJoin(schema.users, eq(businesses.email, schema.users.email));
|
||||
const countQuery = this.conn.select({ value: count() }).from(businesses_json).leftJoin(users_json, eq(businesses_json.email, users_json.email));
|
||||
|
||||
const whereConditions = this.getWhereConditions(criteria, user);
|
||||
|
||||
|
|
@ -187,15 +215,15 @@ export class BusinessListingService {
|
|||
async findBusinessesById(id: string, user: JwtUser): Promise<BusinessListing> {
|
||||
const conditions = [];
|
||||
if (user?.role !== 'admin') {
|
||||
conditions.push(or(eq(businesses.email, user?.email), ne(businesses.draft, true)));
|
||||
conditions.push(or(eq(businesses_json.email, user?.email), sql`(${businesses_json.data}->>'draft')::boolean IS NOT TRUE`));
|
||||
}
|
||||
conditions.push(sql`${businesses.id} = ${id}`);
|
||||
conditions.push(eq(businesses_json.id, id));
|
||||
const result = await this.conn
|
||||
.select()
|
||||
.from(businesses)
|
||||
.from(businesses_json)
|
||||
.where(and(...conditions));
|
||||
if (result.length > 0) {
|
||||
return result[0] as BusinessListing;
|
||||
return { id: result[0].id, email: result[0].email, ...(result[0].data as BusinessListing) } as BusinessListing;
|
||||
} else {
|
||||
throw new BadRequestException(`No entry available for ${id}`);
|
||||
}
|
||||
|
|
@ -203,35 +231,34 @@ export class BusinessListingService {
|
|||
|
||||
async findBusinessesByEmail(email: string, user: JwtUser): Promise<BusinessListing[]> {
|
||||
const conditions = [];
|
||||
conditions.push(eq(businesses.email, email));
|
||||
conditions.push(eq(businesses_json.email, email));
|
||||
if (email !== user?.email && user?.role !== 'admin') {
|
||||
conditions.push(ne(businesses.draft, true));
|
||||
conditions.push(sql`(${businesses_json.data}->>'draft')::boolean IS NOT TRUE`);
|
||||
}
|
||||
const listings = (await this.conn
|
||||
const listings = await this.conn
|
||||
.select()
|
||||
.from(businesses)
|
||||
.where(and(...conditions))) as BusinessListing[];
|
||||
|
||||
return listings;
|
||||
.from(businesses_json)
|
||||
.where(and(...conditions));
|
||||
return listings.map(l => ({ id: l.id, email: l.email, ...(l.data as BusinessListing) }) as BusinessListing);
|
||||
}
|
||||
// #### Find Favorites ########################################
|
||||
|
||||
async findFavoriteListings(user: JwtUser): Promise<BusinessListing[]> {
|
||||
const userFavorites = await this.conn
|
||||
.select()
|
||||
.from(businesses)
|
||||
.where(arrayContains(businesses.favoritesForUser, [user.email]));
|
||||
return userFavorites;
|
||||
.from(businesses_json)
|
||||
.where(arrayContains(sql`${businesses_json.data}->>'favoritesForUser'`, [user.email]));
|
||||
return userFavorites.map(l => ({ id: l.id, email: l.email, ...(l.data as BusinessListing) }) as BusinessListing);
|
||||
}
|
||||
// #### CREATE ########################################
|
||||
|
||||
async createListing(data: BusinessListing): Promise<BusinessListing> {
|
||||
try {
|
||||
data.created = data.created ? (typeof data.created === 'string' ? new Date(data.created) : data.created) : new Date();
|
||||
data.updated = new Date();
|
||||
BusinessListingSchema.parse(data);
|
||||
const convertedBusinessListing = data;
|
||||
delete convertedBusinessListing.id;
|
||||
const [createdListing] = await this.conn.insert(businesses).values(convertedBusinessListing).returning();
|
||||
return createdListing;
|
||||
const { id, email, ...rest } = data;
|
||||
const convertedBusinessListing = { email, data: rest };
|
||||
const [createdListing] = await this.conn.insert(businesses_json).values(convertedBusinessListing).returning();
|
||||
return { id: createdListing.id, email: createdListing.email, ...(createdListing.data as BusinessListing) };
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
const filteredErrors = error.errors
|
||||
|
|
@ -245,10 +272,10 @@ export class BusinessListingService {
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
// #### UPDATE Business ########################################
|
||||
|
||||
async updateBusinessListing(id: string, data: BusinessListing, user: JwtUser): Promise<BusinessListing> {
|
||||
try {
|
||||
const [existingListing] = await this.conn.select().from(businesses).where(eq(businesses.id, id));
|
||||
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`);
|
||||
|
|
@ -256,12 +283,13 @@ export class BusinessListingService {
|
|||
data.updated = 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 = existingListing.favoritesForUser;
|
||||
data.favoritesForUser = (<BusinessListing>existingListing.data).favoritesForUser || [];
|
||||
}
|
||||
BusinessListingSchema.parse(data);
|
||||
const convertedBusinessListing = data;
|
||||
const [updateListing] = await this.conn.update(businesses).set(convertedBusinessListing).where(eq(businesses.id, id)).returning();
|
||||
return updateListing;
|
||||
const { id: _, email, ...rest } = data;
|
||||
const convertedBusinessListing = { email, data: rest };
|
||||
const [updateListing] = await this.conn.update(businesses_json).set(convertedBusinessListing).where(eq(businesses_json.id, id)).returning();
|
||||
return { id: updateListing.id, email: updateListing.email, ...(updateListing.data as BusinessListing) };
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
const filteredErrors = error.errors
|
||||
|
|
@ -275,17 +303,17 @@ export class BusinessListingService {
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
// #### DELETE ########################################
|
||||
|
||||
async deleteListing(id: string): Promise<void> {
|
||||
await this.conn.delete(businesses).where(eq(businesses.id, id));
|
||||
await this.conn.delete(businesses_json).where(eq(businesses_json.id, id));
|
||||
}
|
||||
// #### DELETE Favorite ###################################
|
||||
|
||||
async deleteFavorite(id: string, user: JwtUser): Promise<void> {
|
||||
await this.conn
|
||||
.update(businesses)
|
||||
.update(businesses_json)
|
||||
.set({
|
||||
favoritesForUser: sql`array_remove(${businesses.favoritesForUser}, ${user.email})`,
|
||||
data: sql`jsonb_set(${businesses_json.data}, '{favoritesForUser}', array_remove((${businesses_json.data}->>'favoritesForUser')::jsonb, ${user.email}))`,
|
||||
})
|
||||
.where(sql`${businesses.id} = ${id}`);
|
||||
.where(eq(businesses_json.id, id));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { and, arrayContains, asc, count, desc, eq, gte, ilike, inArray, lte, ne, or, SQL, sql } from 'drizzle-orm';
|
||||
import { and, arrayContains, asc, count, desc, eq, gte, inArray, lte, or, SQL, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||
import { Logger } from 'winston';
|
||||
import { ZodError } from 'zod';
|
||||
import * as schema from '../drizzle/schema';
|
||||
import { commercials, PG_CONNECTION } from '../drizzle/schema';
|
||||
import { commercials_json, PG_CONNECTION } from '../drizzle/schema';
|
||||
import { FileService } from '../file/file.service';
|
||||
import { GeoService } from '../geo/geo.service';
|
||||
import { CommercialPropertyListing, CommercialPropertyListingSchema } from '../models/db.model';
|
||||
|
|
@ -24,33 +24,33 @@ export class CommercialPropertyService {
|
|||
const whereConditions: SQL[] = [];
|
||||
|
||||
if (criteria.city && criteria.searchType === 'exact') {
|
||||
whereConditions.push(sql`${commercials.location}->>'name' ilike ${criteria.city.name}`);
|
||||
whereConditions.push(sql`(${commercials_json.data}->'location'->>'name') ILIKE ${criteria.city.name}`);
|
||||
}
|
||||
if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) {
|
||||
const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name);
|
||||
whereConditions.push(sql`${getDistanceQuery(commercials, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`);
|
||||
whereConditions.push(sql`${getDistanceQuery(commercials_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`);
|
||||
}
|
||||
if (criteria.types && criteria.types.length > 0) {
|
||||
whereConditions.push(inArray(schema.commercials.type, criteria.types));
|
||||
whereConditions.push(inArray(sql`${commercials_json.data}->>'type'`, criteria.types));
|
||||
}
|
||||
|
||||
if (criteria.state) {
|
||||
whereConditions.push(sql`${schema.commercials.location}->>'state' = ${criteria.state}`);
|
||||
whereConditions.push(sql`(${commercials_json.data}->'location'->>'state') = ${criteria.state}`);
|
||||
}
|
||||
|
||||
if (criteria.minPrice) {
|
||||
whereConditions.push(gte(schema.commercials.price, criteria.minPrice));
|
||||
whereConditions.push(gte(sql`(${commercials_json.data}->>'price')::double precision`, criteria.minPrice));
|
||||
}
|
||||
|
||||
if (criteria.maxPrice) {
|
||||
whereConditions.push(lte(schema.commercials.price, criteria.maxPrice));
|
||||
whereConditions.push(lte(sql`(${commercials_json.data}->>'price')::double precision`, criteria.maxPrice));
|
||||
}
|
||||
|
||||
if (criteria.title) {
|
||||
whereConditions.push(or(ilike(schema.commercials.title, `%${criteria.title}%`), ilike(schema.commercials.description, `%${criteria.title}%`)));
|
||||
whereConditions.push(sql`(${commercials_json.data}->>'title') ILIKE ${`%${criteria.title}%`} OR (${commercials_json.data}->>'description') ILIKE ${`%${criteria.title}%`}`);
|
||||
}
|
||||
if (user?.role !== 'admin') {
|
||||
whereConditions.push(or(eq(commercials.email, user?.email), ne(commercials.draft, true)));
|
||||
whereConditions.push(or(eq(commercials_json.email, user?.email), sql`(${commercials_json.data}->>'draft')::boolean IS NOT TRUE`));
|
||||
}
|
||||
// whereConditions.push(and(eq(schema.users.customerType, 'professional')));
|
||||
return whereConditions;
|
||||
|
|
@ -59,7 +59,7 @@ export class CommercialPropertyService {
|
|||
async searchCommercialProperties(criteria: CommercialPropertyListingCriteria, user: JwtUser): Promise<any> {
|
||||
const start = criteria.start ? criteria.start : 0;
|
||||
const length = criteria.length ? criteria.length : 12;
|
||||
const query = this.conn.select({ commercial: commercials }).from(commercials).leftJoin(schema.users, eq(commercials.email, schema.users.email));
|
||||
const query = this.conn.select({ commercial: commercials_json }).from(commercials_json).leftJoin(schema.users_json, eq(commercials_json.email, schema.users_json.email));
|
||||
const whereConditions = this.getWhereConditions(criteria, user);
|
||||
|
||||
if (whereConditions.length > 0) {
|
||||
|
|
@ -69,16 +69,16 @@ export class CommercialPropertyService {
|
|||
// Sortierung
|
||||
switch (criteria.sortBy) {
|
||||
case 'priceAsc':
|
||||
query.orderBy(asc(commercials.price));
|
||||
query.orderBy(asc(sql`(${commercials_json.data}->>'price')::double precision`));
|
||||
break;
|
||||
case 'priceDesc':
|
||||
query.orderBy(desc(commercials.price));
|
||||
query.orderBy(desc(sql`(${commercials_json.data}->>'price')::double precision`));
|
||||
break;
|
||||
case 'creationDateFirst':
|
||||
query.orderBy(asc(commercials.created));
|
||||
query.orderBy(asc(sql`${commercials_json.data}->>'created'`));
|
||||
break;
|
||||
case 'creationDateLast':
|
||||
query.orderBy(desc(commercials.created));
|
||||
query.orderBy(desc(sql`${commercials_json.data}->>'created'`));
|
||||
break;
|
||||
default:
|
||||
// Keine spezifische Sortierung, Standardverhalten kann hier eingefügt werden
|
||||
|
|
@ -89,7 +89,7 @@ export class CommercialPropertyService {
|
|||
query.limit(length).offset(start);
|
||||
|
||||
const data = await query;
|
||||
const results = data.map(r => r.commercial);
|
||||
const results = data.map(r => ({ id: r.commercial.id, email: r.commercial.email, ...(r.commercial.data as CommercialPropertyListing) }));
|
||||
const totalCount = await this.getCommercialPropertiesCount(criteria, user);
|
||||
|
||||
return {
|
||||
|
|
@ -98,7 +98,7 @@ export class CommercialPropertyService {
|
|||
};
|
||||
}
|
||||
async getCommercialPropertiesCount(criteria: CommercialPropertyListingCriteria, user: JwtUser): Promise<number> {
|
||||
const countQuery = this.conn.select({ value: count() }).from(schema.commercials).leftJoin(schema.users, eq(commercials.email, schema.users.email));
|
||||
const countQuery = this.conn.select({ value: count() }).from(commercials_json).leftJoin(schema.users_json, eq(commercials_json.email, schema.users_json.email));
|
||||
const whereConditions = this.getWhereConditions(criteria, user);
|
||||
|
||||
if (whereConditions.length > 0) {
|
||||
|
|
@ -114,15 +114,15 @@ export class CommercialPropertyService {
|
|||
async findCommercialPropertiesById(id: string, user: JwtUser): Promise<CommercialPropertyListing> {
|
||||
const conditions = [];
|
||||
if (user?.role !== 'admin') {
|
||||
conditions.push(or(eq(commercials.email, user?.email), ne(commercials.draft, true)));
|
||||
conditions.push(or(eq(commercials_json.email, user?.email), sql`(${commercials_json.data}->>'draft')::boolean IS NOT TRUE`));
|
||||
}
|
||||
conditions.push(sql`${commercials.id} = ${id}`);
|
||||
conditions.push(eq(commercials_json.id, id));
|
||||
const result = await this.conn
|
||||
.select()
|
||||
.from(commercials)
|
||||
.from(commercials_json)
|
||||
.where(and(...conditions));
|
||||
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;
|
||||
} else {
|
||||
throw new BadRequestException(`No entry available for ${id}`);
|
||||
}
|
||||
|
|
@ -131,42 +131,58 @@ export class CommercialPropertyService {
|
|||
// #### Find by User EMail ########################################
|
||||
async findCommercialPropertiesByEmail(email: string, user: JwtUser): Promise<CommercialPropertyListing[]> {
|
||||
const conditions = [];
|
||||
conditions.push(eq(commercials.email, email));
|
||||
conditions.push(eq(commercials_json.email, email));
|
||||
if (email !== user?.email && user?.role !== 'admin') {
|
||||
conditions.push(ne(commercials.draft, true));
|
||||
conditions.push(sql`(${commercials_json.data}->>'draft')::boolean IS NOT TRUE`);
|
||||
}
|
||||
const listings = (await this.conn
|
||||
const listings = await this.conn
|
||||
.select()
|
||||
.from(commercials)
|
||||
.where(and(...conditions))) as CommercialPropertyListing[];
|
||||
return listings as CommercialPropertyListing[];
|
||||
.from(commercials_json)
|
||||
.where(and(...conditions));
|
||||
return listings.map(l => ({ id: l.id, email: l.email, ...(l.data as CommercialPropertyListing) }) as CommercialPropertyListing);
|
||||
}
|
||||
// #### Find Favorites ########################################
|
||||
async findFavoriteListings(user: JwtUser): Promise<CommercialPropertyListing[]> {
|
||||
const userFavorites = await this.conn
|
||||
.select()
|
||||
.from(commercials)
|
||||
.where(arrayContains(commercials.favoritesForUser, [user.email]));
|
||||
return userFavorites;
|
||||
.from(commercials_json)
|
||||
.where(arrayContains(sql`${commercials_json.data}->>'favoritesForUser'`, [user.email]));
|
||||
return userFavorites.map(l => ({ id: l.id, email: l.email, ...(l.data as CommercialPropertyListing) }) as CommercialPropertyListing);
|
||||
}
|
||||
// #### Find by imagePath ########################################
|
||||
async findByImagePath(imagePath: string, serial: string): Promise<CommercialPropertyListing> {
|
||||
const result = await this.conn
|
||||
.select()
|
||||
.from(commercials)
|
||||
.where(and(sql`${commercials.imagePath} = ${imagePath}`, sql`${commercials.serialId} = ${serial}`));
|
||||
return result[0] as CommercialPropertyListing;
|
||||
.from(commercials_json)
|
||||
.where(and(sql`(${commercials_json.data}->>'imagePath') = ${imagePath}`, sql`(${commercials_json.data}->>'serialId')::integer = ${serial}`));
|
||||
if (result.length > 0) {
|
||||
return { id: result[0].id, email: result[0].email, ...(result[0].data as CommercialPropertyListing) } as CommercialPropertyListing;
|
||||
}
|
||||
}
|
||||
// #### CREATE ########################################
|
||||
async createListing(data: CommercialPropertyListing): Promise<CommercialPropertyListing> {
|
||||
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.updated = new Date();
|
||||
data.serialId = Number(serialId);
|
||||
CommercialPropertyListingSchema.parse(data);
|
||||
const convertedCommercialPropertyListing = data;
|
||||
delete convertedCommercialPropertyListing.id;
|
||||
const [createdListing] = await this.conn.insert(commercials).values(convertedCommercialPropertyListing).returning();
|
||||
return createdListing;
|
||||
const { id, email, ...rest } = data;
|
||||
const convertedCommercialPropertyListing = { email, data: rest };
|
||||
const [createdListing] = await this.conn.insert(commercials_json).values(convertedCommercialPropertyListing).returning();
|
||||
return { id: createdListing.id, email: createdListing.email, ...(createdListing.data as CommercialPropertyListing) };
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
const filteredErrors = error.errors
|
||||
|
|
@ -183,7 +199,7 @@ export class CommercialPropertyService {
|
|||
// #### UPDATE CommercialProps ########################################
|
||||
async updateCommercialPropertyListing(id: string, data: CommercialPropertyListing, user: JwtUser): Promise<CommercialPropertyListing> {
|
||||
try {
|
||||
const [existingListing] = await this.conn.select().from(commercials).where(eq(commercials.id, id));
|
||||
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`);
|
||||
|
|
@ -191,7 +207,7 @@ export class CommercialPropertyService {
|
|||
data.updated = 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 = existingListing.favoritesForUser;
|
||||
data.favoritesForUser = (<CommercialPropertyListing>existingListing.data).favoritesForUser || [];
|
||||
}
|
||||
CommercialPropertyListingSchema.parse(data);
|
||||
const imageOrder = await this.fileService.getPropertyImages(data.imagePath, String(data.serialId));
|
||||
|
|
@ -200,9 +216,10 @@ export class CommercialPropertyService {
|
|||
this.logger.warn(`changes between image directory and imageOrder in listing ${data.serialId}: ${difference.join(',')}`);
|
||||
data.imageOrder = imageOrder;
|
||||
}
|
||||
const convertedCommercialPropertyListing = data;
|
||||
const [updateListing] = await this.conn.update(commercials).set(convertedCommercialPropertyListing).where(eq(commercials.id, id)).returning();
|
||||
return updateListing;
|
||||
const { id: _, email, ...rest } = data;
|
||||
const convertedCommercialPropertyListing = { email, data: rest };
|
||||
const [updateListing] = await this.conn.update(commercials_json).set(convertedCommercialPropertyListing).where(eq(commercials_json.id, id)).returning();
|
||||
return { id: updateListing.id, email: updateListing.email, ...(updateListing.data as CommercialPropertyListing) };
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
const filteredErrors = error.errors
|
||||
|
|
@ -220,7 +237,7 @@ export class CommercialPropertyService {
|
|||
// Images for commercial Properties
|
||||
// ##############################################################
|
||||
async deleteImage(imagePath: string, serial: string, name: string) {
|
||||
const listing = (await this.findByImagePath(imagePath, serial)) as unknown as CommercialPropertyListing;
|
||||
const listing = await this.findByImagePath(imagePath, serial);
|
||||
const index = listing.imageOrder.findIndex(im => im === name);
|
||||
if (index > -1) {
|
||||
listing.imageOrder.splice(index, 1);
|
||||
|
|
@ -228,31 +245,21 @@ export class CommercialPropertyService {
|
|||
}
|
||||
}
|
||||
async addImage(imagePath: string, serial: string, imagename: string) {
|
||||
const listing = (await this.findByImagePath(imagePath, serial)) as unknown as CommercialPropertyListing;
|
||||
const listing = await this.findByImagePath(imagePath, serial);
|
||||
listing.imageOrder.push(imagename);
|
||||
await this.updateCommercialPropertyListing(listing.id, listing, null);
|
||||
}
|
||||
// #### DELETE ########################################
|
||||
async deleteListing(id: string): Promise<void> {
|
||||
await this.conn.delete(commercials).where(eq(commercials.id, id));
|
||||
await this.conn.delete(commercials_json).where(eq(commercials_json.id, id));
|
||||
}
|
||||
// #### DELETE Favorite ###################################
|
||||
async deleteFavorite(id: string, user: JwtUser): Promise<void> {
|
||||
await this.conn
|
||||
.update(commercials)
|
||||
.update(commercials_json)
|
||||
.set({
|
||||
favoritesForUser: sql`array_remove(${commercials.favoritesForUser}, ${user.email})`,
|
||||
data: sql`jsonb_set(${commercials_json.data}, '{favoritesForUser}', array_remove((${commercials_json.data}->>'favoritesForUser')::jsonb, ${user.email}))`,
|
||||
})
|
||||
.where(sql`${commercials.id} = ${id}`);
|
||||
.where(eq(commercials_json.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`);
|
||||
// }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,10 +153,10 @@ export const GeoSchema = z
|
|||
zipCode: z.number().optional().nullable(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!data.name && !data.county) {
|
||||
if (!data.state) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'You need to select either a city or a county',
|
||||
message: 'You need to select at least a state',
|
||||
path: ['name'],
|
||||
});
|
||||
}
|
||||
|
|
@ -268,7 +268,7 @@ export const BusinessListingSchema = z
|
|||
title: z.string().min(10),
|
||||
description: z.string().min(10),
|
||||
location: GeoSchema,
|
||||
price: z.number().positive(),
|
||||
price: z.number().positive().optional().nullable(),
|
||||
favoritesForUser: z.array(z.string()),
|
||||
draft: z.boolean(),
|
||||
listingsCategory: ListingsCategoryEnum,
|
||||
|
|
@ -276,10 +276,12 @@ export const BusinessListingSchema = z
|
|||
leasedLocation: z.boolean().optional().nullable(),
|
||||
franchiseResale: z.boolean().optional().nullable(),
|
||||
salesRevenue: z.number().positive().nullable(),
|
||||
cashFlow: z.number().positive().max(100000000),
|
||||
supportAndTraining: z.string().min(5),
|
||||
cashFlow: z.number().optional().nullable(),
|
||||
ffe: z.number().optional().nullable(),
|
||||
inventory: z.number().optional().nullable(),
|
||||
supportAndTraining: z.string().min(5).optional().nullable(),
|
||||
employees: z.number().int().positive().max(100000).optional().nullable(),
|
||||
established: z.number().int().min(1800).max(2030).optional().nullable(),
|
||||
established: z.number().int().min(1).max(250).optional().nullable(),
|
||||
internalListingNumber: z.number().int().positive().optional().nullable(),
|
||||
reasonForSale: z.string().min(5).optional().nullable(),
|
||||
brokerLicencing: z.string().optional().nullable(),
|
||||
|
|
@ -324,9 +326,10 @@ export const CommercialPropertyListingSchema = z
|
|||
title: z.string().min(10),
|
||||
description: z.string().min(10),
|
||||
location: GeoSchema,
|
||||
price: z.number().positive(),
|
||||
price: z.number().positive().optional().nullable(),
|
||||
favoritesForUser: z.array(z.string()),
|
||||
listingsCategory: ListingsCategoryEnum,
|
||||
internalListingNumber: z.number().int().positive().optional().nullable(),
|
||||
draft: z.boolean(),
|
||||
imageOrder: z.array(z.string()),
|
||||
imagePath: z.string().nullable().optional(),
|
||||
|
|
|
|||
|
|
@ -69,11 +69,11 @@ export interface ListCriteria {
|
|||
state: string;
|
||||
city: GeoResult;
|
||||
prompt: string;
|
||||
sortBy: SortByOptions;
|
||||
searchType: 'exact' | 'radius';
|
||||
// radius: '5' | '20' | '50' | '100' | '200' | '300' | '400' | '500';
|
||||
radius: number;
|
||||
criteriaType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
|
||||
sortBy?: SortByOptions;
|
||||
}
|
||||
export interface BusinessListingCriteria extends ListCriteria {
|
||||
minPrice: number;
|
||||
|
|
@ -84,13 +84,13 @@ export interface BusinessListingCriteria extends ListCriteria {
|
|||
maxCashFlow: number;
|
||||
minNumberEmployees: number;
|
||||
maxNumberEmployees: number;
|
||||
establishedSince: number;
|
||||
establishedUntil: number;
|
||||
establishedMin: number;
|
||||
realEstateChecked: boolean;
|
||||
leasedLocation: boolean;
|
||||
franchiseResale: boolean;
|
||||
title: string;
|
||||
brokerName: string;
|
||||
email: string;
|
||||
criteriaType: 'businessListings';
|
||||
}
|
||||
export interface CommercialPropertyListingCriteria extends ListCriteria {
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
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);
|
||||
// }
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
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 {}
|
||||
|
|
@ -1,216 +0,0 @@
|
|||
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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import { ImageType, KeyValue, KeyValueAsSortBy, KeyValueStyle } from '../models/
|
|||
export class SelectOptionsService {
|
||||
constructor() {}
|
||||
public typesOfBusiness: Array<KeyValueStyle> = [
|
||||
{ name: 'Automotive', value: 'automotive', oldValue: '1', icon: 'fa-solid fa-car', textColorClass: 'text-green-400' },
|
||||
{ name: 'Automotive', value: 'automotive', oldValue: '1', icon: 'fa-solid fa-car', textColorClass: 'text-green-500' },
|
||||
{ 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: 'Real Estate', value: 'realEstate', oldValue: '3', icon: 'fa-solid fa-building', textColorClass: 'text-blue-400' },
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, asc, count, desc, eq, ilike, inArray, or, SQL, sql } from 'drizzle-orm';
|
||||
import { and, asc, count, desc, eq, inArray, or, SQL, sql } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres/driver';
|
||||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||||
import { Logger } from 'winston';
|
||||
|
|
@ -9,7 +9,7 @@ import { FileService } from '../file/file.service';
|
|||
import { GeoService } from '../geo/geo.service';
|
||||
import { User, UserSchema } from '../models/db.model';
|
||||
import { createDefaultUser, emailToDirName, JwtUser, UserListingCriteria } from '../models/main.model';
|
||||
import { DrizzleUser, getDistanceQuery, splitName } from '../utils';
|
||||
import { getDistanceQuery, splitName } from '../utils';
|
||||
|
||||
type CustomerSubType = (typeof customerSubTypeEnum.enumValues)[number];
|
||||
@Injectable()
|
||||
|
|
@ -23,45 +23,45 @@ export class UserService {
|
|||
|
||||
private getWhereConditions(criteria: UserListingCriteria): SQL[] {
|
||||
const whereConditions: SQL[] = [];
|
||||
whereConditions.push(eq(schema.users.customerType, 'professional'));
|
||||
whereConditions.push(sql`(${schema.users_json.data}->>'customerType') = 'professional'`);
|
||||
if (criteria.city && criteria.searchType === 'exact') {
|
||||
whereConditions.push(sql`${schema.users.location}->>'name' ilike ${criteria.city.name}`);
|
||||
whereConditions.push(sql`(${schema.users_json.data}->'location'->>'name') ILIKE ${criteria.city.name}`);
|
||||
}
|
||||
if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) {
|
||||
const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name);
|
||||
whereConditions.push(sql`${getDistanceQuery(schema.users, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`);
|
||||
whereConditions.push(sql`${getDistanceQuery(schema.users_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`);
|
||||
}
|
||||
if (criteria.types && criteria.types.length > 0) {
|
||||
// whereConditions.push(inArray(schema.users.customerSubType, criteria.types));
|
||||
whereConditions.push(inArray(schema.users.customerSubType, criteria.types as CustomerSubType[]));
|
||||
whereConditions.push(inArray(sql`${schema.users_json.data}->>'customerSubType'`, criteria.types as CustomerSubType[]));
|
||||
}
|
||||
|
||||
if (criteria.brokerName) {
|
||||
const { firstname, lastname } = splitName(criteria.brokerName);
|
||||
whereConditions.push(or(ilike(schema.users.firstname, `%${firstname}%`), ilike(schema.users.lastname, `%${lastname}%`)));
|
||||
whereConditions.push(sql`(${schema.users_json.data}->>'firstname') ILIKE ${`%${firstname}%`} OR (${schema.users_json.data}->>'lastname') ILIKE ${`%${lastname}%`}`);
|
||||
}
|
||||
|
||||
if (criteria.companyName) {
|
||||
whereConditions.push(ilike(schema.users.companyName, `%${criteria.companyName}%`));
|
||||
whereConditions.push(sql`(${schema.users_json.data}->>'companyName') ILIKE ${`%${criteria.companyName}%`}`);
|
||||
}
|
||||
|
||||
if (criteria.counties && criteria.counties.length > 0) {
|
||||
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}%`})`)));
|
||||
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}%`})`)));
|
||||
}
|
||||
|
||||
if (criteria.state) {
|
||||
whereConditions.push(sql`EXISTS (SELECT 1 FROM jsonb_array_elements(${schema.users.areasServed}) AS area WHERE area->>'state' = ${criteria.state})`);
|
||||
whereConditions.push(sql`EXISTS (SELECT 1 FROM jsonb_array_elements(${schema.users_json.data}->'areasServed') AS area WHERE area->>'state' = ${criteria.state})`);
|
||||
}
|
||||
|
||||
//never show user which denied
|
||||
whereConditions.push(eq(schema.users.showInDirectory, true));
|
||||
whereConditions.push(sql`(${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE`);
|
||||
|
||||
return whereConditions;
|
||||
}
|
||||
async searchUserListings(criteria: UserListingCriteria): Promise<{ results: User[]; totalCount: number }> {
|
||||
const start = criteria.start ? criteria.start : 0;
|
||||
const length = criteria.length ? criteria.length : 12;
|
||||
const query = this.conn.select().from(schema.users);
|
||||
const query = this.conn.select().from(schema.users_json);
|
||||
const whereConditions = this.getWhereConditions(criteria);
|
||||
|
||||
if (whereConditions.length > 0) {
|
||||
|
|
@ -71,10 +71,10 @@ export class UserService {
|
|||
// Sortierung
|
||||
switch (criteria.sortBy) {
|
||||
case 'nameAsc':
|
||||
query.orderBy(asc(schema.users.lastname));
|
||||
query.orderBy(asc(sql`${schema.users_json.data}->>'lastname'`));
|
||||
break;
|
||||
case 'nameDesc':
|
||||
query.orderBy(desc(schema.users.lastname));
|
||||
query.orderBy(desc(sql`${schema.users_json.data}->>'lastname'`));
|
||||
break;
|
||||
default:
|
||||
// Keine spezifische Sortierung, Standardverhalten kann hier eingefügt werden
|
||||
|
|
@ -84,7 +84,7 @@ export class UserService {
|
|||
query.limit(length).offset(start);
|
||||
|
||||
const data = await query;
|
||||
const results = data;
|
||||
const results = data.map(u => ({ id: u.id, email: u.email, ...(u.data as User) }) as User);
|
||||
const totalCount = await this.getUserListingsCount(criteria);
|
||||
|
||||
return {
|
||||
|
|
@ -93,7 +93,7 @@ export class UserService {
|
|||
};
|
||||
}
|
||||
async getUserListingsCount(criteria: UserListingCriteria): Promise<number> {
|
||||
const countQuery = this.conn.select({ value: count() }).from(schema.users);
|
||||
const countQuery = this.conn.select({ value: count() }).from(schema.users_json);
|
||||
const whereConditions = this.getWhereConditions(criteria);
|
||||
|
||||
if (whereConditions.length > 0) {
|
||||
|
|
@ -105,35 +105,29 @@ export class UserService {
|
|||
return totalCount;
|
||||
}
|
||||
async getUserByMail(email: string, jwtuser?: JwtUser) {
|
||||
const users = (await this.conn
|
||||
.select()
|
||||
.from(schema.users)
|
||||
.where(sql`email = ${email}`)) as User[];
|
||||
const users = await this.conn.select().from(schema.users_json).where(eq(schema.users_json.email, email));
|
||||
if (users.length === 0) {
|
||||
const user: User = { id: undefined, customerType: 'professional', ...createDefaultUser(email, '', '', null) };
|
||||
const u = await this.saveUser(user, false);
|
||||
return u;
|
||||
} else {
|
||||
const user = users[0];
|
||||
const user = { id: users[0].id, email: users[0].email, ...(users[0].data as User) } as User;
|
||||
user.hasCompanyLogo = this.fileService.hasCompanyLogo(emailToDirName(user.email));
|
||||
user.hasProfile = this.fileService.hasProfile(emailToDirName(user.email));
|
||||
return user;
|
||||
}
|
||||
}
|
||||
async getUserById(id: string) {
|
||||
const users = (await this.conn
|
||||
.select()
|
||||
.from(schema.users)
|
||||
.where(sql`id = ${id}`)) as User[];
|
||||
const users = await this.conn.select().from(schema.users_json).where(eq(schema.users_json.id, id));
|
||||
|
||||
const user = users[0];
|
||||
const user = { id: users[0].id, email: users[0].email, ...(users[0].data as User) } as User;
|
||||
user.hasCompanyLogo = this.fileService.hasCompanyLogo(emailToDirName(user.email));
|
||||
user.hasProfile = this.fileService.hasProfile(emailToDirName(user.email));
|
||||
return user;
|
||||
}
|
||||
async getAllUser() {
|
||||
const users = await this.conn.select().from(schema.users);
|
||||
return users;
|
||||
const users = await this.conn.select().from(schema.users_json);
|
||||
return users.map(u => ({ id: u.id, email: u.email, ...(u.data as User) }) as User);
|
||||
}
|
||||
async saveUser(user: User, processValidation = true): Promise<User> {
|
||||
try {
|
||||
|
|
@ -148,13 +142,14 @@ export class UserService {
|
|||
validatedUser = UserSchema.parse(user);
|
||||
}
|
||||
//const drizzleUser = convertUserToDrizzleUser(validatedUser);
|
||||
const drizzleUser = validatedUser as DrizzleUser;
|
||||
const { id: _, ...rest } = validatedUser;
|
||||
const drizzleUser = { email: user.email, data: rest };
|
||||
if (user.id) {
|
||||
const [updateUser] = await this.conn.update(schema.users).set(drizzleUser).where(eq(schema.users.id, user.id)).returning();
|
||||
return updateUser as User;
|
||||
const [updateUser] = await this.conn.update(schema.users_json).set(drizzleUser).where(eq(schema.users_json.id, user.id)).returning();
|
||||
return { id: updateUser.id, email: updateUser.email, ...(updateUser.data as User) } as User;
|
||||
} else {
|
||||
const [newUser] = await this.conn.insert(schema.users).values(drizzleUser).returning();
|
||||
return newUser as User;
|
||||
const [newUser] = await this.conn.insert(schema.users_json).values(drizzleUser).returning();
|
||||
return { id: newUser.id, email: newUser.email, ...(newUser.data as User) } as User;
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { sql } from 'drizzle-orm';
|
||||
import { businesses, commercials, users } from './drizzle/schema';
|
||||
import { businesses, businesses_json, commercials, commercials_json, users, users_json } from './drizzle/schema';
|
||||
export const EARTH_RADIUS_KM = 6371; // Erdradius in Kilometern
|
||||
export const EARTH_RADIUS_MILES = 3959; // Erdradius in Meilen
|
||||
export function convertStringToNullUndefined(value) {
|
||||
|
|
@ -16,21 +16,13 @@ export function convertStringToNullUndefined(value) {
|
|||
return value;
|
||||
}
|
||||
|
||||
export const getDistanceQuery = (schema: typeof businesses | typeof commercials | typeof users, lat: number, lon: number, unit: 'km' | 'miles' = 'miles') => {
|
||||
export const getDistanceQuery = (schema: typeof businesses_json | typeof commercials_json | typeof users_json, lat: number, lon: number, unit: 'km' | 'miles' = '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`
|
||||
${radius} * 2 * ASIN(SQRT(
|
||||
POWER(SIN((${lat} - (${schema.location}->>'latitude')::float) * PI() / 180 / 2), 2) +
|
||||
COS(${lat} * PI() / 180) * COS((${schema.location}->>'latitude')::float * PI() / 180) *
|
||||
POWER(SIN((${lon} - (${schema.location}->>'longitude')::float) * PI() / 180 / 2), 2)
|
||||
POWER(SIN((${lat} - (${schema.data}->'location'->>'latitude')::float) * PI() / 180 / 2), 2) +
|
||||
COS(${lat} * PI() / 180) * COS((${schema.data}->'location'->>'latitude')::float * PI() / 180) *
|
||||
POWER(SIN((${lon} - (${schema.data}->'location'->>'longitude')::float) * PI() / 180 / 2), 2)
|
||||
))
|
||||
`;
|
||||
};
|
||||
|
|
@ -38,121 +30,7 @@ export const getDistanceQuery = (schema: typeof businesses | typeof commercials
|
|||
export type DrizzleUser = typeof users.$inferSelect;
|
||||
export type DrizzleBusinessListing = typeof businesses.$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 } {
|
||||
const parts = fullName.trim().split(/\s+/); // Teile den Namen am Leerzeichen auf
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,10 @@
|
|||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
},
|
||||
"src/favicon.ico",
|
||||
"src/assets"
|
||||
],
|
||||
|
|
@ -67,6 +71,17 @@
|
|||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"prod": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"optimization": true,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"prebuild": "node version.js",
|
||||
"build": "node version.js && ng build",
|
||||
"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",
|
||||
"test": "ng test",
|
||||
"serve:ssr:bizmatch": "node dist/bizmatch/server/server.mjs"
|
||||
|
|
@ -51,6 +52,8 @@
|
|||
"ngx-sharebuttons": "^15.0.3",
|
||||
"ngx-stripe": "^18.1.0",
|
||||
"on-change": "^5.0.1",
|
||||
"posthog-js": "^1.259.0",
|
||||
"quill": "2.0.2",
|
||||
"rxjs": "~7.8.1",
|
||||
"tslib": "^2.6.3",
|
||||
"urlcat": "^3.1.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"/bizmatch": {
|
||||
"target": "http://localhost:3000",
|
||||
"target": "http://localhost:3001",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "debug"
|
||||
|
|
|
|||
|
|
@ -3,10 +3,16 @@
|
|||
@if (actualRoute !=='home' && actualRoute !=='login' && actualRoute!=='emailVerification' && actualRoute!=='email-authorized'){
|
||||
<header></header>
|
||||
}
|
||||
<main class="flex-1">
|
||||
<main class="flex-1 flex">
|
||||
@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>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<app-footer></app-footer>
|
||||
</div>
|
||||
|
||||
|
|
@ -35,5 +41,6 @@
|
|||
|
||||
<app-message-container></app-message-container>
|
||||
<app-search-modal></app-search-modal>
|
||||
<app-search-modal-commercial></app-search-modal-commercial>
|
||||
<app-confirmation></app-confirmation>
|
||||
<app-email></app-email>
|
||||
|
|
|
|||
|
|
@ -1,25 +1,3 @@
|
|||
// .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 {
|
||||
margin-top: 20px; /* Abstand zwischen Spinner und Text anpassen */
|
||||
font-size: 20px; /* Schriftgröße nach Bedarf anpassen */
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { EMailComponent } from './components/email/email.component';
|
|||
import { FooterComponent } from './components/footer/footer.component';
|
||||
import { HeaderComponent } from './components/header/header.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 { AuditService } from './services/audit.service';
|
||||
import { GeoService } from './services/geo.service';
|
||||
|
|
@ -19,7 +20,7 @@ import { UserService } from './services/user.service';
|
|||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterOutlet, HeaderComponent, FooterComponent, MessageContainerComponent, SearchModalComponent, ConfirmationComponent, EMailComponent],
|
||||
imports: [CommonModule, RouterOutlet, HeaderComponent, FooterComponent, MessageContainerComponent, SearchModalComponent, SearchModalCommercialComponent, ConfirmationComponent, EMailComponent],
|
||||
providers: [],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss',
|
||||
|
|
@ -47,42 +48,18 @@ export class AppComponent {
|
|||
this.actualRoute = currentRoute.snapshot.url[0].path;
|
||||
});
|
||||
}
|
||||
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,
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
ngOnInit() {}
|
||||
@HostListener('window:keydown', ['$event'])
|
||||
handleKeyboardEvent(event: KeyboardEvent) {
|
||||
if (event.shiftKey && event.ctrlKey && event.key === 'V') {
|
||||
this.showVersionDialog();
|
||||
}
|
||||
}
|
||||
|
||||
showVersionDialog() {
|
||||
this.confirmationService.showConfirmation({ message: `App Version: ${this.build.timestamp}`, buttons: 'none' });
|
||||
}
|
||||
isFilterRoute(): boolean {
|
||||
const filterRoutes = ['/businessListings', '/commercialPropertyListings', '/brokerListings'];
|
||||
return filterRoutes.includes(this.actualRoute);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { AuthInterceptor } from './interceptors/auth.interceptor';
|
|||
import { LoadingInterceptor } from './interceptors/loading.interceptor';
|
||||
import { TimeoutInterceptor } from './interceptors/timeout.interceptor';
|
||||
import { GlobalErrorHandler } from './services/globalErrorHandler';
|
||||
import { POSTHOG_INIT_PROVIDER } from './services/posthog.factory';
|
||||
import { SelectOptionsService } from './services/select-options.service';
|
||||
import { createLogger } from './utils/utils';
|
||||
// provideClientHydration()
|
||||
|
|
@ -67,6 +68,7 @@ export const appConfig: ApplicationConfig = {
|
|||
anchorScrolling: 'enabled',
|
||||
}),
|
||||
),
|
||||
...(environment.production ? [POSTHOG_INIT_PROVIDER] : []),
|
||||
provideAnimations(),
|
||||
provideNgxStripe('pk_test_IlpbVQhxAXZypLgnCHOCqlj8'),
|
||||
provideQuillConfig({
|
||||
|
|
|
|||
|
|
@ -1,37 +1,28 @@
|
|||
<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">
|
||||
<a routerLink="/home" class="flex items-center space-x-3 rtl:space-x-reverse">
|
||||
<img src="assets/images/header-logo.png" class="h-8" alt="Flowbite Logo" />
|
||||
<img src="assets/images/header-logo.png" class="h-10" alt="Flowbite Logo" />
|
||||
</a>
|
||||
<div class="flex items-center md:order-2 space-x-3 rtl:space-x-reverse">
|
||||
<!-- Filter button -->
|
||||
@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">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
(click)="toggleSortDropdown()"
|
||||
[ngClass]="{ 'text-blue-500': selectOptions.getSortByOption(criteria?.sortBy) !== 'Sort', 'text-gray-900': selectOptions.getSortByOption(criteria?.sortBy) === 'Sort' }"
|
||||
[ngClass]="{ 'text-blue-500': selectOptions.getSortByOption(sortBy) !== 'Sort', 'text-gray-900': selectOptions.getSortByOption(sortBy) === 'Sort' }"
|
||||
>
|
||||
<i class="fas fa-sort mr-2"></i>{{ selectOptions.getSortByOption(criteria?.sortBy) }}
|
||||
<i class="fas fa-sort mr-2"></i>{{ selectOptions.getSortByOption(sortBy) }}
|
||||
</button>
|
||||
|
||||
<!-- 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">
|
||||
<ul class="py-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
@for(item of sortByOptions; track item){
|
||||
<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)="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>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -217,23 +208,14 @@
|
|||
</div>
|
||||
<!-- Mobile filter button -->
|
||||
<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
|
||||
(click)="toggleSortDropdown()"
|
||||
type="button"
|
||||
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"
|
||||
[ngClass]="{ 'text-blue-500': selectOptions.getSortByOption(criteria?.sortBy) !== 'Sort', 'text-gray-900': selectOptions.getSortByOption(criteria?.sortBy) === 'Sort' }"
|
||||
[ngClass]="{ 'text-blue-500': selectOptions.getSortByOption(sortBy) !== 'Sort', 'text-gray-900': selectOptions.getSortByOption(sortBy) === 'Sort' }"
|
||||
>
|
||||
<i class="fas fa-sort mr-2"></i>{{ selectOptions.getSortByOption(criteria?.sortBy) }}
|
||||
<i class="fas fa-sort mr-2"></i>{{ selectOptions.getSortByOption(sortBy) }}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -1,25 +1,26 @@
|
|||
import { BreakpointObserver } from '@angular/cdk/layout';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, HostListener } from '@angular/core';
|
||||
import { Component, HostListener, OnDestroy, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NavigationEnd, Router, RouterModule } from '@angular/router';
|
||||
import { faUserGear } from '@fortawesome/free-solid-svg-icons';
|
||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||
import { Collapse, Dropdown, initFlowbite } from 'flowbite';
|
||||
import { debounceTime, filter, Observable, Subject, Subscription } from 'rxjs';
|
||||
import { filter, Observable, Subject, takeUntil } from 'rxjs';
|
||||
|
||||
import { SortByOptions, User } from '../../../../../bizmatch-server/src/models/db.model';
|
||||
import { BusinessListingCriteria, CommercialPropertyListingCriteria, emailToDirName, KeycloakUser, KeyValueAsSortBy, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
|
||||
import { emailToDirName, KeycloakUser, KeyValueAsSortBy, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { CriteriaChangeService } from '../../services/criteria-change.service';
|
||||
import { FilterStateService } from '../../services/filter-state.service';
|
||||
import { ListingsService } from '../../services/listings.service';
|
||||
import { SearchService } from '../../services/search.service';
|
||||
import { SelectOptionsService } from '../../services/select-options.service';
|
||||
import { SharedService } from '../../services/shared.service';
|
||||
import { UserService } from '../../services/user.service';
|
||||
import { assignProperties, compareObjects, createEmptyBusinessListingCriteria, createEmptyCommercialPropertyListingCriteria, createEmptyUserListingCriteria, getCriteriaProxy, map2User } from '../../utils/utils';
|
||||
import { map2User } from '../../utils/utils';
|
||||
import { DropdownComponent } from '../dropdown/dropdown.component';
|
||||
import { ModalService } from '../search-modal/modal.service';
|
||||
|
||||
@UntilDestroy()
|
||||
@Component({
|
||||
selector: 'header',
|
||||
|
|
@ -28,7 +29,7 @@ import { ModalService } from '../search-modal/modal.service';
|
|||
templateUrl: './header.component.html',
|
||||
styleUrl: './header.component.scss',
|
||||
})
|
||||
export class HeaderComponent {
|
||||
export class HeaderComponent implements OnInit, OnDestroy {
|
||||
public buildVersion = environment.buildVersion;
|
||||
user$: Observable<KeycloakUser>;
|
||||
keycloakUser: KeycloakUser;
|
||||
|
|
@ -41,26 +42,31 @@ export class HeaderComponent {
|
|||
isMobile: boolean = false;
|
||||
private destroy$ = new Subject<void>();
|
||||
prompt: string;
|
||||
private subscription: Subscription;
|
||||
criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
|
||||
private routerSubscription: Subscription | undefined;
|
||||
baseRoute: string;
|
||||
sortDropdownVisible: boolean;
|
||||
|
||||
// Aktueller Listing-Typ basierend auf Route
|
||||
currentListingType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings' | null = null;
|
||||
|
||||
// Sortierung
|
||||
sortDropdownVisible: boolean = false;
|
||||
sortByOptions: KeyValueAsSortBy[] = [];
|
||||
sortBy: SortByOptions = null;
|
||||
|
||||
// Observable für Anzahl der Listings
|
||||
numberOfBroker$: Observable<number>;
|
||||
numberOfCommercial$: Observable<number>;
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private userService: UserService,
|
||||
private sharedService: SharedService,
|
||||
private breakpointObserver: BreakpointObserver,
|
||||
private modalService: ModalService,
|
||||
private searchService: SearchService,
|
||||
private criteriaChangeService: CriteriaChangeService,
|
||||
private filterStateService: FilterStateService,
|
||||
public selectOptions: SelectOptionsService,
|
||||
public authService: AuthService,
|
||||
private listingService: ListingsService,
|
||||
) {}
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
handleGlobalClick(event: Event) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
|
@ -68,66 +74,125 @@ export class HeaderComponent {
|
|||
this.sortDropdownVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
// User Setup
|
||||
const token = await this.authService.getToken();
|
||||
this.keycloakUser = map2User(token);
|
||||
if (this.keycloakUser) {
|
||||
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.numberOfBroker$ = this.userService.getNumberOfBroker(createEmptyUserListingCriteria());
|
||||
this.numberOfCommercial$ = this.listingService.getNumberOfListings(createEmptyCommercialPropertyListingCriteria(), 'commercialProperty');
|
||||
|
||||
// Lade Anzahl der Listings
|
||||
this.numberOfBroker$ = this.userService.getNumberOfBroker(this.createEmptyUserListingCriteria());
|
||||
this.numberOfCommercial$ = this.listingService.getNumberOfListings('commercialProperty');
|
||||
|
||||
// Flowbite initialisieren
|
||||
setTimeout(() => {
|
||||
initFlowbite();
|
||||
}, 10);
|
||||
|
||||
this.sharedService.currentProfilePhoto.subscribe(photoUrl => {
|
||||
// Profile Photo Updates
|
||||
this.sharedService.currentProfilePhoto.pipe(untilDestroyed(this)).subscribe(photoUrl => {
|
||||
this.profileUrl = photoUrl;
|
||||
});
|
||||
|
||||
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.subscription = this.criteriaChangeService.criteriaChange$.pipe(debounceTime(400)).subscribe(() => {
|
||||
this.criteria = getCriteriaProxy(this.baseRoute, this);
|
||||
});
|
||||
// User Updates
|
||||
this.userService.currentUser.pipe(untilDestroyed(this)).subscribe(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 {
|
||||
this.baseRoute = url.split('/')[1]; // Nimmt den ersten Teil der Route nach dem ersten '/'
|
||||
const specialRoutes = [, '', ''];
|
||||
this.criteria = getCriteriaProxy(this.baseRoute, this);
|
||||
// this.searchService.search(this.criteria);
|
||||
const baseRoute = url.split('/')[1];
|
||||
|
||||
// Bestimme den aktuellen Listing-Typ
|
||||
if (baseRoute === 'businessListings') {
|
||||
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 = [];
|
||||
if (this.isProfessionalListing()) {
|
||||
this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => s.type === 'professional')];
|
||||
}
|
||||
if (this.isBusinessListing()) {
|
||||
this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => s.type === 'business' || s.type === 'listing')];
|
||||
}
|
||||
if (this.isCommercialPropertyListing()) {
|
||||
this.sortByOptions = [...this.sortByOptions, ...this.selectOptions.sortByOptions.filter(s => s.type === 'commercial' || s.type === 'listing')];
|
||||
|
||||
if (!this.currentListingType) return;
|
||||
|
||||
switch (this.currentListingType) {
|
||||
case 'brokerListings':
|
||||
this.sortByOptions = [...this.selectOptions.sortByOptions.filter(s => s.type === 'professional')];
|
||||
break;
|
||||
case 'businessListings':
|
||||
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)];
|
||||
}
|
||||
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() {
|
||||
const modalResult = await this.modalService.showModal(this.criteria);
|
||||
if (!this.currentListingType) return;
|
||||
|
||||
const criteria = this.filterStateService.getCriteria(this.currentListingType);
|
||||
const modalResult = await this.modalService.showModal(criteria);
|
||||
|
||||
if (modalResult.accepted) {
|
||||
this.searchService.search(this.criteria);
|
||||
} else {
|
||||
this.criteria = assignProperties(this.criteria, modalResult.criteria);
|
||||
this.searchService.search(this.currentListingType);
|
||||
}
|
||||
}
|
||||
|
||||
navigateWithState(dest: string, state: any) {
|
||||
this.router.navigate([dest], { state: state });
|
||||
}
|
||||
|
|
@ -135,21 +200,23 @@ export class HeaderComponent {
|
|||
isActive(route: string): boolean {
|
||||
return this.router.url === route;
|
||||
}
|
||||
|
||||
isFilterUrl(): boolean {
|
||||
return ['/businessListings', '/commercialPropertyListings', '/brokerListings'].includes(this.router.url);
|
||||
}
|
||||
|
||||
isBusinessListing(): boolean {
|
||||
return ['/businessListings'].includes(this.router.url);
|
||||
return this.router.url === '/businessListings';
|
||||
}
|
||||
|
||||
isCommercialPropertyListing(): boolean {
|
||||
return ['/commercialPropertyListings'].includes(this.router.url);
|
||||
return this.router.url === '/commercialPropertyListings';
|
||||
}
|
||||
|
||||
isProfessionalListing(): boolean {
|
||||
return ['/brokerListings'].includes(this.router.url);
|
||||
return this.router.url === '/brokerListings';
|
||||
}
|
||||
// isSortingUrl(): boolean {
|
||||
// return ['/businessListings', '/commercialPropertyListings'].includes(this.router.url);
|
||||
// }
|
||||
|
||||
closeDropdown() {
|
||||
const dropdownButton = document.getElementById('user-menu-button');
|
||||
const dropdownMenu = this.user ? document.getElementById('user-login') : document.getElementById('user-unknown');
|
||||
|
|
@ -159,6 +226,7 @@ export class HeaderComponent {
|
|||
dropdown.hide();
|
||||
}
|
||||
}
|
||||
|
||||
closeMobileMenu() {
|
||||
const targetElement = document.getElementById('navbar-user');
|
||||
const triggerElement = document.querySelector('[data-collapse-toggle="navbar-user"]');
|
||||
|
|
@ -168,39 +236,60 @@ export class HeaderComponent {
|
|||
collapse.collapse();
|
||||
}
|
||||
}
|
||||
|
||||
closeMenusAndSetCriteria(path: string) {
|
||||
this.closeDropdown();
|
||||
this.closeMobileMenu();
|
||||
const criteria = getCriteriaProxy(path, this);
|
||||
criteria.page = 1;
|
||||
criteria.start = 0;
|
||||
|
||||
// Bestimme Listing-Typ aus dem Pfad
|
||||
let listingType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings' | null = null;
|
||||
|
||||
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() {
|
||||
this.destroy$.next();
|
||||
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;
|
||||
}
|
||||
get isProfessional() {
|
||||
return this.user?.customerType === 'professional';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// 1. Shared Service (modal.service.ts)
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import { BusinessListingCriteria, CommercialPropertyListingCriteria, ModalResult, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
|
||||
|
|
@ -7,28 +6,33 @@ import { BusinessListingCriteria, CommercialPropertyListingCriteria, ModalResult
|
|||
providedIn: 'root',
|
||||
})
|
||||
export class ModalService {
|
||||
private modalVisibleSubject = new BehaviorSubject<boolean>(false);
|
||||
private modalVisibleSubject = new BehaviorSubject<{ visible: boolean; type?: string }>({ visible: false });
|
||||
private messageSubject = new BehaviorSubject<BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria>(null);
|
||||
private resolvePromise!: (value: ModalResult) => void;
|
||||
|
||||
modalVisible$: Observable<boolean> = this.modalVisibleSubject.asObservable();
|
||||
modalVisible$: Observable<{ visible: boolean; type?: string }> = this.modalVisibleSubject.asObservable();
|
||||
message$: Observable<BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria> = this.messageSubject.asObservable();
|
||||
|
||||
showModal(message: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria): Promise<ModalResult> {
|
||||
this.messageSubject.next(message);
|
||||
this.modalVisibleSubject.next(true);
|
||||
this.modalVisibleSubject.next({ visible: true, type: message.criteriaType });
|
||||
return new Promise<ModalResult>(resolve => {
|
||||
this.resolvePromise = resolve;
|
||||
});
|
||||
}
|
||||
sendCriteria(message: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria): Promise<ModalResult> {
|
||||
this.messageSubject.next(message);
|
||||
return new Promise<ModalResult>(resolve => {
|
||||
this.resolvePromise = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
accept(): void {
|
||||
this.modalVisibleSubject.next(false);
|
||||
this.modalVisibleSubject.next({ visible: false });
|
||||
this.resolvePromise({ accepted: true });
|
||||
}
|
||||
|
||||
reject(backupCriteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria): void {
|
||||
this.modalVisibleSubject.next(false);
|
||||
this.modalVisibleSubject.next({ visible: false });
|
||||
this.resolvePromise({ accepted: false, criteria: backupCriteria });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,222 @@
|
|||
<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>
|
||||
|
|
@ -0,0 +1,301 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,12 @@
|
|||
<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">
|
||||
<div class="relative w-full max-w-4xl max-h-full">
|
||||
<div
|
||||
*ngIf="isModal && (modalService.modalVisible$ | async)?.visible && (modalService.modalVisible$ | async)?.type === 'businessListings'"
|
||||
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="flex items-start justify-between p-4 border-b rounded-t">
|
||||
@if(criteria.criteriaType==='businessListings'){
|
||||
<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">
|
||||
<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">Business 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>
|
||||
|
|
@ -18,59 +15,71 @@
|
|||
</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">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> -->
|
||||
<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>
|
||||
@if(criteria.criteriaType==='businessListings'){
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- 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.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>
|
||||
<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>
|
||||
<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>
|
||||
<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">
|
||||
<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" />
|
||||
<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" value="radius" />
|
||||
<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>
|
||||
<!-- 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">
|
||||
|
|
@ -79,78 +88,41 @@
|
|||
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"
|
||||
(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" 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"
|
||||
/> -->
|
||||
<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>
|
||||
<!-- <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>
|
||||
<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">
|
||||
<!-- <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>
|
||||
<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>
|
||||
<!-- <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>
|
||||
<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">
|
||||
<!-- <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>
|
||||
<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" 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"
|
||||
/> -->
|
||||
<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>
|
||||
|
|
@ -158,65 +130,37 @@
|
|||
<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"
|
||||
[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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
|
@ -224,37 +168,32 @@
|
|||
<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"
|
||||
[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"
|
||||
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"
|
||||
[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="establishedSince" class="block mb-2 text-sm font-medium text-gray-900">Established Since</label>
|
||||
<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="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"
|
||||
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>
|
||||
|
|
@ -263,55 +202,88 @@
|
|||
<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"
|
||||
[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>
|
||||
} @if(criteria.criteriaType==='commercialPropertyListings'){
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
</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>
|
||||
<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>
|
||||
<!-- <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" />
|
||||
<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" value="radius" />
|
||||
<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>
|
||||
<!-- 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">
|
||||
|
|
@ -320,7 +292,7 @@
|
|||
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"
|
||||
(click)="setRadius(radius)"
|
||||
>
|
||||
{{ radius }}
|
||||
</button>
|
||||
|
|
@ -330,23 +302,29 @@
|
|||
<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">
|
||||
<!-- <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"
|
||||
/> -->
|
||||
<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>
|
||||
<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" placeholder="To" inputClasses="bg-gray-50 text-sm !mt-0 p-2.5"></app-validated-price>
|
||||
<!-- <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" (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>
|
||||
|
|
@ -354,159 +332,84 @@
|
|||
<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"
|
||||
[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>
|
||||
<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.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>
|
||||
<label for="counties" class="block mb-2 text-sm font-medium text-gray-900">Locations served - Counties</label>
|
||||
<ng-select
|
||||
[items]="counties$ | async"
|
||||
class="custom"
|
||||
[items]="selectOptions.typesOfBusiness"
|
||||
bindLabel="name"
|
||||
class="custom"
|
||||
bindValue="value"
|
||||
[ngModel]="criteria.types"
|
||||
(ngModelChange)="onCategoryChange($event)"
|
||||
[multiple]="true"
|
||||
[hideSelected]="true"
|
||||
[trackByFn]="trackByFn"
|
||||
[minTermLength]="2"
|
||||
[loading]="countyLoading"
|
||||
typeToSearchText="Please enter 2 or more characters"
|
||||
[typeahead]="countyInput$"
|
||||
[(ngModel)]="criteria.counties"
|
||||
>
|
||||
</ng-select>
|
||||
[closeOnSelect]="true"
|
||||
placeholder="Select categories"
|
||||
></ng-select>
|
||||
</div>
|
||||
<div>
|
||||
<!-- <label for="city" class="block mb-2 text-sm font-medium text-gray-900">Location - City</label>
|
||||
<label class="block mb-2 text-sm font-medium text-gray-900">Type of Property</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>
|
||||
[items]="propertyTypeOptions"
|
||||
bindLabel="name"
|
||||
bindValue="value"
|
||||
[ngModel]="selectedPropertyType"
|
||||
(ngModelChange)="onPropertyTypeChange($event)"
|
||||
placeholder="Select property type"
|
||||
></ng-select>
|
||||
</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>
|
||||
<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="brokername" class="block mb-2 text-sm font-medium text-gray-900">Name of Professional</label>
|
||||
<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"
|
||||
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"
|
||||
[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>
|
||||
<!-- 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 }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</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.customerSubTypes; track tob){
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="automotive"
|
||||
[ngModel]="isTypeOfProfessionalClicked(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>
|
||||
}
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
:host ::ng-deep .ng-select.custom .ng-select-container {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
|
||||
height: 46px;
|
||||
min-height: 46px;
|
||||
border-radius: 0.5rem;
|
||||
.ng-value-container .ng-input {
|
||||
top: 10px;
|
||||
}
|
||||
}
|
||||
:host ::ng-deep .ng-select.ng-select-multiple .ng-select-container .ng-value-container .ng-placeholder {
|
||||
position: unset;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,20 @@
|
|||
import { AsyncPipe, NgIf } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||
import { NgSelectModule } from '@ng-select/ng-select';
|
||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||
import { catchError, concat, debounceTime, distinctUntilChanged, map, Observable, of, Subject, Subscription, switchMap, tap } from 'rxjs';
|
||||
import { BusinessListingCriteria, CommercialPropertyListingCriteria, CountyResult, GeoResult, KeyValue, KeyValueStyle, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
|
||||
import { CriteriaChangeService } from '../../services/criteria-change.service';
|
||||
import { catchError, concat, debounceTime, distinctUntilChanged, map, Observable, of, Subject, switchMap, takeUntil, tap } from 'rxjs';
|
||||
import { BusinessListingCriteria, CountyResult, GeoResult, KeyValue, KeyValueStyle } 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 { UserService } from '../../services/user.service';
|
||||
import { SharedModule } from '../../shared/shared/shared.module';
|
||||
import { resetBusinessListingCriteria, resetCommercialPropertyListingCriteria, resetUserListingCriteria } from '../../utils/utils';
|
||||
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',
|
||||
|
|
@ -22,55 +23,105 @@ import { ModalService } from './modal.service';
|
|||
templateUrl: './search-modal.component.html',
|
||||
styleUrl: './search-modal.component.scss',
|
||||
})
|
||||
export class SearchModalComponent {
|
||||
// cities$: Observable<GeoResult[]>;
|
||||
export class SearchModalComponent implements OnInit, OnDestroy {
|
||||
@Input() isModal: boolean = true;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
private searchDebounce$ = new Subject<void>();
|
||||
|
||||
// State
|
||||
criteria: BusinessListingCriteria;
|
||||
backupCriteria: any;
|
||||
currentListingType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
|
||||
|
||||
// Geo search
|
||||
counties$: Observable<CountyResult[]>;
|
||||
// cityLoading = false;
|
||||
countyLoading = false;
|
||||
// cityInput$ = new Subject<string>();
|
||||
countyInput$ = new Subject<string>();
|
||||
private criteriaChangeSubscription: Subscription;
|
||||
public criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
|
||||
backupCriteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria;
|
||||
|
||||
// Property type for business listings
|
||||
selectedPropertyType: string | null = null;
|
||||
propertyTypeOptions = [
|
||||
{ name: 'Real Estate', value: 'realEstateChecked' },
|
||||
{ name: 'Leased Location', value: 'leasedLocation' },
|
||||
{ name: 'Franchise', value: 'franchiseResale' },
|
||||
];
|
||||
|
||||
// Results count
|
||||
numberOfResults$: Observable<number>;
|
||||
cancelDisable = false;
|
||||
|
||||
constructor(
|
||||
public selectOptions: SelectOptionsService,
|
||||
public modalService: ModalService,
|
||||
private geoService: GeoService,
|
||||
private criteriaChangeService: CriteriaChangeService,
|
||||
private filterStateService: FilterStateService,
|
||||
private listingService: ListingsService,
|
||||
private userService: UserService,
|
||||
private searchService: SearchService,
|
||||
) {}
|
||||
ngOnInit() {
|
||||
this.setupCriteriaChangeListener();
|
||||
this.modalService.message$.pipe(untilDestroyed(this)).subscribe(msg => {
|
||||
this.criteria = msg;
|
||||
this.backupCriteria = JSON.parse(JSON.stringify(msg));
|
||||
this.setTotalNumberOfResults();
|
||||
|
||||
ngOnInit(): void {
|
||||
// Load counties
|
||||
this.loadCounties();
|
||||
|
||||
if (this.isModal) {
|
||||
// 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 => {
|
||||
if (val) {
|
||||
if (val.visible) {
|
||||
// Reset pagination when modal opens
|
||||
if (this.criteria) {
|
||||
this.criteria.page = 1;
|
||||
this.criteria.start = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
// this.loadCities();
|
||||
this.loadCounties();
|
||||
} else {
|
||||
// Embedded mode: Determine type from route and subscribe to state
|
||||
this.determineListingType();
|
||||
this.subscribeToStateChanges();
|
||||
}
|
||||
|
||||
ngOnChanges() {}
|
||||
categoryClicked(checked: boolean, value: string) {
|
||||
if (checked) {
|
||||
this.criteria.types.push(value);
|
||||
} else {
|
||||
const index = this.criteria.types.findIndex(t => t === value);
|
||||
if (index > -1) {
|
||||
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(
|
||||
of([]), // default items
|
||||
this.countyInput$.pipe(
|
||||
|
|
@ -78,87 +129,314 @@ export class SearchModalComponent {
|
|||
tap(() => (this.countyLoading = true)),
|
||||
switchMap(term =>
|
||||
this.geoService.findCountiesStartingWith(term).pipe(
|
||||
catchError(() => of([])), // empty list on error
|
||||
map(counties => counties.map(county => county.name)), // transform the list of objects to a list of city names
|
||||
catchError(() => of([])),
|
||||
map(counties => counties.map(county => county.name)),
|
||||
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) {
|
||||
this.criteria.city = city;
|
||||
this.criteria.state = city.state;
|
||||
updates.city = city;
|
||||
updates.state = city.state;
|
||||
} else {
|
||||
this.criteria.city = null;
|
||||
this.criteria.radius = null;
|
||||
this.criteria.searchType = 'exact';
|
||||
updates.city = null;
|
||||
updates.radius = null;
|
||||
updates.searchType = 'exact';
|
||||
}
|
||||
this.updateCriteria(updates);
|
||||
}
|
||||
setState(state: string) {
|
||||
if (state) {
|
||||
this.criteria.state = state;
|
||||
} else {
|
||||
this.criteria.state = null;
|
||||
this.setCity(null);
|
||||
|
||||
setRadius(radius: number): void {
|
||||
this.updateCriteria({ radius });
|
||||
}
|
||||
|
||||
onCriteriaChange(): void {
|
||||
this.triggerSearch();
|
||||
}
|
||||
private setupCriteriaChangeListener() {
|
||||
this.criteriaChangeSubscription = this.criteriaChangeService.criteriaChange$.pipe(debounceTime(400)).subscribe(() => {
|
||||
|
||||
// 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.updateSelectedPropertyType();
|
||||
this.setTotalNumberOfResults();
|
||||
this.cancelDisable = true;
|
||||
});
|
||||
}
|
||||
trackByFn(item: GeoResult) {
|
||||
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();
|
||||
// Embedded: Use state service
|
||||
this.filterStateService.clearFilters(this.currentListingType);
|
||||
}
|
||||
}
|
||||
}
|
||||
clearFilter() {
|
||||
if (this.criteria.criteriaType === 'businessListings') {
|
||||
resetBusinessListingCriteria(this.criteria);
|
||||
} else if (this.criteria.criteriaType === 'commercialPropertyListings') {
|
||||
resetCommercialPropertyListingCriteria(this.criteria);
|
||||
} else {
|
||||
resetUserListingCriteria(this.criteria);
|
||||
|
||||
// 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() {
|
||||
|
||||
close(): void {
|
||||
if (this.isModal) {
|
||||
// Discard changes
|
||||
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;
|
||||
}
|
||||
|
||||
// Aktivieren Sie nur die aktuell ausgewählte Checkbox
|
||||
this.criteria[checkbox] = value;
|
||||
// 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;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,13 +149,34 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent {
|
|||
}
|
||||
const result = [
|
||||
{ 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: 'Asking Price', value: `${this.listing.price ? `$${this.listing.price.toLocaleString()}` : ''}` },
|
||||
{ label: 'Sales revenue', value: `${this.listing.salesRevenue ? `$${this.listing.salesRevenue.toLocaleString()}` : ''}` },
|
||||
{ label: 'Cash flow', value: `${this.listing.cashFlow ? `$${this.listing.cashFlow.toLocaleString()}` : ''}` },
|
||||
{
|
||||
label: 'Located in',
|
||||
value: `${this.listing.location.name ? this.listing.location.name : this.listing.location.county ? this.listing.location.county : ''} ${
|
||||
this.listing.location.name || this.listing.location.county ? ', ' : ''
|
||||
}${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: 'Employees', value: this.listing.employees },
|
||||
{ label: 'Established since', value: this.listing.established },
|
||||
{ label: 'Years established', value: this.listing.established },
|
||||
{ label: 'Support & Training', value: this.listing.supportAndTraining },
|
||||
{ label: 'Reason for Sale', value: this.listing.reasonForSale },
|
||||
{ label: 'Broker licensing', value: this.listing.brokerLicencing },
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@
|
|||
@if(this.images.length>0){
|
||||
<h2 class="text-xl font-semibold">Contact the Author of this Listing</h2>
|
||||
}@else {
|
||||
<div class="md:mt-[-60px] text-2xl font-bold mb-4">Contact the Author of this Listing</div>
|
||||
<div class="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>
|
||||
<form class="space-y-4">
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
:host ::ng-deep p {
|
||||
display: block;
|
||||
// margin-top: 1em;
|
||||
// margin-bottom: 1em;
|
||||
//margin-top: 1em;
|
||||
//margin-bottom: 1em;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
font-size: 1rem; /* oder 1rem, abhängig vom Browser und den Standardeinstellungen */
|
||||
line-height: 1.5;
|
||||
min-height: 1.5em;
|
||||
}
|
||||
:host ::ng-deep h1 {
|
||||
display: block;
|
||||
|
|
|
|||
|
|
@ -37,14 +37,26 @@
|
|||
<!-- ==== ANPASSUNGEN START ==== -->
|
||||
<!-- 1. px-4 für <main> (vorher px-2 sm:px-4) -->
|
||||
<main class="flex flex-col items-center justify-center px-4 w-full flex-grow">
|
||||
<div class="bg-cover-custom py-12 md:py-20 lg:py-32 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)]">
|
||||
<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">
|
||||
<!-- 3. Für Mobile: m-2 statt max-w-xs; ab sm: wieder max-width und kein Margin -->
|
||||
<div class="w-full m-2 sm:m-0 sm:max-w-md md:max-w-xl lg:max-w-2xl xl:max-w-3xl">
|
||||
<!-- Schriftgrößen angepasst (wie vorher) -->
|
||||
<h1 class="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold text-blue-900 mb-3 sm:mb-4 text-center">Find businesses for sale.</h1>
|
||||
<!-- Abstand unten angepasst (wie vorher) -->
|
||||
<p class="text-base md:text-lg lg:text-xl text-blue-600 mb-6 md:mb-8 text-center">Unlocking Exclusive Opportunities - Empowering Entrepreneurial Dreams</p>
|
||||
<!-- Hero-Container -->
|
||||
<section class="relative">
|
||||
<!-- Dein Hintergrundbild liegt hier per CSS oder absolutem <img> -->
|
||||
|
||||
<!-- 1) Overlay: sorgt für Kontrast auf hellem Himmel -->
|
||||
<div aria-hidden="true" class="pointer-events-none absolute inset-0"></div>
|
||||
|
||||
<!-- 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){
|
||||
|
|
@ -155,7 +167,7 @@
|
|||
</div>
|
||||
}
|
||||
<div class="bg-blue-600 hover:bg-blue-500 transition-colors duration-200 max-sm:rounded-md">
|
||||
@if(getNumberOfFiltersSet()>0 && numberOfResults$){
|
||||
@if( 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()">
|
||||
Search ({{ numberOfResults$ | async }})
|
||||
</button>
|
||||
|
|
@ -165,16 +177,6 @@
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
.bg-cover-custom {
|
||||
background-image: url('/assets/images/index-bg.webp');
|
||||
background-image: url('/assets/images/flags_bg.avif');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border-radius: 20px;
|
||||
|
|
|
|||
|
|
@ -3,30 +3,22 @@ import { ChangeDetectorRef, Component, ElementRef, ViewChild } from '@angular/co
|
|||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { NgSelectModule } from '@ng-select/ng-select';
|
||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||
import { UntilDestroy } from '@ngneat/until-destroy';
|
||||
import { initFlowbite } from 'flowbite';
|
||||
import { catchError, concat, debounceTime, distinctUntilChanged, lastValueFrom, Observable, of, Subject, Subscription, switchMap, tap } from 'rxjs';
|
||||
import { catchError, concat, distinctUntilChanged, Observable, of, Subject, switchMap, tap } from 'rxjs';
|
||||
import { BusinessListingCriteria, CityAndStateResult, CommercialPropertyListingCriteria, GeoResult, KeycloakUser, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
|
||||
import { ModalService } from '../../components/search-modal/modal.service';
|
||||
import { TooltipComponent } from '../../components/tooltip/tooltip.component';
|
||||
import { AiService } from '../../services/ai.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { CriteriaChangeService } from '../../services/criteria-change.service';
|
||||
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 { UserService } from '../../services/user.service';
|
||||
import {
|
||||
assignProperties,
|
||||
compareObjects,
|
||||
createEmptyBusinessListingCriteria,
|
||||
createEmptyCommercialPropertyListingCriteria,
|
||||
createEmptyUserListingCriteria,
|
||||
createEnhancedProxy,
|
||||
getCriteriaStateObject,
|
||||
map2User,
|
||||
} from '../../utils/utils';
|
||||
import { map2User } from '../../utils/utils';
|
||||
|
||||
@UntilDestroy()
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
|
|
@ -50,7 +42,6 @@ export class HomeComponent {
|
|||
cityLoading = false;
|
||||
cityInput$ = new Subject<string>();
|
||||
cityOrState = undefined;
|
||||
private criteriaChangeSubscription: Subscription;
|
||||
numberOfResults$: Observable<number>;
|
||||
numberOfBroker$: Observable<number>;
|
||||
numberOfCommercial$: Observable<number>;
|
||||
|
|
@ -59,126 +50,156 @@ export class HomeComponent {
|
|||
aiSearchFailed = false;
|
||||
loadingAi = false;
|
||||
@ViewChild('aiSearchInput', { static: false }) searchInput!: ElementRef;
|
||||
typingSpeed: number = 100; // Geschwindigkeit des Tippens (ms)
|
||||
pauseTime: number = 2000; // Pausezeit, bevor der Text verschwindet (ms)
|
||||
typingSpeed: number = 100;
|
||||
pauseTime: number = 2000;
|
||||
index: number = 0;
|
||||
charIndex: number = 0;
|
||||
typingInterval: any;
|
||||
showInput: boolean = true; // Steuerung der Anzeige des Eingabefelds
|
||||
showInput: boolean = true;
|
||||
tooltipTargetBeta = 'tooltipTargetBeta';
|
||||
public constructor(
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private modalService: ModalService,
|
||||
private searchService: SearchService,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
public selectOptions: SelectOptionsService,
|
||||
|
||||
private criteriaChangeService: CriteriaChangeService,
|
||||
private geoService: GeoService,
|
||||
public cdRef: ChangeDetectorRef,
|
||||
private listingService: ListingsService,
|
||||
private userService: UserService,
|
||||
private aiService: AiService,
|
||||
private authService: AuthService,
|
||||
private filterStateService: FilterStateService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
setTimeout(() => {
|
||||
initFlowbite();
|
||||
}, 0);
|
||||
this.numberOfBroker$ = this.userService.getNumberOfBroker(createEmptyUserListingCriteria());
|
||||
this.numberOfCommercial$ = this.listingService.getNumberOfListings(createEmptyCommercialPropertyListingCriteria(), 'commercialProperty');
|
||||
|
||||
// Clear all filters and sort options on initial load
|
||||
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();
|
||||
sessionStorage.removeItem('businessListings');
|
||||
sessionStorage.removeItem('commercialPropertyListings');
|
||||
sessionStorage.removeItem('brokerListings');
|
||||
this.criteria = createEnhancedProxy(getCriteriaStateObject('businessListings'), this);
|
||||
this.user = map2User(token);
|
||||
this.loadCities();
|
||||
this.setupCriteriaChangeListener();
|
||||
this.setTotalNumberOfResults();
|
||||
}
|
||||
async changeTab(tabname: 'business' | 'commercialProperty' | 'broker') {
|
||||
|
||||
changeTab(tabname: 'business' | 'commercialProperty' | 'broker') {
|
||||
this.activeTabAction = tabname;
|
||||
this.cityOrState = null;
|
||||
if ('business' === tabname) {
|
||||
this.criteria = createEnhancedProxy(getCriteriaStateObject('businessListings'), this);
|
||||
} else if ('commercialProperty' === tabname) {
|
||||
this.criteria = createEnhancedProxy(getCriteriaStateObject('commercialPropertyListings'), this);
|
||||
} else if ('broker' === tabname) {
|
||||
this.criteria = createEnhancedProxy(getCriteriaStateObject('brokerListings'), this);
|
||||
} else {
|
||||
this.criteria = undefined;
|
||||
}
|
||||
const tabToListingType = {
|
||||
business: 'businessListings',
|
||||
commercialProperty: 'commercialPropertyListings',
|
||||
broker: 'brokerListings',
|
||||
};
|
||||
this.criteria = this.filterStateService.getCriteria(tabToListingType[tabname] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings');
|
||||
this.setTotalNumberOfResults();
|
||||
}
|
||||
|
||||
search() {
|
||||
this.router.navigate([`${this.activeTabAction}Listings`]);
|
||||
}
|
||||
private setupCriteriaChangeListener() {
|
||||
this.criteriaChangeSubscription = this.criteriaChangeService.criteriaChange$.pipe(untilDestroyed(this), debounceTime(400)).subscribe(() => this.setTotalNumberOfResults());
|
||||
}
|
||||
|
||||
toggleMenu() {
|
||||
this.isMenuOpen = !this.isMenuOpen;
|
||||
}
|
||||
|
||||
onTypesChange(value) {
|
||||
if (value === '') {
|
||||
// Wenn keine Option ausgewählt ist, setzen Sie types zurück auf ein leeres Array
|
||||
this.criteria.types = [];
|
||||
} else {
|
||||
this.criteria.types = [value];
|
||||
}
|
||||
const tabToListingType = {
|
||||
business: 'businessListings',
|
||||
commercialProperty: 'commercialPropertyListings',
|
||||
broker: 'brokerListings',
|
||||
};
|
||||
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) {
|
||||
if (value === 'null') {
|
||||
// Wenn keine Option ausgewählt ist, setzen Sie types zurück auf ein leeres Array
|
||||
this.criteria.radius = null;
|
||||
} else {
|
||||
this.criteria.radius = parseInt(value);
|
||||
}
|
||||
const tabToListingType = {
|
||||
business: 'businessListings',
|
||||
commercialProperty: 'commercialPropertyListings',
|
||||
broker: 'brokerListings',
|
||||
};
|
||||
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() {
|
||||
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);
|
||||
if (accepted) {
|
||||
this.router.navigate([`${this.activeTabAction}Listings`]);
|
||||
}
|
||||
}
|
||||
|
||||
private loadCities() {
|
||||
this.cities$ = concat(
|
||||
of([]), // default items
|
||||
of([]),
|
||||
this.cityInput$.pipe(
|
||||
distinctUntilChanged(),
|
||||
tap(() => (this.cityLoading = true)),
|
||||
switchMap(term =>
|
||||
//this.geoService.findCitiesStartingWith(term).pipe(
|
||||
this.geoService.findCitiesAndStatesStartingWith(term).pipe(
|
||||
catchError(() => of([])), // empty list on error
|
||||
// map(cities => cities.map(city => city.city)), // transform the list of objects to a list of city names
|
||||
catchError(() => of([])),
|
||||
tap(() => (this.cityLoading = false)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
trackByFn(item: GeoResult) {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
setCityOrState(cityOrState: CityAndStateResult) {
|
||||
const tabToListingType = {
|
||||
business: 'businessListings',
|
||||
commercialProperty: 'commercialPropertyListings',
|
||||
broker: 'brokerListings',
|
||||
};
|
||||
const listingType = tabToListingType[this.activeTabAction] as 'businessListings' | 'commercialPropertyListings' | 'brokerListings';
|
||||
|
||||
if (cityOrState) {
|
||||
if (cityOrState.type === 'state') {
|
||||
this.criteria.state = cityOrState.content.state_code;
|
||||
this.filterStateService.updateCriteria(listingType, { state: cityOrState.content.state_code, city: null, radius: null, searchType: 'exact' });
|
||||
} else {
|
||||
this.criteria.city = cityOrState.content as GeoResult;
|
||||
this.criteria.state = cityOrState.content.state;
|
||||
this.criteria.searchType = 'radius';
|
||||
this.criteria.radius = 20;
|
||||
this.filterStateService.updateCriteria(listingType, {
|
||||
city: cityOrState.content as GeoResult,
|
||||
state: cityOrState.content.state,
|
||||
searchType: 'radius',
|
||||
radius: 20,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.criteria.state = null;
|
||||
this.criteria.city = null;
|
||||
this.criteria.radius = null;
|
||||
this.criteria.searchType = 'exact';
|
||||
this.filterStateService.updateCriteria(listingType, { state: null, city: null, radius: null, searchType: 'exact' });
|
||||
}
|
||||
this.criteria = this.filterStateService.getCriteria(listingType);
|
||||
this.setTotalNumberOfResults();
|
||||
}
|
||||
|
||||
getTypes() {
|
||||
if (this.criteria.criteriaType === 'businessListings') {
|
||||
return this.selectOptions.typesOfBusiness;
|
||||
|
|
@ -188,6 +209,7 @@ export class HomeComponent {
|
|||
return this.selectOptions.customerSubTypes;
|
||||
}
|
||||
}
|
||||
|
||||
getPlaceholderLabel() {
|
||||
if (this.criteria.criteriaType === 'businessListings') {
|
||||
return 'Business Type';
|
||||
|
|
@ -197,117 +219,28 @@ export class HomeComponent {
|
|||
return 'Professional Type';
|
||||
}
|
||||
}
|
||||
|
||||
setTotalNumberOfResults() {
|
||||
if (this.criteria) {
|
||||
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') {
|
||||
this.numberOfResults$ = this.listingService.getNumberOfListings(this.criteria, this.criteria.criteriaType === 'businessListings' ? 'business' : 'commercialProperty');
|
||||
this.numberOfResults$ = this.listingService.getNumberOfListings(this.criteria.criteriaType === 'businessListings' ? 'business' : 'commercialProperty');
|
||||
} else if (this.criteria.criteriaType === 'brokerListings') {
|
||||
this.numberOfResults$ = this.userService.getNumberOfBroker(this.criteria);
|
||||
this.numberOfResults$ = this.userService.getNumberOfBroker(this.filterStateService.getCriteria('brokerListings') as UserListingCriteria);
|
||||
} else {
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,7 +85,6 @@
|
|||
<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>
|
||||
<button (click)="openFilterModal()" class="w-full px-3 py-2 bg-indigo-600 hover:bg-indigo-700 transition-all duration-500 rounded-full text-white text-xs font-semibold leading-4">Change Filter</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { CommonModule, NgOptimizedImage } from '@angular/common';
|
|||
import { ChangeDetectorRef, Component } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||
import { BusinessListing, User } from '../../../../../../bizmatch-server/src/models/db.model';
|
||||
import { UntilDestroy } from '@ngneat/until-destroy';
|
||||
import { BusinessListing, SortByOptions, User } from '../../../../../../bizmatch-server/src/models/db.model';
|
||||
import { LISTINGS_PER_PAGE, ListingType, UserListingCriteria, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { CustomerSubTypeComponent } from '../../../components/customer-sub-type/customer-sub-type.component';
|
||||
|
|
@ -45,6 +45,7 @@ export class BrokerListingsComponent {
|
|||
emailToDirName = emailToDirName;
|
||||
page = 1;
|
||||
pageCount = 1;
|
||||
sortBy: SortByOptions = null; // Neu: Separate Property
|
||||
constructor(
|
||||
public selectOptions: SelectOptionsService,
|
||||
private listingsService: ListingsService,
|
||||
|
|
@ -60,12 +61,16 @@ export class BrokerListingsComponent {
|
|||
) {
|
||||
this.criteria = getCriteriaProxy('brokerListings', this) as UserListingCriteria;
|
||||
this.init();
|
||||
this.searchService.currentCriteria.pipe(untilDestroyed(this)).subscribe(criteria => {
|
||||
if (criteria && criteria.criteriaType === 'brokerListings') {
|
||||
this.criteria = criteria as UserListingCriteria;
|
||||
this.search();
|
||||
this.loadSortBy();
|
||||
// this.searchService.currentCriteria.pipe(untilDestroyed(this)).subscribe(({ criteria }) => {
|
||||
// if (criteria.criteriaType === 'brokerListings') {
|
||||
// this.search();
|
||||
// }
|
||||
// });
|
||||
}
|
||||
});
|
||||
private loadSortBy() {
|
||||
const storedSortBy = sessionStorage.getItem('professionalsSortBy');
|
||||
this.sortBy = storedSortBy && storedSortBy !== 'null' ? (storedSortBy as SortByOptions) : null;
|
||||
}
|
||||
async ngOnInit() {}
|
||||
async init() {
|
||||
|
|
@ -101,14 +106,14 @@ export class BrokerListingsComponent {
|
|||
this.criteriaChangeService.notifyCriteriaChange();
|
||||
|
||||
// Search with cleared filters
|
||||
this.searchService.search(this.criteria);
|
||||
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(this.criteria);
|
||||
this.searchService.search('brokerListings');
|
||||
} else {
|
||||
this.criteria = assignProperties(this.criteria, modalResult.criteria);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,50 +1,69 @@
|
|||
<div class="container mx-auto p-4">
|
||||
@if(listings?.length>0){
|
||||
<div class="flex flex-col md:flex-row">
|
||||
<!-- 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">
|
||||
<!-- Anzahl der Spalten auf 3 reduziert und den Abstand erhöht -->
|
||||
@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">
|
||||
<!-- Hover-Effekt hinzugefügt -->
|
||||
<div class="p-6 flex flex-col h-full relative z-[0]">
|
||||
<div class="flex items-center mb-4">
|
||||
<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>
|
||||
<!-- Schriftgröße erhöht -->
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold mb-4">
|
||||
<!-- Überschrift vergrößert -->
|
||||
{{ 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>
|
||||
}
|
||||
</h2>
|
||||
<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">
|
||||
{{ selectOptions.getState(listing.location.state) }}
|
||||
</span>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
<strong>{{ getDaysListed(listing) }} days listed</strong>
|
||||
</p>
|
||||
</div>
|
||||
<!-- Asking Price hervorgehoben -->
|
||||
<p class="text-base font-bold text-gray-800 mb-2">
|
||||
<strong>Asking price:</strong> <span class="text-green-600"> {{ listing.price | currency : 'USD' : 'symbol' : '1.0-0' }}</span>
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 mb-2"><strong>Sales revenue:</strong> {{ listing.salesRevenue | currency : 'USD' : 'symbol' : '1.0-0' }}</p>
|
||||
<p class="text-sm text-gray-600 mb-2"><strong>Net profit:</strong> {{ listing.cashFlow | currency : 'USD' : 'symbol' : '1.0-0' }}</p>
|
||||
<p class="text-sm text-gray-600 mb-2"><strong>Location:</strong> {{ listing.location.name ? listing.location.name : listing.location.county }}</p>
|
||||
<p class="text-sm text-gray-600 mb-4"><strong>Established:</strong> {{ listing.established }}</p>
|
||||
|
||||
@if (getListingBadge(listing); as badge) {
|
||||
<span
|
||||
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>
|
||||
|
||||
<p class="text-base font-bold text-gray-800 mb-2">
|
||||
<strong>Asking price:</strong>
|
||||
<span class="text-green-600">
|
||||
{{ listing?.price != null ? (listing.price | currency : 'USD' : 'symbol' : '1.0-0') : 'undisclosed' }}
|
||||
</span>
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 mb-2">
|
||||
<strong>Sales revenue:</strong>
|
||||
{{ listing?.salesRevenue != null ? (listing.salesRevenue | currency : 'USD' : 'symbol' : '1.0-0') : 'undisclosed' }}
|
||||
</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" />
|
||||
<!-- Position und Größe des Bildes angepasst -->
|
||||
<div class="flex-grow"></div>
|
||||
<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"
|
||||
[routerLink]="['/details-business-listing', listing.id]"
|
||||
>
|
||||
<!-- Button-Größe und Hover-Effekt verbessert -->
|
||||
View Full Listing
|
||||
<i class="fas fa-arrow-right ml-2"></i>
|
||||
</button>
|
||||
|
|
@ -52,7 +71,7 @@
|
|||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else if (listings?.length===0){
|
||||
} @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">
|
||||
|
|
@ -77,7 +96,7 @@
|
|||
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"
|
||||
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
|
||||
|
|
@ -100,14 +119,17 @@
|
|||
<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>
|
||||
<button (click)="openFilterModal()" class="w-full px-3 py-2 bg-indigo-600 hover:bg-indigo-700 transition-all duration-500 rounded-full text-white text-xs font-semibold leading-4">Change Filter</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if(pageCount > 1) {
|
||||
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if(pageCount>1){
|
||||
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator>
|
||||
}
|
||||
<!-- 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>
|
||||
|
|
|
|||
|
|
@ -1,119 +1,171 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { ChangeDetectorRef, Component } from '@angular/core';
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||
import { UntilDestroy } from '@ngneat/until-destroy';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import { BusinessListing } from '../../../../../../bizmatch-server/src/models/db.model';
|
||||
import { BusinessListing, SortByOptions } from '../../../../../../bizmatch-server/src/models/db.model';
|
||||
import { BusinessListingCriteria, LISTINGS_PER_PAGE, ListingType, emailToDirName } from '../../../../../../bizmatch-server/src/models/main.model';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { PaginatorComponent } from '../../../components/paginator/paginator.component';
|
||||
import { ModalService } from '../../../components/search-modal/modal.service';
|
||||
import { CriteriaChangeService } from '../../../services/criteria-change.service';
|
||||
import { SearchModalComponent } from '../../../components/search-modal/search-modal.component';
|
||||
import { FilterStateService } from '../../../services/filter-state.service';
|
||||
import { ImageService } from '../../../services/image.service';
|
||||
import { ListingsService } from '../../../services/listings.service';
|
||||
import { SearchService } from '../../../services/search.service';
|
||||
import { SelectOptionsService } from '../../../services/select-options.service';
|
||||
import { assignProperties, getCriteriaProxy, resetBusinessListingCriteria } from '../../../utils/utils';
|
||||
|
||||
@UntilDestroy()
|
||||
@Component({
|
||||
selector: 'app-business-listings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent],
|
||||
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, SearchModalComponent],
|
||||
templateUrl: './business-listings.component.html',
|
||||
styleUrls: ['./business-listings.component.scss', '../../pages.scss'],
|
||||
})
|
||||
export class BusinessListingsComponent {
|
||||
export class BusinessListingsComponent implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
// Component properties
|
||||
environment = environment;
|
||||
listings: Array<BusinessListing>;
|
||||
filteredListings: Array<BusinessListing>;
|
||||
criteria: BusinessListingCriteria;
|
||||
realEstateChecked: boolean;
|
||||
maxPrice: string;
|
||||
minPrice: string;
|
||||
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;
|
||||
listings: Array<BusinessListing> = [];
|
||||
filteredListings: Array<ListingType> = [];
|
||||
criteria: BusinessListingCriteria;
|
||||
sortBy: SortByOptions | null = null;
|
||||
|
||||
// Pagination
|
||||
totalRecords = 0;
|
||||
page = 1;
|
||||
pageCount = 1;
|
||||
first = 0;
|
||||
rows = LISTINGS_PER_PAGE;
|
||||
|
||||
// UI state
|
||||
ts = new Date().getTime();
|
||||
emailToDirName = emailToDirName;
|
||||
|
||||
constructor(
|
||||
public selectOptions: SelectOptionsService,
|
||||
private listingsService: ListingsService,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private router: Router,
|
||||
private cdRef: ChangeDetectorRef,
|
||||
private imageService: ImageService,
|
||||
private route: ActivatedRoute,
|
||||
private searchService: SearchService,
|
||||
private modalService: ModalService,
|
||||
private criteriaChangeService: CriteriaChangeService,
|
||||
) {
|
||||
this.criteria = getCriteriaProxy('businessListings', this) as BusinessListingCriteria;
|
||||
this.init();
|
||||
this.searchService.currentCriteria.pipe(untilDestroyed(this)).subscribe(criteria => {
|
||||
if (criteria && criteria.criteriaType === 'businessListings') {
|
||||
this.criteria = criteria as BusinessListingCriteria;
|
||||
private filterStateService: FilterStateService,
|
||||
private route: ActivatedRoute,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Subscribe to state changes
|
||||
this.filterStateService
|
||||
.getState$('businessListings')
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.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();
|
||||
}
|
||||
});
|
||||
}
|
||||
async ngOnInit() {
|
||||
this.search();
|
||||
}
|
||||
async init() {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
async search() {
|
||||
const listingReponse = await this.listingsService.getListings(this.criteria, 'business');
|
||||
this.listings = listingReponse.results;
|
||||
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;
|
||||
this.page = this.criteria.page ? this.criteria.page : 1;
|
||||
async search(): Promise<void> {
|
||||
try {
|
||||
// Get current criteria from service
|
||||
this.criteria = this.filterStateService.getCriteria('businessListings') as BusinessListingCriteria;
|
||||
|
||||
// Add sortBy if available
|
||||
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.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) {}
|
||||
reset() {
|
||||
this.criteria.title = null;
|
||||
|
||||
onPageChange(page: number): void {
|
||||
// Update only pagination properties
|
||||
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) {
|
||||
return dayjs().diff(listing.created, 'day');
|
||||
}
|
||||
// New methods for filter actions
|
||||
clearAllFilters() {
|
||||
// Reset criteria to default values
|
||||
resetBusinessListingCriteria(this.criteria);
|
||||
|
||||
// Reset pagination
|
||||
this.criteria.page = 1;
|
||||
this.criteria.start = 0;
|
||||
|
||||
this.criteriaChangeService.notifyCriteriaChange();
|
||||
|
||||
// Search with cleared filters
|
||||
this.searchService.search(this.criteria);
|
||||
}
|
||||
|
||||
async openFilterModal() {
|
||||
// Open the search modal with current criteria
|
||||
const modalResult = await this.modalService.showModal(this.criteria);
|
||||
if (modalResult.accepted) {
|
||||
this.searchService.search(this.criteria);
|
||||
} else {
|
||||
this.criteria = assignProperties(this.criteria, modalResult.criteria);
|
||||
}
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
<div class="container mx-auto px-4 py-8">
|
||||
@if(listings?.length>0){
|
||||
<div class="flex flex-col md:flex-row">
|
||||
<!-- 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">
|
||||
@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">
|
||||
|
|
@ -34,7 +42,7 @@
|
|||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else if (listings?.length===0){
|
||||
} @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">
|
||||
|
|
@ -82,13 +90,17 @@
|
|||
<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>
|
||||
<button (click)="openFilterModal()" class="w-full px-3 py-2 bg-indigo-600 hover:bg-indigo-700 transition-all duration-500 rounded-full text-white text-xs font-semibold leading-4">Change 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>
|
||||
@if(pageCount>1){
|
||||
<app-paginator [page]="page" [pageCount]="pageCount" (pageChange)="onPageChange($event)"></app-paginator>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,117 +1,165 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { ChangeDetectorRef, Component } from '@angular/core';
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
|
||||
import { UntilDestroy } from '@ngneat/until-destroy';
|
||||
import dayjs from 'dayjs';
|
||||
import { CommercialPropertyListing } from '../../../../../../bizmatch-server/src/models/db.model';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
import { CommercialPropertyListing, SortByOptions } from '../../../../../../bizmatch-server/src/models/db.model';
|
||||
import { CommercialPropertyListingCriteria, LISTINGS_PER_PAGE, ResponseCommercialPropertyListingArray } from '../../../../../../bizmatch-server/src/models/main.model';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { PaginatorComponent } from '../../../components/paginator/paginator.component';
|
||||
import { ModalService } from '../../../components/search-modal/modal.service';
|
||||
import { CriteriaChangeService } from '../../../services/criteria-change.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 { ListingsService } from '../../../services/listings.service';
|
||||
import { SearchService } from '../../../services/search.service';
|
||||
import { SelectOptionsService } from '../../../services/select-options.service';
|
||||
import { assignProperties, getCriteriaProxy, resetCommercialPropertyListingCriteria } from '../../../utils/utils';
|
||||
|
||||
@UntilDestroy()
|
||||
@Component({
|
||||
selector: 'app-commercial-property-listings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent],
|
||||
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, SearchModalCommercialComponent],
|
||||
templateUrl: './commercial-property-listings.component.html',
|
||||
styleUrls: ['./commercial-property-listings.component.scss', '../../pages.scss'],
|
||||
})
|
||||
export class CommercialPropertyListingsComponent {
|
||||
export class CommercialPropertyListingsComponent implements OnInit, OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
// Component properties
|
||||
environment = environment;
|
||||
listings: Array<CommercialPropertyListing>;
|
||||
filteredListings: Array<CommercialPropertyListing>;
|
||||
criteria: CommercialPropertyListingCriteria;
|
||||
realEstateChecked: boolean;
|
||||
first: number = 0;
|
||||
rows: number = 12;
|
||||
maxPrice: string;
|
||||
minPrice: string;
|
||||
type: string;
|
||||
statesSet = new Set();
|
||||
state: string;
|
||||
totalRecords: number = 0;
|
||||
env = environment;
|
||||
listings: Array<CommercialPropertyListing> = [];
|
||||
filteredListings: Array<CommercialPropertyListing> = [];
|
||||
criteria: CommercialPropertyListingCriteria;
|
||||
sortBy: SortByOptions | null = null;
|
||||
|
||||
// Pagination
|
||||
totalRecords = 0;
|
||||
page = 1;
|
||||
pageCount = 1;
|
||||
first = 0;
|
||||
rows = LISTINGS_PER_PAGE;
|
||||
|
||||
// UI state
|
||||
ts = new Date().getTime();
|
||||
|
||||
constructor(
|
||||
public selectOptions: SelectOptionsService,
|
||||
private listingsService: ListingsService,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private router: Router,
|
||||
private cdRef: ChangeDetectorRef,
|
||||
private imageService: ImageService,
|
||||
private route: ActivatedRoute,
|
||||
private searchService: SearchService,
|
||||
private modalService: ModalService,
|
||||
private criteriaChangeService: CriteriaChangeService,
|
||||
) {
|
||||
this.criteria = getCriteriaProxy('commercialPropertyListings', this) as CommercialPropertyListingCriteria;
|
||||
this.init();
|
||||
this.searchService.currentCriteria.pipe(untilDestroyed(this)).subscribe(criteria => {
|
||||
if (criteria && criteria.criteriaType === 'commercialPropertyListings') {
|
||||
this.criteria = criteria as CommercialPropertyListingCriteria;
|
||||
private filterStateService: FilterStateService,
|
||||
private route: ActivatedRoute,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Subscribe to state changes
|
||||
this.filterStateService
|
||||
.getState$('commercialPropertyListings')
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.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();
|
||||
}
|
||||
});
|
||||
}
|
||||
async ngOnInit() {}
|
||||
async init() {
|
||||
this.search();
|
||||
}
|
||||
async search() {
|
||||
const listingReponse = await this.listingsService.getListings(this.criteria, 'commercialProperty');
|
||||
this.listings = (<ResponseCommercialPropertyListingArray>listingReponse).results;
|
||||
this.totalRecords = (<ResponseCommercialPropertyListingArray>listingReponse).totalCount;
|
||||
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;
|
||||
|
||||
async search(): Promise<void> {
|
||||
try {
|
||||
// Perform search
|
||||
const listingResponse = await this.listingsService.getListings('commercialProperty');
|
||||
this.listings = (listingResponse as ResponseCommercialPropertyListingArray).results;
|
||||
this.totalRecords = (listingResponse as ResponseCommercialPropertyListingArray).totalCount;
|
||||
this.pageCount = Math.ceil(this.totalRecords / LISTINGS_PER_PAGE);
|
||||
this.page = this.criteria.page || 1;
|
||||
|
||||
// Update view
|
||||
this.cdRef.markForCheck();
|
||||
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() {
|
||||
this.criteria.title = null;
|
||||
|
||||
onPageChange(page: number): void {
|
||||
// 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();
|
||||
}
|
||||
getDaysListed(listing: CommercialPropertyListing) {
|
||||
|
||||
getDaysListed(listing: CommercialPropertyListing): number {
|
||||
return dayjs().diff(listing.created, 'day');
|
||||
}
|
||||
// New methods for filter actions
|
||||
clearAllFilters() {
|
||||
// Reset criteria to default values
|
||||
resetCommercialPropertyListingCriteria(this.criteria);
|
||||
|
||||
// Reset pagination
|
||||
this.criteria.page = 1;
|
||||
this.criteria.start = 0;
|
||||
|
||||
this.criteriaChangeService.notifyCriteriaChange();
|
||||
|
||||
// Search with cleared filters
|
||||
this.searchService.search(this.criteria);
|
||||
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';
|
||||
}
|
||||
|
||||
async openFilterModal() {
|
||||
// Open the search modal with current criteria
|
||||
const modalResult = await this.modalService.showModal(this.criteria);
|
||||
if (modalResult.accepted) {
|
||||
this.searchService.search(this.criteria);
|
||||
} else {
|
||||
this.criteria = assignProperties(this.criteria, modalResult.criteria);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,107 +17,35 @@
|
|||
</ng-select>
|
||||
</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>
|
||||
<app-validated-input label="Title of Listing" name="title" [(ngModel)]="listing.title"></app-validated-input>
|
||||
</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>
|
||||
<app-validated-quill label="Description" name="description" [(ngModel)]="listing.description"></app-validated-quill>
|
||||
</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>
|
||||
<app-validated-ng-select label="Type of business" name="type" [(ngModel)]="listing.type" [items]="typesOfBusiness"></app-validated-ng-select>
|
||||
</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">
|
||||
<!-- <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-price label="Price" name="price" [(ngModel)]="listing.price"></app-validated-price>
|
||||
</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">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- <div class="mb-4">
|
||||
<label for="cashFlow" class="block text-sm font-bold text-gray-700 mb-1">Cash Flow</label>
|
||||
<input
|
||||
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">
|
||||
<app-validated-input label="Established In" name="established" [(ngModel)]="listing.established" mask="0000" kind="number"></app-validated-input>
|
||||
<app-validated-price label="Furniture, Fixtures / Equipment Value (FFE)" name="ffe" [(ngModel)]="listing.ffe"></app-validated-price>
|
||||
<app-validated-price label="Inventory at Cost Value" name="inventory" [(ngModel)]="listing.inventory"></app-validated-price>
|
||||
</div>
|
||||
|
||||
<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="Employees" name="employees" [(ngModel)]="listing.employees" mask="0000" kind="number"></app-validated-input>
|
||||
</div>
|
||||
|
||||
|
|
@ -157,48 +85,23 @@
|
|||
</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">
|
||||
<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>
|
||||
</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>
|
||||
<!-- <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>
|
||||
<!-- @if(listingUser){ -->
|
||||
<ng-select [(ngModel)]="listing.brokerLicencing" name="brokerLicencing">
|
||||
@for (licensedIn of listingUser?.licensedIn; track listingUser?.licensedIn) {
|
||||
<ng-option [value]="licensedIn.registerNo">{{ licensedIn.state }} {{ licensedIn.registerNo }}</ng-option>
|
||||
}
|
||||
</ng-select>
|
||||
</div>
|
||||
<!-- } -->
|
||||
<app-validated-input label="Internal Listing Number" name="internalListingNumber" [(ngModel)]="listing.internalListingNumber" kind="number" mask="00000000000000000000"></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>
|
||||
<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>
|
||||
|
|
@ -221,162 +124,3 @@
|
|||
}
|
||||
</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> -->
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@
|
|||
</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>
|
||||
<app-validated-input label="Internal Listing Number" name="internalListingNumber" [(ngModel)]="listing.internalListingNumber" kind="number" mask="00000000000000000000"></app-validated-input>
|
||||
</div>
|
||||
<div class="container mx-auto pt-2">
|
||||
<div class="flex justify-center">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<div class="relative">
|
||||
|
|
@ -42,10 +45,8 @@
|
|||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mx-auto pt-2">
|
||||
<app-drag-drop-mixed [listing]="listing" [ts]="ts" (imageOrderChanged)="imageOrderChanged($event)" (imageToDelete)="deleteConfirm($event)"></app-drag-drop-mixed>
|
||||
<!-- </div> -->
|
||||
</div>
|
||||
|
||||
<div class="bg-white px-4 pb-4 rounded-lg shadow">
|
||||
|
|
|
|||
|
|
@ -4,29 +4,85 @@
|
|||
|
||||
<!-- Desktop view -->
|
||||
<div class="hidden md:block">
|
||||
<table class="w-full bg-white drop-shadow-inner-faint rounded-lg overflow-hidden">
|
||||
<table class="w-full table-fixed bg-white drop-shadow-inner-faint rounded-lg overflow-hidden">
|
||||
<colgroup>
|
||||
<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">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<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">Located in</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">Publication Status</th>
|
||||
<th class="py-2 px-4 text-left">Actions</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>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr *ngFor="let listing of myListings" class="border-b">
|
||||
<td class="py-2 px-4">{{ listing.title }}</td>
|
||||
<td class="py-2 px-4">{{ listing.listingsCategory === 'commercialProperty' ? 'Commercial Property' : 'Business' }}</td>
|
||||
<td class="py-2 px-4">{{ listing.location.name ? listing.location.name : listing.location.county }}, {{ listing.location.state }}</td>
|
||||
<td class="py-2 px-4">{{ listing.location.name ? listing.location.name : listing.location.county ? listing.location.county : this.selectOptions.getState(listing.location.state) }}</td>
|
||||
<td class="py-2 px-4">${{ listing.price.toLocaleString() }}</td>
|
||||
<td class="py-2 px-4 flex justify-center">
|
||||
{{ listing.internalListingNumber ?? '—' }}
|
||||
</td>
|
||||
<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">
|
||||
<td class="py-2 px-4 whitespace-nowrap">
|
||||
@if(listing.listingsCategory==='business'){
|
||||
<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">
|
||||
|
|
@ -57,11 +113,27 @@
|
|||
|
||||
<!-- Mobile view -->
|
||||
<div class="md:hidden">
|
||||
<!-- Mobile Filter -->
|
||||
<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>
|
||||
<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">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">
|
||||
|
|
@ -94,20 +166,7 @@
|
|||
</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"><<</button>
|
||||
<button class="px-2 py-1 border-t border-b bg-gray-100"><</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">></button>
|
||||
<button class="px-2 py-1 border rounded-r-md bg-gray-100">>></button>
|
||||
<select class="ml-2 border rounded-md px-2 py-1">
|
||||
<option>10</option>
|
||||
</select>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-confirmation></app-confirmation>
|
||||
|
|
|
|||
|
|
@ -21,9 +21,22 @@ import { map2User } from '../../../utils/utils';
|
|||
styleUrl: './my-listing.component.scss',
|
||||
})
|
||||
export class MyListingComponent {
|
||||
listings: Array<ListingType> = []; //dataListings as unknown as Array<BusinessListing>;
|
||||
myListings: Array<ListingType>;
|
||||
// Vollständige, ungefilterte Daten
|
||||
listings: Array<ListingType> = [];
|
||||
// Aktuell angezeigte (gefilterte) Daten
|
||||
myListings: Array<ListingType> = [];
|
||||
|
||||
user: User;
|
||||
|
||||
// VERY small filter state
|
||||
filters = {
|
||||
title: '',
|
||||
internalListingNumber: '',
|
||||
location: '',
|
||||
status: '' as '' | 'published' | 'draft',
|
||||
category: '' as '' | 'business' | 'commercialProperty', // <── NEU
|
||||
};
|
||||
|
||||
constructor(
|
||||
public userService: UserService,
|
||||
private listingsService: ListingsService,
|
||||
|
|
@ -33,23 +46,64 @@ export class MyListingComponent {
|
|||
private confirmationService: ConfirmationService,
|
||||
private authService: AuthService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
const token = await this.authService.getToken();
|
||||
const keycloakUser = map2User(token);
|
||||
const email = keycloakUser.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')]);
|
||||
this.myListings = [...result[0], ...result[1]];
|
||||
|
||||
const result = await Promise.all([this.listingsService.getListingsByEmail(this.user.email, 'business'), this.listingsService.getListingsByEmail(this.user.email, 'commercialProperty')]);
|
||||
|
||||
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) {
|
||||
if (listing.listingsCategory === 'business') {
|
||||
await this.listingsService.deleteBusinessListing(listing.id);
|
||||
} else {
|
||||
await this.listingsService.deleteCommercialPropertyListing(listing.id, (<CommercialPropertyListing>listing).imagePath);
|
||||
await this.listingsService.deleteCommercialPropertyListing(listing.id, (listing as CommercialPropertyListing).imagePath);
|
||||
}
|
||||
const result = await Promise.all([await this.listingsService.getListingsByEmail(this.user.email, 'business'), await this.listingsService.getListingsByEmail(this.user.email, 'commercialProperty')]);
|
||||
this.myListings = [...result[0], ...result[1]];
|
||||
const result = await Promise.all([this.listingsService.getListingsByEmail(this.user.email, 'business'), this.listingsService.getListingsByEmail(this.user.email, 'commercialProperty')]);
|
||||
this.listings = [...result[0], ...result[1]];
|
||||
this.applyFilters(); // Filter beibehalten nach Löschen
|
||||
}
|
||||
|
||||
async confirm(listing: ListingType) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,245 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -2,8 +2,9 @@ import { HttpClient } from '@angular/common/http';
|
|||
import { Injectable } from '@angular/core';
|
||||
import { Observable, lastValueFrom } from 'rxjs';
|
||||
import { BusinessListing, CommercialPropertyListing } from '../../../../bizmatch-server/src/models/db.model';
|
||||
import { BusinessListingCriteria, CommercialPropertyListingCriteria, ListingType, ResponseBusinessListingArray, ResponseCommercialPropertyListingArray } from '../../../../bizmatch-server/src/models/main.model';
|
||||
import { ListingType, ResponseBusinessListingArray, ResponseCommercialPropertyListingArray } from '../../../../bizmatch-server/src/models/main.model';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { getCriteriaByListingCategory, getSortByListingCategory } from '../utils/utils';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
|
|
@ -12,20 +13,21 @@ export class ListingsService {
|
|||
private apiBaseUrl = environment.apiBaseUrl;
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
async getListings(
|
||||
criteria: BusinessListingCriteria | CommercialPropertyListingCriteria,
|
||||
listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty',
|
||||
): Promise<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray> {
|
||||
const result = await lastValueFrom(this.http.post<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/find`, criteria));
|
||||
async getListings(listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty'): Promise<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray> {
|
||||
const criteria = getCriteriaByListingCategory(listingsCategory);
|
||||
const sortBy = getSortByListingCategory(listingsCategory);
|
||||
const body = { ...criteria, sortBy }; // Merge sortBy in Body
|
||||
const result = await lastValueFrom(this.http.post<ResponseBusinessListingArray | ResponseCommercialPropertyListingArray>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/find`, body));
|
||||
return result;
|
||||
}
|
||||
getNumberOfListings(criteria: BusinessListingCriteria | CommercialPropertyListingCriteria, listingsCategory: 'business' | 'commercialProperty'): Observable<number> {
|
||||
return this.http.post<number>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/findTotal`, criteria);
|
||||
}
|
||||
async getListingsByPrompt(criteria: BusinessListingCriteria | CommercialPropertyListingCriteria): Promise<BusinessListing[]> {
|
||||
const result = await lastValueFrom(this.http.post<BusinessListing[]>(`${this.apiBaseUrl}/bizmatch/listings/business/search`, criteria));
|
||||
return result;
|
||||
|
||||
getNumberOfListings(listingsCategory: 'business' | 'commercialProperty', crit?: any): Observable<number> {
|
||||
const criteria = crit ? crit : getCriteriaByListingCategory(listingsCategory);
|
||||
const sortBy = getSortByListingCategory(listingsCategory);
|
||||
const body = { ...criteria, sortBy }; // Merge, falls relevant (wenn Backend sortBy für Count braucht; sonst ignorieren)
|
||||
return this.http.post<number>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/findTotal`, body);
|
||||
}
|
||||
|
||||
getListingById(id: string, listingsCategory?: 'business' | 'commercialProperty'): Observable<ListingType> {
|
||||
const result = this.http.get<ListingType>(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/${id}`);
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
// 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,
|
||||
};
|
||||
|
|
@ -1,17 +1,21 @@
|
|||
// Vereinfachter search.service.ts
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import { BusinessListingCriteria, CommercialPropertyListingCriteria, UserListingCriteria } from '../../../../bizmatch-server/src/models/main.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SearchService {
|
||||
private criteriaSource = new Subject<BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria>();
|
||||
currentCriteria = this.criteriaSource.asObservable();
|
||||
private searchTriggerSubject = new Subject<string>();
|
||||
|
||||
// Observable für Komponenten zum Abonnieren
|
||||
searchTrigger$ = this.searchTriggerSubject.asObservable();
|
||||
|
||||
constructor() {}
|
||||
|
||||
search(criteria: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria): void {
|
||||
this.criteriaSource.next(criteria);
|
||||
// Trigger eine Suche für einen bestimmten Listing-Typ
|
||||
search(listingType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings'): void {
|
||||
console.log(`Triggering search for: ${listingType}`);
|
||||
this.searchTriggerSubject.next(listingType);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import { Router } from '@angular/router';
|
|||
import { ConsoleFormattedStream, INFO, createLogger as _createLogger, stdSerializers } from 'browser-bunyan';
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import onChange from 'on-change';
|
||||
import { User } from '../../../../bizmatch-server/src/models/db.model';
|
||||
import { SortByOptions, User } from '../../../../bizmatch-server/src/models/db.model';
|
||||
import { BusinessListingCriteria, CommercialPropertyListingCriteria, JwtToken, KeycloakUser, MailInfo, UserListingCriteria } from '../../../../bizmatch-server/src/models/main.model';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
|
|
@ -15,7 +15,6 @@ export function createEmptyBusinessListingCriteria(): BusinessListingCriteria {
|
|||
city: null,
|
||||
types: [],
|
||||
prompt: '',
|
||||
sortBy: null,
|
||||
criteriaType: 'businessListings',
|
||||
minPrice: null,
|
||||
maxPrice: null,
|
||||
|
|
@ -25,12 +24,12 @@ export function createEmptyBusinessListingCriteria(): BusinessListingCriteria {
|
|||
maxCashFlow: null,
|
||||
minNumberEmployees: null,
|
||||
maxNumberEmployees: null,
|
||||
establishedSince: null,
|
||||
establishedUntil: null,
|
||||
establishedMin: null,
|
||||
realEstateChecked: false,
|
||||
leasedLocation: false,
|
||||
franchiseResale: false,
|
||||
title: '',
|
||||
email: '',
|
||||
brokerName: '',
|
||||
searchType: 'exact',
|
||||
radius: null,
|
||||
|
|
@ -46,7 +45,6 @@ export function createEmptyCommercialPropertyListingCriteria(): CommercialProper
|
|||
city: null,
|
||||
types: [],
|
||||
prompt: '',
|
||||
sortBy: null,
|
||||
criteriaType: 'commercialPropertyListings',
|
||||
minPrice: null,
|
||||
maxPrice: null,
|
||||
|
|
@ -64,7 +62,6 @@ export function createEmptyUserListingCriteria(): UserListingCriteria {
|
|||
city: null,
|
||||
types: [],
|
||||
prompt: '',
|
||||
sortBy: null,
|
||||
criteriaType: 'brokerListings',
|
||||
brokerName: '',
|
||||
companyName: '',
|
||||
|
|
@ -82,7 +79,6 @@ export function resetBusinessListingCriteria(criteria: BusinessListingCriteria)
|
|||
criteria.city = null;
|
||||
criteria.types = [];
|
||||
criteria.prompt = '';
|
||||
criteria.sortBy = null;
|
||||
criteria.criteriaType = 'businessListings';
|
||||
criteria.minPrice = null;
|
||||
criteria.maxPrice = null;
|
||||
|
|
@ -92,8 +88,7 @@ export function resetBusinessListingCriteria(criteria: BusinessListingCriteria)
|
|||
criteria.maxCashFlow = null;
|
||||
criteria.minNumberEmployees = null;
|
||||
criteria.maxNumberEmployees = null;
|
||||
criteria.establishedSince = null;
|
||||
criteria.establishedUntil = null;
|
||||
criteria.establishedMin = null;
|
||||
criteria.realEstateChecked = false;
|
||||
criteria.leasedLocation = false;
|
||||
criteria.franchiseResale = false;
|
||||
|
|
@ -111,7 +106,6 @@ export function resetCommercialPropertyListingCriteria(criteria: CommercialPrope
|
|||
criteria.city = null;
|
||||
criteria.types = [];
|
||||
criteria.prompt = '';
|
||||
criteria.sortBy = null;
|
||||
criteria.criteriaType = 'commercialPropertyListings';
|
||||
criteria.minPrice = null;
|
||||
criteria.maxPrice = null;
|
||||
|
|
@ -127,7 +121,6 @@ export function resetUserListingCriteria(criteria: UserListingCriteria) {
|
|||
criteria.city = null;
|
||||
criteria.types = [];
|
||||
criteria.prompt = '';
|
||||
criteria.sortBy = null;
|
||||
criteria.criteriaType = 'brokerListings';
|
||||
criteria.brokerName = '';
|
||||
criteria.companyName = '';
|
||||
|
|
@ -301,6 +294,11 @@ export function checkAndUpdate(changed: boolean, condition: boolean, assignment:
|
|||
}
|
||||
return changed || condition;
|
||||
}
|
||||
export function removeSortByStorage() {
|
||||
sessionStorage.removeItem('businessSortBy');
|
||||
sessionStorage.removeItem('commercialSortBy');
|
||||
sessionStorage.removeItem('professionalsSortBy');
|
||||
}
|
||||
// -----------------------------
|
||||
// Criteria Proxy
|
||||
// -----------------------------
|
||||
|
|
@ -342,6 +340,19 @@ export function createEnhancedProxy(obj: BusinessListingCriteria | CommercialPro
|
|||
}
|
||||
});
|
||||
}
|
||||
// export function isAdmin(email: string) {
|
||||
// return 'andreas.knuth@gmail.com' === email;
|
||||
// }
|
||||
export function getCriteriaByListingCategory(listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty') {
|
||||
const storedState =
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 8.7 MiB |
|
After Width: | Height: | Size: 26 MiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 662 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 667 B |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
|
@ -5,12 +5,6 @@ export const environment_base = {
|
|||
imageBaseUrl: 'https://dev.bizmatch.net',
|
||||
buildVersion: '<BUILD_VERSION>',
|
||||
mailinfoUrl: 'https://dev.bizmatch.net',
|
||||
keycloak: {
|
||||
url: 'https://auth.bizmatch.net',
|
||||
realm: 'bizmatch-dev',
|
||||
clientId: 'bizmatch-dev',
|
||||
redirectUri: 'https://dev.bizmatch.net',
|
||||
},
|
||||
ipinfo_token: '7029590fb91214',
|
||||
firebaseConfig: {
|
||||
apiKey: 'AIzaSyBqVutQqdgUzwD9tKiKJrJq2Q6rD1hNdzw',
|
||||
|
|
@ -22,4 +16,7 @@ export const environment_base = {
|
|||
appId: '1:1065122571067:web:1124571ab67bc0f5240d1e',
|
||||
measurementId: 'G-MHVDK1KSWV',
|
||||
},
|
||||
POSTHOG_KEY: '',
|
||||
POSTHOG_HOST: '',
|
||||
production: false,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@ import { environment_base } from './environment.base';
|
|||
|
||||
export const environment = environment_base;
|
||||
|
||||
environment.apiBaseUrl = 'https://api-dev.bizmatch.net';
|
||||
//environment.apiBaseUrl = 'https://api-dev.bizmatch.net';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
import { environment_base } from './environment.base';
|
||||
|
||||
export const environment = environment_base;
|
||||
environment.production = true;
|
||||
environment.apiBaseUrl = 'https://api.bizmatch.net';
|
||||
environment.mailinfoUrl = 'https://www.bizmatch.net';
|
||||
environment.imageBaseUrl = 'https://www.bizmatch.net';
|
||||
|
||||
environment.POSTHOG_KEY = 'phc_eUIcIq0UPVzEDtZLy78klKhGudyagBz3goDlKx8SQFe';
|
||||
environment.POSTHOG_HOST = 'https://eu.i.posthog.com';
|
||||
|
|
@ -3,7 +3,3 @@ import { environment_base } from './environment.base';
|
|||
export const environment = environment_base;
|
||||
environment.mailinfoUrl = 'http://localhost:4200';
|
||||
environment.imageBaseUrl = 'http://localhost:4200';
|
||||
environment.keycloak.clientId = 'dev';
|
||||
environment.keycloak.realm = 'dev';
|
||||
environment.keycloak.redirectUri = 'http://192.168.178.24:4200';
|
||||
// environment.keycloak.redirectUri = 'http://localhost:4200';
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
max_line_length = 180
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
node_modules
|
||||
*.js
|
||||
*.map
|
||||
package-lock.json
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Crawler",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"program": "${workspaceFolder}/index.js",
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/**/*.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "ChangeUserId",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"args": ["--userId","e0811669-c7eb-4e5e-a699-e8334d5c5b01"],
|
||||
"program": "${workspaceFolder}/changeUserId.js",
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/**/*.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "postgres_business_import",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"program": "${workspaceFolder}/build/crawler/postgres_business_import.js",
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/**/*.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "generateTypes",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"program": "${workspaceFolder}/generateTypes.js",
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/**/*.js"
|
||||
],
|
||||
"args": [
|
||||
"--sourcefile","schema.ts",
|
||||
"--outfile", "model.ts",
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
export interface User {
|
||||
id: string;
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
email: string;
|
||||
phoneNumber?: string;
|
||||
description?: string;
|
||||
created_at: Date;
|
||||
updated_at?: Date;
|
||||
isActive: boolean;
|
||||
score?: number;
|
||||
balance?: number;
|
||||
tags?: string[];
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
// import yargs from 'yargs'
|
||||
// import fs from 'fs-extra';
|
||||
// import { hideBin } from 'yargs/helpers'
|
||||
// import { BusinessListing } from "../common-models/src/main.model"
|
||||
|
||||
// const argv = yargs(hideBin(process.argv)).argv
|
||||
|
||||
// if (!argv.userId){
|
||||
// console.log(' --userId [any valid userId]')
|
||||
// process.exit(1)
|
||||
// }
|
||||
|
||||
// (async () => {
|
||||
// console
|
||||
// const response = await fetch('http://localhost:3000/bizmatch/listings', {
|
||||
// method: 'GET',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// })
|
||||
// const listings:Array<BusinessListing> = await response.json();
|
||||
// for (const listing of listings) {
|
||||
// listing.userId=argv.userId;
|
||||
// listing.created=new Date()
|
||||
// listing.updated=new Date()
|
||||
// const response = await fetch(`http://localhost:3000/bizmatch/listings/${listing.id}`, {
|
||||
// method: 'PUT',
|
||||
// body: JSON.stringify(listing),
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// });
|
||||
// }
|
||||
// })();
|
||||