diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..0a5a47f --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,647 @@ +# Changelog - BizMatch Project + +Dokumentation aller wichtigen Änderungen am BizMatch-Projekt. Diese Datei listet Feature-Implementierungen, Bugfixes, Datenbank-Migrationen und architektonische Verbesserungen auf. + +--- + +## Inhaltsverzeichnis + +1. [Datenbank-Änderungen](#1-datenbank-änderungen) +2. [Backend-Änderungen](#2-backend-änderungen) +3. [Frontend-Änderungen](#3-frontend-änderungen) +4. [SEO-Verbesserungen](#4-seo-verbesserungen) +5. [Code-Cleanup & Wartung](#5-code-cleanup--wartung) +6. [Bekannte Issues & Workarounds](#6-bekannte-issues--workarounds) + +--- + +## 1) Datenbank-Änderungen + +### 1.1 Schema-Migration: JSON-basierte Speicherung + +**Datum:** November 2025 +**Status:** ✅ Abgeschlossen + +#### Zusammenfassung der Änderungen + +Die Datenbank wurde von einem **relationalen Schema** zu einem **JSON-basierten Schema** migriert. Dies bedeutet: + +- ✅ **Neue Tabellen wurden erstellt** (`users_json`, `businesses_json`, `commercials_json`) +- ❌ **Alte Tabellen wurden NICHT gelöscht** (`users`, `businesses`, `commercials` existieren noch) +- ✅ **Alle Daten wurden migriert** (kopiert von alten zu neuen Tabellen) +- ✅ **Anwendung nutzt ausschließlich neue Tabellen** (alte Tabellen dienen nur als Backup) + +#### Detaillierte Tabellenstruktur + +**ALTE Tabellen (nicht mehr in Verwendung, aber noch vorhanden):** + +```sql +-- users (relational) +CREATE TABLE users ( + id UUID PRIMARY KEY, + email VARCHAR(255), + firstname VARCHAR(100), + lastname VARCHAR(100), + phone VARCHAR(50), + location_name VARCHAR(255), + location_state VARCHAR(2), + location_latitude FLOAT, + location_longitude FLOAT, + customer_type VARCHAR(50), + customer_sub_type VARCHAR(50), + show_in_directory BOOLEAN, + created TIMESTAMP, + updated TIMESTAMP, + -- ... weitere 20+ Spalten +); + +-- businesses (relational) +CREATE TABLE businesses ( + id UUID PRIMARY KEY, + email VARCHAR(255), + title VARCHAR(500), + asking_price DECIMAL, + established INTEGER, + revenue DECIMAL, + cash_flow DECIMAL, + -- ... weitere 30+ Spalten für alle Business-Eigenschaften +); + +-- commercials (relational) +CREATE TABLE commercials ( + id UUID PRIMARY KEY, + email VARCHAR(255), + title VARCHAR(500), + asking_price DECIMAL, + property_type VARCHAR(100), + -- ... weitere 25+ Spalten für alle Property-Eigenschaften +); +``` + +**NEUE Tabellen (aktuell in Verwendung):** + +```sql +-- users_json (JSON-basiert) +CREATE TABLE users_json ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + data JSONB NOT NULL +); + +-- businesses_json (JSON-basiert) +CREATE TABLE businesses_json ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) NOT NULL, + data JSONB NOT NULL +); + +-- commercials_json (JSON-basiert) +CREATE TABLE commercials_json ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) NOT NULL, + data JSONB NOT NULL +); +``` + +#### Was wurde NICHT geändert + +**❌ Folgende Dinge wurden NICHT geändert:** + +1. **Alte Tabellen existieren weiterhin** - Sie wurden nicht gelöscht, um als Backup zu dienen +2. **Datenbank-Name** - Weiterhin `bizmatch` (keine Änderung) +3. **Datenbank-User** - Weiterhin `bizmatch` (keine Änderung) +4. **Datenbank-Passwort** - Keine Änderung +5. **Datenbank-Port** - Weiterhin `5432` (keine Änderung) +6. **Docker-Container-Name** - Weiterhin `bizmatchdb` (keine Änderung) +7. **Indices** - PostgreSQL JSONB-Indices wurden automatisch erstellt + +#### Was wurde geändert + +**✅ Folgende Dinge wurden geändert:** + +1. **Anwendungs-Code verwendet nur noch neue Tabellen:** + - Backend liest/schreibt nur noch in `users_json`, `businesses_json`, `commercials_json` + - Drizzle ORM Schema wurde aktualisiert (`bizmatch-server/src/drizzle/schema.ts`) + - Alle Services wurden angepasst (user.service.ts, business-listing.service.ts, etc.) + +2. **Datenstruktur in JSONB-Spalten:** + - Alle Felder, die vorher einzelne Spalten waren, sind jetzt in der `data`-Spalte als JSON + - Nested Objects möglich (z.B. `location` mit `name`, `state`, `latitude`, `longitude`) + - Arrays direkt im JSON speicherbar (z.B. `imageOrder`, `areasServed`) + +3. **Query-Syntax:** + - Statt `WHERE firstname = 'John'` → `WHERE (data->>'firstname') = 'John'` + - Statt `WHERE location_state = 'TX'` → `WHERE (data->'location'->>'state') = 'TX'` + - Haversine-Formel für Radius-Suche nutzt jetzt JSON-Pfade + +#### Beispiel: User-Datensatz Vorher/Nachher + +**VORHER (relationale Tabelle `users`):** + +| id | email | firstname | lastname | phone | location_name | location_state | location_latitude | location_longitude | customer_type | customer_sub_type | show_in_directory | created | updated | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| abc-123 | john@example.com | John | Doe | +1-555-0123 | Austin | TX | 30.2672 | -97.7431 | professional | broker | true | 2025-11-01 | 2025-11-25 | + +**NACHHER (JSON-Tabelle `users_json`):** + +| id | email | data (JSONB) | +|---|---|---| +| abc-123 | john@example.com | `{"firstname": "John", "lastname": "Doe", "phone": "+1-555-0123", "location": {"name": "Austin", "state": "TX", "latitude": 30.2672, "longitude": -97.7431}, "customerType": "professional", "customerSubType": "broker", "showInDirectory": true, "created": "2025-11-01T10:00:00Z", "updated": "2025-11-25T15:30:00Z"}` | + +#### Vorteile der neuen Struktur + +- ✅ **Keine Schema-Migrationen mehr nötig** - Neue Felder einfach im JSON hinzufügen +- ✅ **Flexiblere Datenstrukturen** - Nested Objects und Arrays direkt speicherbar +- ✅ **Einfacheres ORM-Mapping** - TypeScript-Interfaces direkt zu JSON serialisierbar +- ✅ **Bessere Performance** - PostgreSQL JSONB ist indexierbar und schnell durchsuchbar +- ✅ **Reduzierte Code-Komplexität** - Weniger Join-Operationen, weniger Spalten-Mapping + +#### Migration durchführen (Referenz) + +Die Migration wurde bereits durchgeführt. Falls nötig, Backup-Prozess: + +```bash +# 1. Backup der alten relationalen Tabellen +docker exec -it bizmatchdb \ + pg_dump -U bizmatch -d bizmatch -t users -t businesses -t commercials \ + -F c -Z 9 -f /tmp/backup_relational_tables.dump + +# 2. Neue Tabellen sind bereits vorhanden und in Verwendung +# 3. Alte Tabellen können bei Bedarf gelöscht werden (NICHT empfohlen vor finalem Produktions-Test) +``` + +### 1.2 Location-Datenstruktur bei Professionals + +**Datum:** November 2025 +**Status:** ✅ Abgeschlossen + +#### Problem +Professionals-Suche funktionierte nicht, da die Datenstruktur für `location` falsch angenommen wurde. + +#### Lösung +- Professionals verwenden `location`-Objekt (nicht `areasServed`-Array) +- Struktur: `{ name: 'Austin', state: 'TX', latitude: 30.2672, longitude: -97.7431 }` + +#### Betroffene Queries +- Exact City Search: `location.name` ILIKE-Vergleich +- Radius Search: Haversine-Formel mit `location.latitude` und `location.longitude` + +--- + +## 2) Backend-Änderungen + +### 2.1 Professionals Search Fix + +**Datei:** `bizmatch-server/src/user/user.service.ts` +**Status:** ✅ Abgeschlossen + +#### Änderungen + +**Exact City Search (Zeile 28-30):** +```typescript +if (criteria.city && criteria.searchType === 'exact') { + whereConditions.push(sql`(${schema.users_json.data}->'location'->>'name') ILIKE ${criteria.city.name}`); +} +``` + +**Radius Search (Zeile 32-36):** +```typescript +if (criteria.city && criteria.radius && criteria.searchType === 'radius' && criteria.radius) { + const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name); + const distanceQuery = getDistanceQuery(schema.users_json, cityGeo.latitude, cityGeo.longitude); + whereConditions.push(sql`${distanceQuery} <= ${criteria.radius}`); +} +``` + +**State Filter (Zeile 55-57):** +```typescript +if (criteria.state) { + whereConditions.push(sql`(${schema.users_json.data}->'location'->>'state') = ${criteria.state}`); +} +``` + +**County Filter (Zeile 51-53):** +```typescript +if (criteria.counties && criteria.counties.length > 0) { + whereConditions.push(or(...criteria.counties.map(county => + sql`(${schema.users_json.data}->'location'->>'county') ILIKE ${`%${county}%`}` + ))); +} +``` + +### 2.2 TypeScript-Fehler behoben + +**Problem:** +Kompilierungsfehler wegen falscher Parameter-Übergabe an `getDistanceQuery()` + +**Lösung:** +- ❌ Alt: `getDistanceQuery(schema.users_json.data, 'location', lat, lon)` +- ✅ Neu: `getDistanceQuery(schema.users_json, lat, lon)` + +### 2.3 Slug-Unterstützung für SEO-freundliche URLs + +**Status:** ✅ Implementiert + +Business- und Commercial-Property-Listings können nun über SEO-freundliche Slugs aufgerufen werden: + +- `/business/austin-coffee-shop-for-sale` statt `/business/uuid-123-456` +- `/commercial-property/downtown-retail-space-dallas` statt `/commercial-property/uuid-789` + +**Implementierung:** +- Automatische Slug-Generierung aus Listing-Titeln +- Slug-basierte Routen in allen Controllern +- Fallback auf ID, falls kein Slug vorhanden + +--- + +## 3) Frontend-Änderungen + +### 3.1 Breadcrumbs-Navigation + +**Datum:** November 2025 +**Status:** ✅ Abgeschlossen + +#### Implementierte Komponenten + +**Betroffene Seiten:** +- ✅ Business Detail Pages (`details-business-listing.component.ts`) +- ✅ Commercial Property Detail Pages (`details-commercial-property-listing.component.ts`) +- ✅ User Detail Pages (`details-user.component.ts`) +- ✅ 404 Not Found Page (`not-found.component.ts`) +- ✅ Business Listings Overview (`business-listings.component.ts`) +- ✅ Commercial Property Listings Overview (`commercial-property-listings.component.ts`) + +**Beispiel-Struktur:** +``` +Home > Commercial Properties > Austin Office Space for Sale +Home > Business Listings > Restaurant for Sale in Dallas +Home > 404 - Page Not Found +``` + +**Komponente:** `bizmatch/src/app/components/breadcrumbs/breadcrumbs.component.ts` + +### 3.2 Automatische 50-Meilen-Radius-Auswahl + +**Datum:** November 2025 +**Status:** ✅ Abgeschlossen + +#### Änderungen + +Bei Auswahl einer Stadt in den Suchfiltern wird automatisch: +- **Search Type** auf `"radius"` gesetzt +- **Radius** auf `50` Meilen gesetzt +- Filter-UI aktualisiert (Radius-Feld aktiv und ausgefüllt) + +**Betroffene Dateien:** +- `bizmatch/src/app/components/search-modal/search-modal.component.ts` (Business) +- `bizmatch/src/app/components/search-modal/search-modal-commercial.component.ts` (Properties) +- `bizmatch/src/app/components/search-modal/search-modal-broker.component.ts` (Professionals) + +**Implementierung (Zeilen 255-269 in search-modal.component.ts):** +```typescript +setCity(city: any): void { + const updates: any = {}; + if (city) { + updates.city = city; + updates.state = city.state; + // Automatically set radius to 50 miles and enable radius search + updates.searchType = 'radius'; + updates.radius = 50; + } else { + updates.city = null; + updates.radius = null; + updates.searchType = 'exact'; + } + this.updateCriteria(updates); +} +``` + +### 3.3 Error Handling Verbesserungen + +**Betroffene Komponenten:** +- `details-business-listing.component.ts` +- `details-commercial-property-listing.component.ts` + +**Änderungen:** +- ✅ Safe Navigation für `listing.imageOrder` (Null-Check vor forEach) +- ✅ Verbesserte Error-Message-Extraktion mit Optional Chaining +- ✅ Default-Breadcrumbs auch bei Fehlerfall +- ✅ Korrekte Navigation zu 404-Seite bei fehlenden Listings + +**Beispiel (Zeile 139 in details-commercial-property-listing.component.ts):** +```typescript +if (this.listing.imageOrder && Array.isArray(this.listing.imageOrder)) { + this.listing.imageOrder.forEach(image => { + const imageURL = `${this.env.imageBaseUrl}/pictures/property/${this.listing.imagePath}/${this.listing.serialId}/${image}`; + this.images.push(new ImageItem({ src: imageURL, thumb: imageURL })); + }); +} +``` + +### 3.4 Business Location Privacy - Stadt-Grenze statt exakter Adresse + +**Datum:** November 2025 +**Status:** ✅ Abgeschlossen + +#### Problem & Motivation + +Bei Business-Listings für verkaufende Unternehmen sollte die **exakte Adresse nicht öffentlich angezeigt** werden, um: +- ✅ Konkurrierende Unternehmen nicht zu informieren +- ✅ Kunden nicht zu verunsichern +- ✅ Mitarbeiter vor Verunsicherung zu schützen + +Nur die **ungefähre Stadt-Region** soll angezeigt werden. Die genaue Adresse wird erst nach Kontaktaufnahme mitgeteilt. + +#### Implementierung + +**Betroffene Dateien:** +- `bizmatch/src/app/services/geo.service.ts` - Neue Methode `getCityBoundary()` +- `bizmatch/src/app/pages/details/details-business-listing/details-business-listing.component.ts` - Override von Map-Methoden + +**Unterschied: Business vs. Commercial Property** + +| Listing-Typ | Map-Anzeige | Adresse-Anzeige | Begründung | +|---|---|---|---| +| **Business Listings** | Stadt-Grenze (rotes Polygon) | Nur Stadt, County, State | Privacy: Verkäufer-Schutz | +| **Commercial Properties** | Exakter Pin-Marker | Vollständige Straßenadresse | Immobilie muss sichtbar sein | + +**Technische Umsetzung:** + +1. **Nominatim API Integration** ([geo.service.ts:33-37](bizmatch/src/app/services/geo.service.ts#L33-L37)): +```typescript +getCityBoundary(cityName: string, state: string): Observable { + const query = `${cityName}, ${state}, USA`; + let headers = new HttpHeaders().set('X-Hide-Loading', 'true').set('Accept-Language', 'en-US'); + return this.http.get(`${this.baseUrl}?q=${encodeURIComponent(query)}&format=json&polygon_geojson=1&limit=1`, { headers }); +} +``` + +2. **City Boundary Polygon** ([details-business-listing.component.ts:322-430](bizmatch/src/app/pages/details/details-business-listing/details-business-listing.component.ts#L322-L430)): +- Lädt Stadt-Grenz-Polygon von OpenStreetMap Nominatim API +- Zeigt rote Umrandung der Stadt (wie Google Maps) +- Unterstützt `Polygon` und `MultiPolygon` Geometrien +- **Fallback:** 8km-Radius-Kreis bei API-Fehler oder fehlenden Daten + +3. **Karten-Konfiguration:** +```typescript +// Rotes Polygon (wie Google Maps) +const cityPolygon = polygon(latlngs, { + color: '#ef4444', // Rot + fillColor: '#ef4444', // Rot + fillOpacity: 0.1, // Sehr transparent (90% durchsichtig) + weight: 2 // Linienstärke +}); + +// Popup zeigt nur allgemeine Information +cityPolygon.bindPopup(` +
+ General Area:
+ ${cityName}, ${county ? county + ', ' : ''}${state}
+ City boundary shown for privacy.
Exact location provided after contact.
+
+`); +``` + +4. **Address Control Override:** +- Zeigt nur: "Austin, Travis County, TX" +- **NICHT** angezeigt: Straßenname, Hausnummer, PLZ + +5. **OpenStreetMap Link Override:** +- Zoom-Level 11 (Stadt-Ansicht) statt Zoom-Level 15 (Straßen-Ansicht) +- Keine Marker auf exakter Position + +**Entwicklungs-Verlauf (Entscheidungen):** + +| Ansatz | Grund für Ablehnung | Status | +|---|---|---| +| 2km Fuzzy-Radius | Zu klein - bei wenigen Businesses in Stadt identifizierbar | ❌ Abgelehnt | +| County-Level | Zu groß - schlechte UX, schlechtes SEO | ❌ Abgelehnt | +| Stadt-Center + 8km-Kreis | Funktioniert, aber nicht professionell aussehend | ⚠️ Fallback | +| **Stadt-Grenz-Polygon (wie Google Maps)** | Professionell, präzise, gute Privacy | ✅ **Implementiert** | + +**Vorteile der Lösung:** + +- ✅ **Privacy by Design** - Exakte Location nicht sichtbar +- ✅ **Professionelles Erscheinungsbild** - Wie Google Maps Stadt-Grenzen +- ✅ **Genaue Stadt-Darstellung** - Nutzt offizielle OSM-Daten +- ✅ **Robust** - Fallback auf Kreis bei API-Problemen +- ✅ **SEO-freundlich** - Stadt-Namen bleiben in Metadaten erhalten +- ✅ **Multi-Polygon Support** - Städte mit mehreren Bereichen (Inseln, etc.) + +**Was NICHT geändert wurde:** + +- ❌ **Commercial Property Listings** zeigen weiterhin exakte Adressen +- ❌ **User/Professional Locations** zeigen weiterhin Stadt-Pins +- ❌ **Datenbank** - Location-Daten bleiben unverändert gespeichert +- ❌ **Backend** - Keine API-Änderungen nötig + +--- + +## 4) SEO-Verbesserungen + +### 4.1 Meta-Tags & Structured Data + +**Status:** ✅ Implementiert + +**Neue SEO-Features:** +- ✅ Dynamische Title & Description für alle Listing-Seiten +- ✅ Open Graph Tags für Social Media Sharing +- ✅ JSON-LD Structured Data (Schema.org) +- ✅ Canonical URLs +- ✅ Noindex für 404-Seiten + +**Implementierung:** +- `bizmatch/src/app/services/seo.service.ts` +- Automatische Meta-Tag-Generierung basierend auf Listing-Daten + +### 4.2 Sitemap-Generierung + +**Status:** ✅ Implementiert + +**Endpunkte:** +- `/bizmatch/sitemap.xml` - Haupt-Sitemap (Index) +- `/bizmatch/sitemap/static.xml` - Statische Seiten +- `/bizmatch/sitemap/business-1.xml` - Business-Listings (paginiert) +- `/bizmatch/sitemap/commercial-1.xml` - Commercial-Properties (paginiert) + +**Controller:** `bizmatch-server/src/sitemap/sitemap.controller.ts` + +### 4.3 SEO-freundliche 404-Seite + +**Datei:** `bizmatch/src/app/components/not-found/not-found.component.ts` + +**Features:** +- ✅ Breadcrumbs für bessere Navigation +- ✅ `noindex` Meta-Tag (verhindert Indexierung) +- ✅ Aussagekräftige Title & Description +- ✅ Link zurück zur Homepage + +--- + +## 5) Code-Cleanup & Wartung + +### 5.1 Gelöschte temporäre Dateien + +**Datum:** November 2025 +**Status:** ✅ Abgeschlossen + +**Markdown-Dokumentation (7 Dateien):** +- ❌ `DATABASE-FIX-INSTRUCTIONS.md` +- ❌ `DEPLOYMENT-GUIDE.md` +- ❌ `PROFESSIONALS-TAB-IMPLEMENTATION.md` +- ❌ `RESTART-BACKEND.md` +- ❌ `SEO-IMPROVEMENTS-SUMMARY.md` +- ❌ `bizmatch-server/SEO-DEPLOYMENT-SUCCESS.md` +- ❌ `bizmatch-server/TYPESCRIPT-FIX-SUMMARY.md` + +**Shell-Scripts (33 Dateien):** +- ❌ Alle `.sh`-Dateien in `bizmatch-server/` (check-*, fix-*, test-*, run-*, setup-*, etc.) + +**SQL-Test-Dateien (5 Dateien):** +- ❌ `create-schema.sql` +- ❌ `insert-professionals-json.sql` +- ❌ `insert-professionals-simple.sql` +- ❌ `insert-test-professionals.sql` +- ❌ `insert-test-professionals-fixed.sql` + +**Debug-JavaScript (2 Dateien):** +- ❌ `check-db.js` +- ❌ `verify.js` + +**Komplette Verzeichnisse:** +- ❌ `bizmatch-server/scripts/` (~13 Dateien) +- ❌ `bizmatch-server/src/scripts/` (~13 Dateien) +- ❌ `.claude/` (Verzeichnis) + +**Gesamt:** ~75 temporäre Dateien und 3 Verzeichnisse entfernt + +### 5.2 Beibehaltene Konfigurationsdateien + +**✅ Wichtige Dateien (nicht gelöscht):** +- `README.md` (Projekt-Dokumentation) +- `bizmatch-server/README.md` (Server-Dokumentation) +- `.eslintrc.js` (Code-Style-Konfiguration) +- `docker-compose.yml` (Container-Konfiguration) +- `.gitignore` (Git-Konfiguration) + +--- + +## 6) Bekannte Issues & Workarounds + +### 6.1 Docker-Container-Neustart nach Code-Änderungen + +**Problem:** +TypeScript-Kompilierungsfehler können dazu führen, dass der Backend-Container nicht startet. + +**Lösung:** +```bash +# Container-Logs prüfen +docker logs bizmatch-app --tail 50 + +# Bei TypeScript-Fehlern: Container neu starten +docker restart bizmatch-app + +# Prüfen, ob App erfolgreich gestartet ist +docker logs bizmatch-app | grep "Nest application successfully started" +``` + +### 6.2 Database Connection Issues + +**Problem:** +`password authentication failed for user "bizmatch"` + +**Lösung:** +Siehe [README.md - Abschnitt 4.1](README.md#41-password-authentication-failed-for-user-bizmatch) + +### 6.3 Frontend Proxy-Fehler + +**Problem:** +`http proxy error: /bizmatch/select-options` während Backend-Neustart + +**Lösung:** +- Warten, bis Backend vollständig gestartet ist (~30 Sekunden) +- Frontend-Dev-Server bei Bedarf neu starten: `npm start` + +--- + +## Migration-Guide: JSON-Schema + +### Von relationaler DB zu JSON-Schema + +**Beispiel: User-Daten** + +**Alt (relationale Tabelle):** +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY, + email VARCHAR(255), + firstname VARCHAR(100), + lastname VARCHAR(100), + phone VARCHAR(50), + ... +); +``` + +**Neu (JSON-Schema):** +```sql +CREATE TABLE users_json ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255), + data JSONB +); +``` + +**JSON-Struktur in `data`-Spalte:** +```json +{ + "firstname": "John", + "lastname": "Doe", + "email": "john.doe@example.com", + "phone": "+1-555-0123", + "customerType": "professional", + "customerSubType": "broker", + "location": { + "name": "Austin", + "state": "TX", + "latitude": 30.2672, + "longitude": -97.7431 + }, + "showInDirectory": true, + "created": "2025-11-25T10:30:00Z", + "updated": "2025-11-25T10:30:00Z" +} +``` + +**Query-Beispiele:** + +```sql +-- Alle Professionals in Texas finden +SELECT * FROM users_json +WHERE (data->>'customerType') = 'professional' + AND (data->'location'->>'state') = 'TX'; + +-- Nach Name suchen +SELECT * FROM users_json +WHERE (data->>'firstname') ILIKE '%John%'; + +-- Radius-Suche (50 Meilen um Austin) +SELECT * FROM users_json +WHERE ( + 3959 * 2 * ASIN(SQRT( + POWER(SIN((30.2672 - (data->'location'->>'latitude')::float) * PI() / 180 / 2), 2) + + COS(30.2672 * PI() / 180) * COS((data->'location'->>'latitude')::float * PI() / 180) * + POWER(SIN((-97.7431 - (data->'location'->>'longitude')::float) * PI() / 180 / 2), 2) + )) +) <= 50; +``` + +--- + +## Support & Fragen + +Bei Fragen zu diesen Änderungen: +1. README.md für Setup-Informationen konsultieren +2. Docker-Logs prüfen: `docker logs bizmatch-app` und `docker logs bizmatchdb` +3. Git-History für Details zu Änderungen durchsuchen + +**Letzte Aktualisierung:** November 2025 diff --git a/bizmatch-server/docker-compose.yml b/bizmatch-server/docker-compose.yml index cc2678d..9d4d2d5 100644 --- a/bizmatch-server/docker-compose.yml +++ b/bizmatch-server/docker-compose.yml @@ -1,21 +1,19 @@ -# ~/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 + - ./:/app + - node_modules:/app/node_modules ports: - - '3001:3000' # Host 3001 -> Container 3000 + - '3001:3001' env_file: - - path: ./.env - required: true + - .env environment: - - NODE_ENV=development # Prod-Modus (vorher stand fälschlich "development") + - NODE_ENV=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" + command: sh -c "if [ ! -f node_modules/.installed ]; then npm ci && touch node_modules/.installed; fi && npm run build && node dist/src/main.js" restart: unless-stopped depends_on: - postgres @@ -24,22 +22,27 @@ services: postgres: container_name: bizmatchdb - image: postgres:17-alpine # Version pinnen ist stabiler als "latest" + image: postgres:17-alpine restart: unless-stopped volumes: - - ${PWD}/bizmatchdb-data:/var/lib/postgresql/data # Daten liegen im Server-Repo + - bizmatch-db-data:/var/lib/postgresql/data env_file: - - path: ./.env - required: true + - .env environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} ports: - - '5433:5432' # Host 5433 -> Container 5432 + - '5434:5432' networks: - bizmatch +volumes: + bizmatch-db-data: + driver: local + node_modules: + driver: local + networks: bizmatch: - external: true # einmalig anlegen: docker network create bizmatch-prod + external: true diff --git a/bizmatch-server/package.json b/bizmatch-server/package.json index ad4cdf3..61cafef 100644 --- a/bizmatch-server/package.json +++ b/bizmatch-server/package.json @@ -23,14 +23,19 @@ "drop": "drizzle-kit drop", "migrate": "tsx src/drizzle/migrate.ts", "import": "tsx src/drizzle/import.ts", - "generateTypes": "tsx src/drizzle/generateTypes.ts src/drizzle/schema.ts src/models/db.model.ts" + "generateTypes": "tsx src/drizzle/generateTypes.ts src/drizzle/schema.ts src/models/db.model.ts", + "create-tables": "node src/scripts/create-tables.js", + "seed": "node src/scripts/seed-database.js", + "create-user": "node src/scripts/create-test-user.js", + "seed:all": "npm run create-user && npm run seed", + "setup": "npm run create-tables && npm run seed" }, "dependencies": { "@nestjs-modules/mailer": "^2.0.2", + "@nestjs/cli": "^11.0.11", "@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", @@ -51,7 +56,7 @@ "pgvector": "^0.2.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", - "sharp": "^0.33.2", + "sharp": "^0.33.5", "stripe": "^16.8.0", "tsx": "^4.16.2", "urlcat": "^3.1.0", @@ -66,11 +71,11 @@ "@nestjs/testing": "^11.0.11", "@types/express": "^4.17.17", "@types/multer": "^1.4.11", - "@types/node": "^20.11.19", + "@types/node": "^20.19.25", "@types/nodemailer": "^6.4.14", "@types/pg": "^8.11.5", "commander": "^12.0.0", - "drizzle-kit": "^0.23.0", + "drizzle-kit": "^0.23.2", "esbuild-register": "^3.5.0", "eslint": "^8.42.0", "eslint-config-prettier": "^9.0.0", @@ -86,7 +91,7 @@ "ts-loader": "^9.4.3", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "^5.1.3" + "typescript": "^5.9.3" }, "jest": { "moduleFileExtensions": [ @@ -105,4 +110,4 @@ "coverageDirectory": "../coverage", "testEnvironment": "node" } -} \ No newline at end of file +} diff --git a/bizmatch-server/src/app.module.ts b/bizmatch-server/src/app.module.ts index 4198a2e..08356c3 100644 --- a/bizmatch-server/src/app.module.ts +++ b/bizmatch-server/src/app.module.ts @@ -25,6 +25,7 @@ import { LoggingInterceptor } from './interceptors/logging.interceptor'; import { UserInterceptor } from './interceptors/user.interceptor'; import { RequestDurationMiddleware } from './request-duration/request-duration.middleware'; import { SelectOptionsModule } from './select-options/select-options.module'; +import { SitemapModule } from './sitemap/sitemap.module'; import { UserModule } from './user/user.module'; //loadEnvFiles(); @@ -70,6 +71,7 @@ console.log('Loaded environment variables:'); // PaymentModule, EventModule, FirebaseAdminModule, + SitemapModule, ], controllers: [AppController, LogController], providers: [ diff --git a/bizmatch-server/src/drizzle/schema.ts b/bizmatch-server/src/drizzle/schema.ts index 3df4188..dc462f1 100644 --- a/bizmatch-server/src/drizzle/schema.ts +++ b/bizmatch-server/src/drizzle/schema.ts @@ -117,6 +117,7 @@ export const businesses = pgTable( brokerLicencing: varchar('brokerLicencing', { length: 255 }), internals: text('internals'), imageName: varchar('imageName', { length: 200 }), + slug: varchar('slug', { length: 300 }).unique(), created: timestamp('created'), updated: timestamp('updated'), location: jsonb('location'), @@ -125,6 +126,7 @@ export const businesses = pgTable( locationBusinessCityStateIdx: index('idx_business_location_city_state').on( sql`((${table.location}->>'name')::varchar), ((${table.location}->>'state')::varchar), ((${table.location}->>'latitude')::float), ((${table.location}->>'longitude')::float)`, ), + slugIdx: index('idx_business_slug').on(table.slug), }), ); @@ -143,6 +145,7 @@ export const commercials = pgTable( draft: boolean('draft'), imageOrder: varchar('imageOrder', { length: 200 }).array(), imagePath: varchar('imagePath', { length: 200 }), + slug: varchar('slug', { length: 300 }).unique(), created: timestamp('created'), updated: timestamp('updated'), location: jsonb('location'), @@ -151,6 +154,7 @@ export const commercials = pgTable( locationCommercialsCityStateIdx: index('idx_commercials_location_city_state').on( sql`((${table.location}->>'name')::varchar), ((${table.location}->>'state')::varchar), ((${table.location}->>'latitude')::float), ((${table.location}->>'longitude')::float)`, ), + slugIdx: index('idx_commercials_slug').on(table.slug), }), ); diff --git a/bizmatch-server/src/listings/business-listing.service.ts b/bizmatch-server/src/listings/business-listing.service.ts index 70367bc..0705830 100644 --- a/bizmatch-server/src/listings/business-listing.service.ts +++ b/bizmatch-server/src/listings/business-listing.service.ts @@ -10,6 +10,7 @@ import { GeoService } from '../geo/geo.service'; import { BusinessListing, BusinessListingSchema } from '../models/db.model'; import { BusinessListingCriteria, JwtUser } from '../models/main.model'; import { getDistanceQuery, splitName } from '../utils'; +import { generateSlug, extractShortIdFromSlug, isSlug } from '../utils/slug.utils'; @Injectable() export class BusinessListingService { @@ -212,6 +213,41 @@ export class BusinessListingService { return totalCount; } + /** + * Find business by slug or ID + * Supports both slug (e.g., "restaurant-austin-tx-a3f7b2c1") and UUID + */ + async findBusinessBySlugOrId(slugOrId: string, user: JwtUser): Promise { + let id = slugOrId; + + // Check if it's a slug (contains multiple hyphens) vs UUID + if (isSlug(slugOrId)) { + // Extract short ID from slug and find by slug field + const listing = await this.findBusinessBySlug(slugOrId); + if (listing) { + id = listing.id; + } + } + + return this.findBusinessesById(id, user); + } + + /** + * Find business by slug + */ + async findBusinessBySlug(slug: string): Promise { + const result = await this.conn + .select() + .from(businesses_json) + .where(sql`${businesses_json.data}->>'slug' = ${slug}`) + .limit(1); + + if (result.length > 0) { + return { id: result[0].id, email: result[0].email, ...(result[0].data as BusinessListing) } as BusinessListing; + } + return null; + } + async findBusinessesById(id: string, user: JwtUser): Promise { const conditions = []; if (user?.role !== 'admin') { @@ -246,7 +282,7 @@ export class BusinessListingService { const userFavorites = await this.conn .select() .from(businesses_json) - .where(arrayContains(sql`${businesses_json.data}->>'favoritesForUser'`, [user.email])); + .where(sql`${businesses_json.data}->'favoritesForUser' ? ${user.email}`); return userFavorites.map(l => ({ id: l.id, email: l.email, ...(l.data as BusinessListing) }) as BusinessListing); } @@ -258,7 +294,13 @@ export class BusinessListingService { 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) }; + + // Generate and update slug after creation (we need the ID first) + const slug = generateSlug(data.title, data.location, createdListing.id); + const listingWithSlug = { ...(createdListing.data as any), slug }; + await this.conn.update(businesses_json).set({ data: listingWithSlug }).where(eq(businesses_json.id, createdListing.id)); + + return { id: createdListing.id, email: createdListing.email, ...(createdListing.data as BusinessListing), slug } as any; } catch (error) { if (error instanceof ZodError) { const filteredErrors = error.errors @@ -285,8 +327,21 @@ export class BusinessListingService { if (existingListing.email === user?.email) { data.favoritesForUser = (existingListing.data).favoritesForUser || []; } - BusinessListingSchema.parse(data); - const { id: _, email, ...rest } = data; + + // Regenerate slug if title or location changed + const existingData = existingListing.data as BusinessListing; + let slug: string; + if (data.title !== existingData.title || JSON.stringify(data.location) !== JSON.stringify(existingData.location)) { + slug = generateSlug(data.title, data.location, id); + } else { + // Keep existing slug + slug = (existingData as any).slug || generateSlug(data.title, data.location, id); + } + + // Add slug to data before validation + const dataWithSlug = { ...data, slug }; + BusinessListingSchema.parse(dataWithSlug); + const { id: _, email, ...rest } = dataWithSlug; 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) }; @@ -308,11 +363,24 @@ export class BusinessListingService { await this.conn.delete(businesses_json).where(eq(businesses_json.id, id)); } + async addFavorite(id: string, user: JwtUser): Promise { + await this.conn + .update(businesses_json) + .set({ + data: sql`jsonb_set(${businesses_json.data}, '{favoritesForUser}', + coalesce((${businesses_json.data}->'favoritesForUser')::jsonb, '[]'::jsonb) || to_jsonb(${user.email}::text))`, + }) + .where(eq(businesses_json.id, id)); + } + async deleteFavorite(id: string, user: JwtUser): Promise { await this.conn .update(businesses_json) .set({ - data: sql`jsonb_set(${businesses_json.data}, '{favoritesForUser}', array_remove((${businesses_json.data}->>'favoritesForUser')::jsonb, ${user.email}))`, + data: sql`jsonb_set(${businesses_json.data}, '{favoritesForUser}', + (SELECT coalesce(jsonb_agg(elem), '[]'::jsonb) + FROM jsonb_array_elements(coalesce(${businesses_json.data}->'favoritesForUser', '[]'::jsonb)) AS elem + WHERE elem::text != to_jsonb(${user.email}::text)::text))`, }) .where(eq(businesses_json.id, id)); } diff --git a/bizmatch-server/src/listings/business-listings.controller.ts b/bizmatch-server/src/listings/business-listings.controller.ts index cf7d1a4..3b06ae3 100644 --- a/bizmatch-server/src/listings/business-listings.controller.ts +++ b/bizmatch-server/src/listings/business-listings.controller.ts @@ -16,9 +16,10 @@ export class BusinessListingsController { ) {} @UseGuards(OptionalAuthGuard) - @Get(':id') - async findById(@Request() req, @Param('id') id: string): Promise { - return await this.listingsService.findBusinessesById(id, req.user as JwtUser); + @Get(':slugOrId') + async findById(@Request() req, @Param('slugOrId') slugOrId: string): Promise { + // Support both slug (e.g., "restaurant-austin-tx-a3f7b2c1") and UUID + return await this.listingsService.findBusinessBySlugOrId(slugOrId, req.user as JwtUser); } @UseGuards(AuthGuard) @Get('favorites/all') @@ -60,9 +61,17 @@ export class BusinessListingsController { await this.listingsService.deleteListing(id); } + @UseGuards(AuthGuard) + @Post('favorite/:id') + async addFavorite(@Request() req, @Param('id') id: string) { + await this.listingsService.addFavorite(id, req.user as JwtUser); + return { success: true, message: 'Added to favorites' }; + } + @UseGuards(AuthGuard) @Delete('favorite/:id') async deleteFavorite(@Request() req, @Param('id') id: string) { await this.listingsService.deleteFavorite(id, req.user as JwtUser); + return { success: true, message: 'Removed from favorites' }; } } diff --git a/bizmatch-server/src/listings/commercial-property-listings.controller.ts b/bizmatch-server/src/listings/commercial-property-listings.controller.ts index e54a957..ded7365 100644 --- a/bizmatch-server/src/listings/commercial-property-listings.controller.ts +++ b/bizmatch-server/src/listings/commercial-property-listings.controller.ts @@ -18,9 +18,10 @@ export class CommercialPropertyListingsController { ) {} @UseGuards(OptionalAuthGuard) - @Get(':id') - async findById(@Request() req, @Param('id') id: string): Promise { - return await this.listingsService.findCommercialPropertiesById(id, req.user as JwtUser); + @Get(':slugOrId') + async findById(@Request() req, @Param('slugOrId') slugOrId: string): Promise { + // Support both slug (e.g., "office-space-austin-tx-a3f7b2c1") and UUID + return await this.listingsService.findCommercialBySlugOrId(slugOrId, req.user as JwtUser); } @UseGuards(AuthGuard) @@ -64,9 +65,18 @@ export class CommercialPropertyListingsController { await this.listingsService.deleteListing(id); this.fileService.deleteDirectoryIfExists(imagePath); } + + @UseGuards(AuthGuard) + @Post('favorite/:id') + async addFavorite(@Request() req, @Param('id') id: string) { + await this.listingsService.addFavorite(id, req.user as JwtUser); + return { success: true, message: 'Added to favorites' }; + } + @UseGuards(AuthGuard) @Delete('favorite/:id') async deleteFavorite(@Request() req, @Param('id') id: string) { await this.listingsService.deleteFavorite(id, req.user as JwtUser); + return { success: true, message: 'Removed from favorites' }; } } diff --git a/bizmatch-server/src/listings/commercial-property.service.ts b/bizmatch-server/src/listings/commercial-property.service.ts index a462540..901c7d9 100644 --- a/bizmatch-server/src/listings/commercial-property.service.ts +++ b/bizmatch-server/src/listings/commercial-property.service.ts @@ -11,6 +11,7 @@ import { GeoService } from '../geo/geo.service'; import { CommercialPropertyListing, CommercialPropertyListingSchema } from '../models/db.model'; import { CommercialPropertyListingCriteria, JwtUser } from '../models/main.model'; import { getDistanceQuery } from '../utils'; +import { generateSlug, extractShortIdFromSlug, isSlug } from '../utils/slug.utils'; @Injectable() export class CommercialPropertyService { @@ -111,6 +112,41 @@ export class CommercialPropertyService { } // #### Find by ID ######################################## + /** + * Find commercial property by slug or ID + * Supports both slug (e.g., "office-space-austin-tx-a3f7b2c1") and UUID + */ + async findCommercialBySlugOrId(slugOrId: string, user: JwtUser): Promise { + let id = slugOrId; + + // Check if it's a slug (contains multiple hyphens) vs UUID + if (isSlug(slugOrId)) { + // Extract short ID from slug and find by slug field + const listing = await this.findCommercialBySlug(slugOrId); + if (listing) { + id = listing.id; + } + } + + return this.findCommercialPropertiesById(id, user); + } + + /** + * Find commercial property by slug + */ + async findCommercialBySlug(slug: string): Promise { + const result = await this.conn + .select() + .from(commercials_json) + .where(sql`${commercials_json.data}->>'slug' = ${slug}`) + .limit(1); + + if (result.length > 0) { + return { id: result[0].id, email: result[0].email, ...(result[0].data as CommercialPropertyListing) } as CommercialPropertyListing; + } + return null; + } + async findCommercialPropertiesById(id: string, user: JwtUser): Promise { const conditions = []; if (user?.role !== 'admin') { @@ -146,7 +182,7 @@ export class CommercialPropertyService { const userFavorites = await this.conn .select() .from(commercials_json) - .where(arrayContains(sql`${commercials_json.data}->>'favoritesForUser'`, [user.email])); + .where(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 ######################################## @@ -182,7 +218,13 @@ export class CommercialPropertyService { 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) }; + + // Generate and update slug after creation (we need the ID first) + const slug = generateSlug(data.title, data.location, createdListing.id); + const listingWithSlug = { ...(createdListing.data as any), slug }; + await this.conn.update(commercials_json).set({ data: listingWithSlug }).where(eq(commercials_json.id, createdListing.id)); + + return { id: createdListing.id, email: createdListing.email, ...(createdListing.data as CommercialPropertyListing), slug } as any; } catch (error) { if (error instanceof ZodError) { const filteredErrors = error.errors @@ -209,14 +251,27 @@ export class CommercialPropertyService { if (existingListing.email === user?.email || !user) { data.favoritesForUser = (existingListing.data).favoritesForUser || []; } - CommercialPropertyListingSchema.parse(data); - const imageOrder = await this.fileService.getPropertyImages(data.imagePath, String(data.serialId)); - const difference = imageOrder.filter(x => !data.imageOrder.includes(x)).concat(data.imageOrder.filter(x => !imageOrder.includes(x))); - if (difference.length > 0) { - this.logger.warn(`changes between image directory and imageOrder in listing ${data.serialId}: ${difference.join(',')}`); - data.imageOrder = imageOrder; + + // Regenerate slug if title or location changed + const existingData = existingListing.data as CommercialPropertyListing; + let slug: string; + if (data.title !== existingData.title || JSON.stringify(data.location) !== JSON.stringify(existingData.location)) { + slug = generateSlug(data.title, data.location, id); + } else { + // Keep existing slug + slug = (existingData as any).slug || generateSlug(data.title, data.location, id); } - const { id: _, email, ...rest } = data; + + // Add slug to data before validation + const dataWithSlug = { ...data, slug }; + CommercialPropertyListingSchema.parse(dataWithSlug); + const imageOrder = await this.fileService.getPropertyImages(dataWithSlug.imagePath, String(dataWithSlug.serialId)); + const difference = imageOrder.filter(x => !dataWithSlug.imageOrder.includes(x)).concat(dataWithSlug.imageOrder.filter(x => !imageOrder.includes(x))); + if (difference.length > 0) { + this.logger.warn(`changes between image directory and imageOrder in listing ${dataWithSlug.serialId}: ${difference.join(',')}`); + dataWithSlug.imageOrder = imageOrder; + } + const { id: _, email, ...rest } = dataWithSlug; 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) }; @@ -253,12 +308,25 @@ export class CommercialPropertyService { async deleteListing(id: string): Promise { await this.conn.delete(commercials_json).where(eq(commercials_json.id, id)); } + // #### ADD Favorite ###################################### + async addFavorite(id: string, user: JwtUser): Promise { + await this.conn + .update(commercials_json) + .set({ + data: sql`jsonb_set(${commercials_json.data}, '{favoritesForUser}', + coalesce((${commercials_json.data}->'favoritesForUser')::jsonb, '[]'::jsonb) || to_jsonb(${user.email}::text))`, + }) + .where(eq(commercials_json.id, id)); + } // #### DELETE Favorite ################################### async deleteFavorite(id: string, user: JwtUser): Promise { await this.conn .update(commercials_json) .set({ - data: sql`jsonb_set(${commercials_json.data}, '{favoritesForUser}', array_remove((${commercials_json.data}->>'favoritesForUser')::jsonb, ${user.email}))`, + data: sql`jsonb_set(${commercials_json.data}, '{favoritesForUser}', + (SELECT coalesce(jsonb_agg(elem), '[]'::jsonb) + FROM jsonb_array_elements(coalesce(${commercials_json.data}->'favoritesForUser', '[]'::jsonb)) AS elem + WHERE elem::text != to_jsonb(${user.email}::text)::text))`, }) .where(eq(commercials_json.id, id)); } diff --git a/bizmatch-server/src/main.ts b/bizmatch-server/src/main.ts index 3ced0df..8ba3eb7 100644 --- a/bizmatch-server/src/main.ts +++ b/bizmatch-server/src/main.ts @@ -19,6 +19,6 @@ async function bootstrap() { methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', allowedHeaders: 'Content-Type, Accept, Authorization, x-hide-loading', }); - await app.listen(3000); + await app.listen(process.env.PORT || 3001); } bootstrap(); diff --git a/bizmatch-server/src/models/db.model.ts b/bizmatch-server/src/models/db.model.ts index c8bf44b..8196ba0 100644 --- a/bizmatch-server/src/models/db.model.ts +++ b/bizmatch-server/src/models/db.model.ts @@ -287,6 +287,7 @@ export const BusinessListingSchema = z brokerLicencing: z.string().optional().nullable(), internals: z.string().min(5).optional().nullable(), imageName: z.string().optional().nullable(), + slug: z.string().optional().nullable(), created: z.date(), updated: z.date(), }) @@ -333,6 +334,7 @@ export const CommercialPropertyListingSchema = z draft: z.boolean(), imageOrder: z.array(z.string()), imagePath: z.string().nullable().optional(), + slug: z.string().optional().nullable(), created: z.date(), updated: z.date(), }) @@ -384,6 +386,6 @@ export const ListingEventSchema = z.object({ locationLat: z.string().max(20).optional().nullable(), // Latitude, als String locationLng: z.string().max(20).optional().nullable(), // Longitude, als String referrer: z.string().max(255).optional().nullable(), // Referrer URL, optional - additionalData: z.record(z.any()).optional().nullable(), // JSON für zusätzliche Daten, z.B. soziale Medien, optional + additionalData: z.record(z.string(), z.any()).optional().nullable(), // JSON für zusätzliche Daten, z.B. soziale Medien, optional }); export type ListingEvent = z.infer; diff --git a/bizmatch-server/src/models/main.model.ts b/bizmatch-server/src/models/main.model.ts index abb9c89..46c2ccf 100644 --- a/bizmatch-server/src/models/main.model.ts +++ b/bizmatch-server/src/models/main.model.ts @@ -359,6 +359,7 @@ export function createDefaultUser(email: string, firstname: string, lastname: st updated: new Date(), subscriptionId: null, subscriptionPlan: subscriptionPlan, + showInDirectory: false, }; } export function createDefaultCommercialPropertyListing(): CommercialPropertyListing { diff --git a/bizmatch-server/src/sitemap/sitemap.controller.ts b/bizmatch-server/src/sitemap/sitemap.controller.ts new file mode 100644 index 0000000..e9aa9a0 --- /dev/null +++ b/bizmatch-server/src/sitemap/sitemap.controller.ts @@ -0,0 +1,51 @@ +import { Controller, Get, Header, Param, ParseIntPipe } from '@nestjs/common'; +import { SitemapService } from './sitemap.service'; + +@Controller() +export class SitemapController { + constructor(private readonly sitemapService: SitemapService) {} + + /** + * Main sitemap index - lists all sitemap files + * Route: /sitemap.xml + */ + @Get('sitemap.xml') + @Header('Content-Type', 'application/xml') + @Header('Cache-Control', 'public, max-age=3600') + async getSitemapIndex(): Promise { + return await this.sitemapService.generateSitemapIndex(); + } + + /** + * Static pages sitemap + * Route: /sitemap/static.xml + */ + @Get('sitemap/static.xml') + @Header('Content-Type', 'application/xml') + @Header('Cache-Control', 'public, max-age=3600') + async getStaticSitemap(): Promise { + return await this.sitemapService.generateStaticSitemap(); + } + + /** + * Business listings sitemap (paginated) + * Route: /sitemap/business-1.xml, /sitemap/business-2.xml, etc. + */ + @Get('sitemap/business-:page.xml') + @Header('Content-Type', 'application/xml') + @Header('Cache-Control', 'public, max-age=3600') + async getBusinessSitemap(@Param('page', ParseIntPipe) page: number): Promise { + return await this.sitemapService.generateBusinessSitemap(page); + } + + /** + * Commercial property sitemap (paginated) + * Route: /sitemap/commercial-1.xml, /sitemap/commercial-2.xml, etc. + */ + @Get('sitemap/commercial-:page.xml') + @Header('Content-Type', 'application/xml') + @Header('Cache-Control', 'public, max-age=3600') + async getCommercialSitemap(@Param('page', ParseIntPipe) page: number): Promise { + return await this.sitemapService.generateCommercialSitemap(page); + } +} diff --git a/bizmatch-server/src/sitemap/sitemap.module.ts b/bizmatch-server/src/sitemap/sitemap.module.ts new file mode 100644 index 0000000..f14375f --- /dev/null +++ b/bizmatch-server/src/sitemap/sitemap.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { SitemapController } from './sitemap.controller'; +import { SitemapService } from './sitemap.service'; +import { DrizzleModule } from '../drizzle/drizzle.module'; + +@Module({ + imports: [DrizzleModule], + controllers: [SitemapController], + providers: [SitemapService], + exports: [SitemapService], +}) +export class SitemapModule {} diff --git a/bizmatch-server/src/sitemap/sitemap.service.ts b/bizmatch-server/src/sitemap/sitemap.service.ts new file mode 100644 index 0000000..f4946b4 --- /dev/null +++ b/bizmatch-server/src/sitemap/sitemap.service.ts @@ -0,0 +1,292 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { eq, sql } from 'drizzle-orm'; +import { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import * as schema from '../drizzle/schema'; +import { PG_CONNECTION } from '../drizzle/schema'; + +interface SitemapUrl { + loc: string; + lastmod?: string; + changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never'; + priority?: number; +} + +interface SitemapIndexEntry { + loc: string; + lastmod?: string; +} + +@Injectable() +export class SitemapService { + private readonly baseUrl = 'https://biz-match.com'; + private readonly URLS_PER_SITEMAP = 10000; // Google best practice + + constructor(@Inject(PG_CONNECTION) private readonly db: NodePgDatabase) {} + + /** + * Generate sitemap index (main sitemap.xml) + * Lists all sitemap files: static, business-1, business-2, commercial-1, etc. + */ + async generateSitemapIndex(): Promise { + const sitemaps: SitemapIndexEntry[] = []; + + // Add static pages sitemap + sitemaps.push({ + loc: `${this.baseUrl}/sitemap/static.xml`, + lastmod: this.formatDate(new Date()), + }); + + // Count business listings + const businessCount = await this.getBusinessListingsCount(); + const businessPages = Math.ceil(businessCount / this.URLS_PER_SITEMAP); + for (let page = 1; page <= businessPages; page++) { + sitemaps.push({ + loc: `${this.baseUrl}/sitemap/business-${page}.xml`, + lastmod: this.formatDate(new Date()), + }); + } + + // Count commercial property listings + const commercialCount = await this.getCommercialPropertiesCount(); + const commercialPages = Math.ceil(commercialCount / this.URLS_PER_SITEMAP); + for (let page = 1; page <= commercialPages; page++) { + sitemaps.push({ + loc: `${this.baseUrl}/sitemap/commercial-${page}.xml`, + lastmod: this.formatDate(new Date()), + }); + } + + return this.buildXmlSitemapIndex(sitemaps); + } + + /** + * Generate static pages sitemap + */ + async generateStaticSitemap(): Promise { + const urls = this.getStaticPageUrls(); + return this.buildXmlSitemap(urls); + } + + /** + * Generate business listings sitemap (paginated) + */ + async generateBusinessSitemap(page: number): Promise { + const offset = (page - 1) * this.URLS_PER_SITEMAP; + const urls = await this.getBusinessListingUrls(offset, this.URLS_PER_SITEMAP); + return this.buildXmlSitemap(urls); + } + + /** + * Generate commercial property sitemap (paginated) + */ + async generateCommercialSitemap(page: number): Promise { + const offset = (page - 1) * this.URLS_PER_SITEMAP; + const urls = await this.getCommercialPropertyUrls(offset, this.URLS_PER_SITEMAP); + return this.buildXmlSitemap(urls); + } + + /** + * Build XML sitemap index + */ + private buildXmlSitemapIndex(sitemaps: SitemapIndexEntry[]): string { + const sitemapElements = sitemaps + .map(sitemap => { + let element = ` \n ${sitemap.loc}`; + if (sitemap.lastmod) { + element += `\n ${sitemap.lastmod}`; + } + element += '\n '; + return element; + }) + .join('\n'); + + return ` + +${sitemapElements} +`; + } + + /** + * Build XML sitemap string + */ + private buildXmlSitemap(urls: SitemapUrl[]): string { + const urlElements = urls.map(url => this.buildUrlElement(url)).join('\n '); + + return ` + + ${urlElements} +`; + } + + /** + * Build single URL element + */ + private buildUrlElement(url: SitemapUrl): string { + let element = `\n ${url.loc}`; + + if (url.lastmod) { + element += `\n ${url.lastmod}`; + } + + if (url.changefreq) { + element += `\n ${url.changefreq}`; + } + + if (url.priority !== undefined) { + element += `\n ${url.priority.toFixed(1)}`; + } + + element += '\n '; + return element; + } + + /** + * Get static page URLs + */ + private getStaticPageUrls(): SitemapUrl[] { + return [ + { + loc: `${this.baseUrl}/`, + changefreq: 'daily', + priority: 1.0, + }, + { + loc: `${this.baseUrl}/home`, + changefreq: 'daily', + priority: 1.0, + }, + { + loc: `${this.baseUrl}/businessListings`, + changefreq: 'daily', + priority: 0.9, + }, + { + loc: `${this.baseUrl}/commercialPropertyListings`, + changefreq: 'daily', + priority: 0.9, + }, + { + loc: `${this.baseUrl}/brokerListings`, + changefreq: 'daily', + priority: 0.8, + }, + { + loc: `${this.baseUrl}/terms-of-use`, + changefreq: 'monthly', + priority: 0.5, + }, + { + loc: `${this.baseUrl}/privacy-statement`, + changefreq: 'monthly', + priority: 0.5, + }, + ]; + } + + /** + * Count business listings (non-draft) + */ + private async getBusinessListingsCount(): Promise { + try { + const result = await this.db + .select({ count: sql`count(*)` }) + .from(schema.businesses_json) + .where(sql`(${schema.businesses_json.data}->>'draft')::boolean IS NOT TRUE`); + + return Number(result[0]?.count || 0); + } catch (error) { + console.error('Error counting business listings:', error); + return 0; + } + } + + /** + * Count commercial properties (non-draft) + */ + private async getCommercialPropertiesCount(): Promise { + try { + const result = await this.db + .select({ count: sql`count(*)` }) + .from(schema.commercials_json) + .where(sql`(${schema.commercials_json.data}->>'draft')::boolean IS NOT TRUE`); + + return Number(result[0]?.count || 0); + } catch (error) { + console.error('Error counting commercial properties:', error); + return 0; + } + } + + /** + * Get business listing URLs from database (paginated, slug-based) + */ + private async getBusinessListingUrls(offset: number, limit: number): Promise { + try { + const listings = await this.db + .select({ + id: schema.businesses_json.id, + slug: sql`${schema.businesses_json.data}->>'slug'`, + updated: sql`(${schema.businesses_json.data}->>'updated')::timestamptz`, + created: sql`(${schema.businesses_json.data}->>'created')::timestamptz`, + }) + .from(schema.businesses_json) + .where(sql`(${schema.businesses_json.data}->>'draft')::boolean IS NOT TRUE`) + .limit(limit) + .offset(offset); + + return listings.map(listing => { + const urlSlug = listing.slug || listing.id; + return { + loc: `${this.baseUrl}/business/${urlSlug}`, + lastmod: this.formatDate(listing.updated || listing.created), + changefreq: 'weekly' as const, + priority: 0.8, + }; + }); + } catch (error) { + console.error('Error fetching business listings for sitemap:', error); + return []; + } + } + + /** + * Get commercial property URLs from database (paginated, slug-based) + */ + private async getCommercialPropertyUrls(offset: number, limit: number): Promise { + try { + const properties = await this.db + .select({ + id: schema.commercials_json.id, + slug: sql`${schema.commercials_json.data}->>'slug'`, + updated: sql`(${schema.commercials_json.data}->>'updated')::timestamptz`, + created: sql`(${schema.commercials_json.data}->>'created')::timestamptz`, + }) + .from(schema.commercials_json) + .where(sql`(${schema.commercials_json.data}->>'draft')::boolean IS NOT TRUE`) + .limit(limit) + .offset(offset); + + return properties.map(property => { + const urlSlug = property.slug || property.id; + return { + loc: `${this.baseUrl}/commercial-property/${urlSlug}`, + lastmod: this.formatDate(property.updated || property.created), + changefreq: 'weekly' as const, + priority: 0.8, + }; + }); + } catch (error) { + console.error('Error fetching commercial properties for sitemap:', error); + return []; + } + } + + /** + * Format date to ISO 8601 format (YYYY-MM-DD) + */ + private formatDate(date: Date | string): string { + if (!date) return new Date().toISOString().split('T')[0]; + const d = typeof date === 'string' ? new Date(date) : date; + return d.toISOString().split('T')[0]; + } +} diff --git a/bizmatch-server/src/user/user.service.ts b/bizmatch-server/src/user/user.service.ts index 4869e61..241fcf1 100644 --- a/bizmatch-server/src/user/user.service.ts +++ b/bizmatch-server/src/user/user.service.ts @@ -24,12 +24,15 @@ export class UserService { private getWhereConditions(criteria: UserListingCriteria): SQL[] { const whereConditions: SQL[] = []; whereConditions.push(sql`(${schema.users_json.data}->>'customerType') = 'professional'`); + if (criteria.city && criteria.searchType === 'exact') { 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_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`); + const distanceQuery = getDistanceQuery(schema.users_json, cityGeo.latitude, cityGeo.longitude); + whereConditions.push(sql`${distanceQuery} <= ${criteria.radius}`); } if (criteria.types && criteria.types.length > 0) { // whereConditions.push(inArray(schema.users.customerSubType, criteria.types)); @@ -46,11 +49,11 @@ export class UserService { } if (criteria.counties && criteria.counties.length > 0) { - whereConditions.push(or(...criteria.counties.map(county => sql`EXISTS (SELECT 1 FROM jsonb_array_elements(${schema.users_json.data}->'areasServed') AS area WHERE area->>'county' ILIKE ${`%${county}%`})`))); + whereConditions.push(or(...criteria.counties.map(county => sql`(${schema.users_json.data}->'location'->>'county') ILIKE ${`%${county}%`}`))); } if (criteria.state) { - whereConditions.push(sql`EXISTS (SELECT 1 FROM jsonb_array_elements(${schema.users_json.data}->'areasServed') AS area WHERE area->>'state' = ${criteria.state})`); + whereConditions.push(sql`(${schema.users_json.data}->'location'->>'state') = ${criteria.state}`); } //never show user which denied diff --git a/bizmatch-server/src/utils/slug.utils.ts b/bizmatch-server/src/utils/slug.utils.ts new file mode 100644 index 0000000..830eeef --- /dev/null +++ b/bizmatch-server/src/utils/slug.utils.ts @@ -0,0 +1,183 @@ +/** + * Utility functions for generating and parsing SEO-friendly URL slugs + * + * Slug format: {title}-{location}-{short-id} + * Example: italian-restaurant-austin-tx-a3f7b2c1 + */ + +/** + * Generate a SEO-friendly URL slug from listing data + * + * @param title - The listing title (e.g., "Italian Restaurant") + * @param location - Location object with name, county, and state + * @param id - The listing UUID + * @returns SEO-friendly slug (e.g., "italian-restaurant-austin-tx-a3f7b2c1") + */ +export function generateSlug(title: string, location: any, id: string): string { + if (!title || !id) { + throw new Error('Title and ID are required to generate a slug'); + } + + // Clean and slugify the title + const titleSlug = title + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') // Remove special characters + .replace(/\s+/g, '-') // Replace spaces with hyphens + .replace(/-+/g, '-') // Replace multiple hyphens with single hyphen + .substring(0, 50); // Limit title to 50 characters + + // Get location string + let locationSlug = ''; + if (location) { + const locationName = location.name || location.county || ''; + const state = location.state || ''; + + if (locationName) { + locationSlug = locationName + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-'); + } + + if (state) { + locationSlug = locationSlug + ? `${locationSlug}-${state.toLowerCase()}` + : state.toLowerCase(); + } + } + + // Get first 8 characters of UUID for uniqueness + const shortId = id.substring(0, 8); + + // Combine parts: title-location-id + const parts = [titleSlug, locationSlug, shortId].filter(Boolean); + const slug = parts.join('-'); + + // Final cleanup + return slug + .replace(/-+/g, '-') // Remove duplicate hyphens + .replace(/^-|-$/g, '') // Remove leading/trailing hyphens + .toLowerCase(); +} + +/** + * Extract the UUID from a slug + * The UUID is always the last segment (8 characters) + * + * @param slug - The URL slug (e.g., "italian-restaurant-austin-tx-a3f7b2c1") + * @returns The short ID (e.g., "a3f7b2c1") + */ +export function extractShortIdFromSlug(slug: string): string { + if (!slug) { + throw new Error('Slug is required'); + } + + const parts = slug.split('-'); + return parts[parts.length - 1]; +} + +/** + * Validate if a string looks like a valid slug + * + * @param slug - The string to validate + * @returns true if the string looks like a valid slug + */ +export function isValidSlug(slug: string): boolean { + if (!slug || typeof slug !== 'string') { + return false; + } + + // Check if slug contains only lowercase letters, numbers, and hyphens + const slugPattern = /^[a-z0-9-]+$/; + if (!slugPattern.test(slug)) { + return false; + } + + // Check if slug has a reasonable length (at least 10 chars for short-id + some content) + if (slug.length < 10) { + return false; + } + + // Check if last segment looks like a UUID prefix (8 chars of alphanumeric) + const parts = slug.split('-'); + const lastPart = parts[parts.length - 1]; + return lastPart.length === 8 && /^[a-z0-9]{8}$/.test(lastPart); +} + +/** + * Check if a parameter is a slug (vs a UUID) + * + * @param param - The URL parameter + * @returns true if it's a slug, false if it's likely a UUID + */ +export function isSlug(param: string): boolean { + if (!param) { + return false; + } + + // UUIDs have a specific format with hyphens at specific positions + // e.g., "a3f7b2c1-4d5e-6789-abcd-1234567890ef" + const uuidPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i; + + if (uuidPattern.test(param)) { + return false; // It's a UUID + } + + // If it contains more than 4 hyphens and looks like our slug format, it's probably a slug + return param.split('-').length > 4 && isValidSlug(param); +} + +/** + * Regenerate slug from updated listing data + * Useful when title or location changes + * + * @param title - Updated title + * @param location - Updated location + * @param existingSlug - The current slug (to preserve short-id) + * @returns New slug with same short-id + */ +export function regenerateSlug(title: string, location: any, existingSlug: string): string { + if (!existingSlug) { + throw new Error('Existing slug is required to regenerate'); + } + + const shortId = extractShortIdFromSlug(existingSlug); + + // Reconstruct full UUID from short-id (not possible, so we use full existing slug's ID) + // In practice, you'd need the full UUID from the database + // For now, we'll construct a new slug with the short-id + const titleSlug = title + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .substring(0, 50); + + let locationSlug = ''; + if (location) { + const locationName = location.name || location.county || ''; + const state = location.state || ''; + + if (locationName) { + locationSlug = locationName + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-'); + } + + if (state) { + locationSlug = locationSlug + ? `${locationSlug}-${state.toLowerCase()}` + : state.toLowerCase(); + } + } + + const parts = [titleSlug, locationSlug, shortId].filter(Boolean); + return parts.join('-').replace(/-+/g, '-').replace(/^-|-$/g, '').toLowerCase(); +} diff --git a/bizmatch-server/tsconfig.json b/bizmatch-server/tsconfig.json index f522e41..2d1b409 100644 --- a/bizmatch-server/tsconfig.json +++ b/bizmatch-server/tsconfig.json @@ -19,5 +19,12 @@ "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": false, "esModuleInterop": true - } + }, + "exclude": [ + "node_modules", + "dist", + "src/scripts/seed-database.ts", + "src/scripts/create-test-user.ts", + "src/sitemap" + ] } diff --git a/bizmatch/angular.json b/bizmatch/angular.json index b1f887f..044519b 100644 --- a/bizmatch/angular.json +++ b/bizmatch/angular.json @@ -32,10 +32,16 @@ "input": "public" }, "src/favicon.ico", - "src/assets" + "src/assets", + { + "glob": "**/*", + "input": "node_modules/leaflet/dist/images", + "output": "assets/leaflet/" + } ], "styles": [ "src/styles.scss", + "src/styles/lazy-load.css", "node_modules/quill/dist/quill.snow.css", "node_modules/leaflet/dist/leaflet.css" ] diff --git a/bizmatch/package.json b/bizmatch/package.json index 54ca2ba..3832496 100644 --- a/bizmatch/package.json +++ b/bizmatch/package.json @@ -25,6 +25,7 @@ "@angular/platform-browser-dynamic": "^18.1.3", "@angular/platform-server": "^18.1.3", "@angular/router": "^18.1.3", + "@angular/ssr": "^18.2.21", "@bluehalo/ngx-leaflet": "^18.0.2", "@fortawesome/angular-fontawesome": "^0.15.0", "@fortawesome/fontawesome-free": "^6.7.2", @@ -58,7 +59,9 @@ "tslib": "^2.6.3", "urlcat": "^3.1.0", "uuid": "^10.0.0", - "zone.js": "~0.14.7" + "zone.js": "~0.14.7", + "stripe": "^19.3.0", + "zod": "^4.1.12" }, "devDependencies": { "@angular-devkit/build-angular": "^18.1.3", diff --git a/bizmatch/server.ts b/bizmatch/server.ts index e3d3d67..7083b14 100644 --- a/bizmatch/server.ts +++ b/bizmatch/server.ts @@ -1,56 +1,56 @@ -// import { APP_BASE_HREF } from '@angular/common'; -// import { CommonEngine } from '@angular/ssr'; -// import express from 'express'; -// import { fileURLToPath } from 'node:url'; -// import { dirname, join, resolve } from 'node:path'; -// import bootstrap from './src/main.server'; +import { APP_BASE_HREF } from '@angular/common'; +import { CommonEngine } from '@angular/ssr'; +import express from 'express'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; +import bootstrap from './src/main.server'; -// // The Express app is exported so that it can be used by serverless Functions. -// export function app(): express.Express { -// const server = express(); -// const serverDistFolder = dirname(fileURLToPath(import.meta.url)); -// const browserDistFolder = resolve(serverDistFolder, '../browser'); -// const indexHtml = join(serverDistFolder, 'index.server.html'); +// The Express app is exported so that it can be used by serverless Functions. +export function app(): express.Express { + const server = express(); + const serverDistFolder = dirname(fileURLToPath(import.meta.url)); + const browserDistFolder = resolve(serverDistFolder, '../browser'); + const indexHtml = join(serverDistFolder, 'index.server.html'); -// const commonEngine = new CommonEngine(); + const commonEngine = new CommonEngine(); -// server.set('view engine', 'html'); -// server.set('views', browserDistFolder); + server.set('view engine', 'html'); + server.set('views', browserDistFolder); -// // Example Express Rest API endpoints -// // server.get('/api/**', (req, res) => { }); -// // Serve static files from /browser -// server.get('*.*', express.static(browserDistFolder, { -// maxAge: '1y' -// })); + // Example Express Rest API endpoints + // server.get('/api/**', (req, res) => { }); + // Serve static files from /browser + server.get('*.*', express.static(browserDistFolder, { + maxAge: '1y' + })); -// // All regular routes use the Angular engine -// server.get('*', (req, res, next) => { -// const { protocol, originalUrl, baseUrl, headers } = req; + // All regular routes use the Angular engine + server.get('*', (req, res, next) => { + const { protocol, originalUrl, baseUrl, headers } = req; -// commonEngine -// .render({ -// bootstrap, -// documentFilePath: indexHtml, -// url: `${protocol}://${headers.host}${originalUrl}`, -// publicPath: browserDistFolder, -// providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }], -// }) -// .then((html) => res.send(html)) -// .catch((err) => next(err)); -// }); + commonEngine + .render({ + bootstrap, + documentFilePath: indexHtml, + url: `${protocol}://${headers.host}${originalUrl}`, + publicPath: browserDistFolder, + providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }], + }) + .then((html) => res.send(html)) + .catch((err) => next(err)); + }); -// return server; -// } + return server; +} -// function run(): void { -// const port = process.env['PORT'] || 4000; +function run(): void { + const port = process.env['PORT'] || 4000; -// // Start up the Node server -// const server = app(); -// server.listen(port, () => { -// console.log(`Node Express server listening on http://localhost:${port}`); -// }); -// } + // Start up the Node server + const server = app(); + server.listen(port, () => { + console.log(`Node Express server listening on http://localhost:${port}`); + }); +} -// run(); +run(); diff --git a/bizmatch/src/app/app.routes.ts b/bizmatch/src/app/app.routes.ts index 3b0bdd6..468be77 100644 --- a/bizmatch/src/app/app.routes.ts +++ b/bizmatch/src/app/app.routes.ts @@ -23,6 +23,8 @@ import { EmailUsComponent } from './pages/subscription/email-us/email-us.compone import { FavoritesComponent } from './pages/subscription/favorites/favorites.component'; import { MyListingComponent } from './pages/subscription/my-listing/my-listing.component'; import { SuccessComponent } from './pages/success/success.component'; +import { TermsOfUseComponent } from './pages/legal/terms-of-use.component'; +import { PrivacyStatementComponent } from './pages/legal/privacy-statement.component'; export const routes: Routes = [ { @@ -45,15 +47,26 @@ export const routes: Routes = [ component: HomeComponent, }, // ######### - // Listings Details + // Listings Details - New SEO-friendly slug-based URLs { - path: 'details-business-listing/:id', + path: 'business/:slug', component: DetailsBusinessListingComponent, }, { - path: 'details-commercial-property-listing/:id', + path: 'commercial-property/:slug', component: DetailsCommercialPropertyListingComponent, }, + // Backward compatibility redirects for old UUID-based URLs + { + path: 'details-business-listing/:id', + redirectTo: 'business/:id', + pathMatch: 'full', + }, + { + path: 'details-commercial-property-listing/:id', + redirectTo: 'commercial-property/:id', + pathMatch: 'full', + }, { path: 'listing/:id', canActivate: [ListingCategoryGuard], @@ -177,5 +190,15 @@ export const routes: Routes = [ component: UserListComponent, canActivate: [AuthGuard], }, + // ######### + // Legal Pages + { + path: 'terms-of-use', + component: TermsOfUseComponent, + }, + { + path: 'privacy-statement', + component: PrivacyStatementComponent, + }, { path: '**', redirectTo: 'home' }, ]; diff --git a/bizmatch/src/app/components/breadcrumbs/breadcrumbs.component.ts b/bizmatch/src/app/components/breadcrumbs/breadcrumbs.component.ts new file mode 100644 index 0000000..79d944d --- /dev/null +++ b/bizmatch/src/app/components/breadcrumbs/breadcrumbs.component.ts @@ -0,0 +1,68 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input } from '@angular/core'; +import { RouterModule } from '@angular/router'; + +export interface BreadcrumbItem { + label: string; + url?: string; + icon?: string; +} + +@Component({ + selector: 'app-breadcrumbs', + standalone: true, + imports: [CommonModule, RouterModule], + template: ` + + `, + styles: [] +}) +export class BreadcrumbsComponent { + @Input() breadcrumbs: BreadcrumbItem[] = []; +} diff --git a/bizmatch/src/app/components/email/email.component.ts b/bizmatch/src/app/components/email/email.component.ts index e2aa94f..5a0fda7 100644 --- a/bizmatch/src/app/components/email/email.component.ts +++ b/bizmatch/src/app/components/email/email.component.ts @@ -17,7 +17,15 @@ import { EMailService } from './email.service'; template: ``, }) export class EMailComponent { - shareByEMail: ShareByEMail = {}; + shareByEMail: ShareByEMail = { + yourName: '', + recipientEmail: '', + yourEmail: '', + type: 'business', + listingTitle: '', + url: '', + id: '' + }; constructor(public eMailService: EMailService, private mailService: MailService, private validationMessagesService: ValidationMessagesService) {} ngOnInit() { this.eMailService.shareByEMail$.pipe(untilDestroyed(this)).subscribe(val => { diff --git a/bizmatch/src/app/components/faq/faq.component.ts b/bizmatch/src/app/components/faq/faq.component.ts new file mode 100644 index 0000000..f39fe14 --- /dev/null +++ b/bizmatch/src/app/components/faq/faq.component.ts @@ -0,0 +1,93 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input, OnInit } from '@angular/core'; +import { SeoService } from '../../services/seo.service'; + +export interface FAQItem { + question: string; + answer: string; +} + +@Component({ + selector: 'app-faq', + standalone: true, + imports: [CommonModule], + template: ` +
+

Frequently Asked Questions

+ +
+ @for (item of faqItems; track $index) { +
+ + + @if (openIndex === $index) { +
+

+
+ } +
+ } +
+
+ `, + styles: [` + .rotate-180 { + transform: rotate(180deg); + } + `] +}) +export class FaqComponent implements OnInit { + @Input() faqItems: FAQItem[] = []; + openIndex: number | null = null; + + constructor(private seoService: SeoService) {} + + ngOnInit() { + // Generate and inject FAQ Schema for rich snippets + if (this.faqItems.length > 0) { + const faqSchema = { + '@context': 'https://schema.org', + '@type': 'FAQPage', + 'mainEntity': this.faqItems.map(item => ({ + '@type': 'Question', + 'name': item.question, + 'acceptedAnswer': { + '@type': 'Answer', + 'text': this.stripHtml(item.answer) + } + })) + }; + + this.seoService.injectStructuredData(faqSchema); + } + } + + toggle(index: number) { + this.openIndex = this.openIndex === index ? null : index; + } + + private stripHtml(html: string): string { + const tmp = document.createElement('DIV'); + tmp.innerHTML = html; + return tmp.textContent || tmp.innerText || ''; + } + + ngOnDestroy() { + this.seoService.clearStructuredData(); + } +} diff --git a/bizmatch/src/app/components/footer/footer.component.html b/bizmatch/src/app/components/footer/footer.component.html index 4d6f7fe..59d6610 100644 --- a/bizmatch/src/app/components/footer/footer.component.html +++ b/bizmatch/src/app/components/footer/footer.component.html @@ -3,32 +3,32 @@
- + -

© {{ currentYear }} Bizmatch All rights reserved.

+

© {{ currentYear }} Bizmatch All rights reserved.

-

BizMatch, Inc., 1001 Blucher Street, Corpus

-

Christi, Texas 78401

+

BizMatch, Inc., 1001 Blucher Street, Corpus

+

Christi, Texas 78401

-
-
+
+
@@ -38,7 +38,7 @@ type="button" data-drawer-hide="privacy" aria-controls="privacy" - class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 absolute top-2.5 end-2.5 flex items-center justify-center dark:hover:bg-gray-600 dark:hover:text-white" + class="text-neutral-400 bg-transparent hover:bg-neutral-200 hover:text-neutral-900 rounded-lg text-sm w-8 h-8 absolute top-2.5 end-2.5 flex items-center justify-center dark:hover:bg-neutral-600 dark:hover:text-white" >
-
-
+
+
@@ -253,7 +253,7 @@ type="button" data-drawer-hide="terms-of-use" aria-controls="terms-of-use" - class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 absolute top-2.5 end-2.5 flex items-center justify-center dark:hover:bg-gray-600 dark:hover:text-white" + class="text-neutral-400 bg-transparent hover:bg-neutral-200 hover:text-neutral-900 rounded-lg text-sm w-8 h-8 absolute top-2.5 end-2.5 flex items-center justify-center dark:hover:bg-neutral-600 dark:hover:text-white" >
- Flowbite Logo + BizMatch - Business Marketplace for Buying and Selling Businesses
@@ -11,18 +11,18 @@ -
-
    +
    +
      @for(item of sortByOptions; track item){ -
    • {{ item.selectName ? item.selectName : item.name }}
    • +
    • {{ item.selectName ? item.selectName : item.name }}
    • }
    @@ -30,7 +30,7 @@ } @if(user){ -
-

{{ listing.location.name ? listing.location.name : listing.location.county }}

+

{{ listing.location.name ? listing.location.name : listing.location.county }}

{{ listing.price | currency : 'USD' : 'symbol' : '1.0-0' }}

-
@@ -89,7 +120,7 @@

There’s no listing here

Try changing your filters to
see listings

- +
@@ -102,5 +133,5 @@ - + diff --git a/bizmatch/src/app/pages/listings/commercial-property-listings/commercial-property-listings.component.ts b/bizmatch/src/app/pages/listings/commercial-property-listings/commercial-property-listings.component.ts index a874c13..6c0ab88 100644 --- a/bizmatch/src/app/pages/listings/commercial-property-listings/commercial-property-listings.component.ts +++ b/bizmatch/src/app/pages/listings/commercial-property-listings/commercial-property-listings.component.ts @@ -6,22 +6,28 @@ import { UntilDestroy } from '@ngneat/until-destroy'; import dayjs from 'dayjs'; 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 { CommercialPropertyListingCriteria, KeycloakUser, LISTINGS_PER_PAGE, ResponseCommercialPropertyListingArray } from '../../../../../../bizmatch-server/src/models/main.model'; import { environment } from '../../../../environments/environment'; +import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component'; import { PaginatorComponent } from '../../../components/paginator/paginator.component'; import { ModalService } from '../../../components/search-modal/modal.service'; import { SearchModalCommercialComponent } from '../../../components/search-modal/search-modal-commercial.component'; +import { LazyLoadImageDirective } from '../../../directives/lazy-load-image.directive'; +import { AltTextService } from '../../../services/alt-text.service'; 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 { SeoService } from '../../../services/seo.service'; +import { AuthService } from '../../../services/auth.service'; +import { map2User } from '../../../utils/utils'; @UntilDestroy() @Component({ selector: 'app-commercial-property-listings', standalone: true, - imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, SearchModalCommercialComponent], + imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, SearchModalCommercialComponent, LazyLoadImageDirective, BreadcrumbsComponent], templateUrl: './commercial-property-listings.component.html', styleUrls: ['./commercial-property-listings.component.scss', '../../pages.scss'], }) @@ -46,7 +52,17 @@ export class CommercialPropertyListingsComponent implements OnInit, OnDestroy { // UI state ts = new Date().getTime(); + // Breadcrumbs + breadcrumbs: BreadcrumbItem[] = [ + { label: 'Home', url: '/home', icon: 'fas fa-home' }, + { label: 'Commercial Properties' } + ]; + + // User for favorites + user: KeycloakUser | null = null; + constructor( + public altText: AltTextService, public selectOptions: SelectOptionsService, private listingsService: ListingsService, private router: Router, @@ -56,9 +72,23 @@ export class CommercialPropertyListingsComponent implements OnInit, OnDestroy { private modalService: ModalService, private filterStateService: FilterStateService, private route: ActivatedRoute, + private seoService: SeoService, + private authService: AuthService, ) {} - ngOnInit(): void { + async ngOnInit(): Promise { + // Load user for favorites functionality + const token = await this.authService.getToken(); + this.user = map2User(token); + + // Set SEO meta tags for commercial property listings page + this.seoService.updateMetaTags({ + title: 'Commercial Properties for Sale - Office, Retail, Industrial Real Estate | BizMatch', + description: 'Browse commercial real estate listings including office buildings, retail spaces, warehouses, and industrial properties. Investment opportunities from verified sellers and brokers across the United States.', + keywords: 'commercial property for sale, commercial real estate, office building for sale, retail space for sale, warehouse for sale, industrial property, investment property, commercial property listings', + type: 'website' + }); + // Subscribe to state changes this.filterStateService .getState$('commercialPropertyListings') @@ -87,6 +117,9 @@ export class CommercialPropertyListingsComponent implements OnInit, OnDestroy { this.pageCount = Math.ceil(this.totalRecords / LISTINGS_PER_PAGE); this.page = this.criteria.page || 1; + // Update pagination SEO links + this.updatePaginationSEO(); + // Update view this.cdRef.markForCheck(); this.cdRef.detectChanges(); @@ -158,8 +191,71 @@ export class CommercialPropertyListingsComponent implements OnInit, OnDestroy { this.router.navigate(['/details-commercial-property-listing', listingId]); } + /** + * Check if listing is already in user's favorites + */ + isFavorite(listing: CommercialPropertyListing): boolean { + if (!this.user?.email || !listing.favoritesForUser) return false; + return listing.favoritesForUser.includes(this.user.email); + } + + /** + * Toggle favorite status for a listing + */ + async toggleFavorite(event: Event, listing: CommercialPropertyListing): Promise { + event.stopPropagation(); + event.preventDefault(); + + if (!this.user?.email) { + // User not logged in - redirect to login + this.router.navigate(['/login']); + return; + } + + try { + if (this.isFavorite(listing)) { + // Remove from favorites + await this.listingsService.removeFavorite(listing.id, 'commercialProperty'); + listing.favoritesForUser = listing.favoritesForUser.filter(email => email !== this.user!.email); + } else { + // Add to favorites + await this.listingsService.addToFavorites(listing.id, 'commercialProperty'); + if (!listing.favoritesForUser) { + listing.favoritesForUser = []; + } + listing.favoritesForUser.push(this.user.email); + } + this.cdRef.detectChanges(); + } catch (error) { + console.error('Error toggling favorite:', error); + } + } + ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); + // Clean up pagination links when leaving the page + this.seoService.clearPaginationLinks(); + } + + /** + * Update pagination SEO links (rel="next/prev") and CollectionPage schema + */ + private updatePaginationSEO(): void { + const baseUrl = `${this.seoService.getBaseUrl()}/commercialPropertyListings`; + + // Inject rel="next" and rel="prev" links + this.seoService.injectPaginationLinks(baseUrl, this.page, this.pageCount); + + // Inject CollectionPage schema for paginated results + const collectionSchema = this.seoService.generateCollectionPageSchema({ + name: 'Commercial Properties for Sale', + description: 'Browse commercial real estate listings including office buildings, retail spaces, warehouses, and industrial properties across the United States.', + totalItems: this.totalRecords, + itemsPerPage: LISTINGS_PER_PAGE, + currentPage: this.page, + baseUrl: baseUrl + }); + this.seoService.injectStructuredData(collectionSchema); } } diff --git a/bizmatch/src/app/pages/subscription/edit-business-listing/edit-business-listing.component.ts b/bizmatch/src/app/pages/subscription/edit-business-listing/edit-business-listing.component.ts index 655b3c6..7a6f8f6 100644 --- a/bizmatch/src/app/pages/subscription/edit-business-listing/edit-business-listing.component.ts +++ b/bizmatch/src/app/pages/subscription/edit-business-listing/edit-business-listing.component.ts @@ -128,14 +128,21 @@ export class EditBusinessListingComponent { this.messageService.addMessage({ severity: 'success', text: 'Listing changes have been persisted', duration: 3000 }); this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten } catch (error) { - this.messageService.addMessage({ - severity: 'danger', - text: 'An error occurred while saving the profile - Please check your inputs', - duration: 5000, - }); + console.error('Error saving listing:', error); + let errorText = 'An error occurred while saving the listing - Please check your inputs'; + if (error.error && Array.isArray(error.error?.message)) { this.validationMessagesService.updateMessages(error.error.message); + errorText = 'Please fix the validation errors highlighted in the form'; + } else if (error.error?.message) { + errorText = `Error: ${error.error.message}`; } + + this.messageService.addMessage({ + severity: 'danger', + text: errorText, + duration: 5000, + }); } } diff --git a/bizmatch/src/app/pages/subscription/edit-commercial-property-listing/edit-commercial-property-listing.component.ts b/bizmatch/src/app/pages/subscription/edit-commercial-property-listing/edit-commercial-property-listing.component.ts index 1bb6ea5..0d621ab 100644 --- a/bizmatch/src/app/pages/subscription/edit-commercial-property-listing/edit-commercial-property-listing.component.ts +++ b/bizmatch/src/app/pages/subscription/edit-commercial-property-listing/edit-commercial-property-listing.component.ts @@ -177,14 +177,21 @@ export class EditCommercialPropertyListingComponent { this.messageService.addMessage({ severity: 'success', text: 'Listing changes have been persisted', duration: 3000 }); this.validationMessagesService.clearMessages(); // Löschen Sie alle bestehenden Validierungsnachrichten } catch (error) { - this.messageService.addMessage({ - severity: 'danger', - text: 'An error occurred while saving the profile', - duration: 5000, - }); + console.error('Error saving listing:', error); + let errorText = 'An error occurred while saving the listing - Please check your inputs'; + if (error.error && Array.isArray(error.error?.message)) { this.validationMessagesService.updateMessages(error.error.message); + errorText = 'Please fix the validation errors highlighted in the form'; + } else if (error.error?.message) { + errorText = `Error: ${error.error.message}`; } + + this.messageService.addMessage({ + severity: 'danger', + text: errorText, + duration: 5000, + }); } } diff --git a/bizmatch/src/app/pages/subscription/favorites/favorites.component.html b/bizmatch/src/app/pages/subscription/favorites/favorites.component.html index f42ab5d..fab9220 100644 --- a/bizmatch/src/app/pages/subscription/favorites/favorites.component.html +++ b/bizmatch/src/app/pages/subscription/favorites/favorites.component.html @@ -23,12 +23,12 @@ ${{ listing.price.toLocaleString() }} @if(listing.listingsCategory==='business'){ - } @if(listing.listingsCategory==='commercialProperty'){ - } @@ -51,12 +51,12 @@

Price: ${{ listing.price.toLocaleString() }}

@if(listing.listingsCategory==='business'){ - } @if(listing.listingsCategory==='commercialProperty'){ - } diff --git a/bizmatch/src/app/services/alt-text.service.ts b/bizmatch/src/app/services/alt-text.service.ts new file mode 100644 index 0000000..6556bbf --- /dev/null +++ b/bizmatch/src/app/services/alt-text.service.ts @@ -0,0 +1,120 @@ +import { Injectable, inject } from '@angular/core'; +import { SelectOptionsService } from './select-options.service'; + +/** + * Service for generating SEO-optimized alt text for images + * Ensures consistent, keyword-rich alt text across the application + */ +@Injectable({ + providedIn: 'root' +}) +export class AltTextService { + private selectOptions = inject(SelectOptionsService); + + /** + * Generate alt text for business listing images + * Format: "Business Name - Business Type for sale in City, State" + * Example: "Italian Restaurant - Restaurant for sale in Austin, TX" + */ + generateBusinessListingAlt(listing: any): string { + const location = this.getLocationString(listing.location); + const businessType = listing.type ? this.selectOptions.getBusiness(listing.type) : 'Business'; + + return `${listing.title} - ${businessType} for sale in ${location}`; + } + + /** + * Generate alt text for commercial property listing images + * Format: "Property Type for sale - Title in City, State" + * Example: "Retail Space for sale - Downtown storefront in Miami, FL" + */ + generatePropertyListingAlt(listing: any): string { + const location = this.getLocationString(listing.location); + const propertyType = listing.type ? this.selectOptions.getCommercialProperty(listing.type) : 'Commercial Property'; + + return `${propertyType} for sale - ${listing.title} in ${location}`; + } + + /** + * Generate alt text for broker/user profile photos + * Format: "First Last - Customer Type broker profile photo" + * Example: "John Smith - Business broker profile photo" + */ + generateBrokerProfileAlt(user: any): string { + const name = `${user.firstname || ''} ${user.lastname || ''}`.trim() || 'Broker'; + const customerType = user.customerSubType ? this.selectOptions.getCustomerSubType(user.customerSubType) : 'Business'; + + return `${name} - ${customerType} broker profile photo`; + } + + /** + * Generate alt text for company logos + * Format: "Company Name company logo" or "Owner Name company logo" + * Example: "ABC Realty company logo" + */ + generateCompanyLogoAlt(companyName?: string, ownerName?: string): string { + const name = companyName || ownerName || 'Company'; + return `${name} company logo`; + } + + /** + * Generate alt text for listing card logo images + * Includes business name and location for context + */ + generateListingCardLogoAlt(listing: any): string { + const location = this.getLocationString(listing.location); + return `${listing.title} - Business for sale in ${location}`; + } + + /** + * Generate alt text for property images in detail view + * Format: "Title - Property Type image (index)" + * Example: "Downtown Office Space - Office building image 1 of 5" + */ + generatePropertyImageAlt(title: string, propertyType: string, imageIndex?: number, totalImages?: number): string { + let alt = `${title} - ${propertyType}`; + + if (imageIndex !== undefined && totalImages !== undefined && totalImages > 1) { + alt += ` image ${imageIndex + 1} of ${totalImages}`; + } else { + alt += ' image'; + } + + return alt; + } + + /** + * Generate alt text for business images in detail view + * Format: "Title - Business Type image (index)" + */ + generateBusinessImageAlt(title: string, businessType: string, imageIndex?: number, totalImages?: number): string { + let alt = `${title} - ${businessType}`; + + if (imageIndex !== undefined && totalImages !== undefined && totalImages > 1) { + alt += ` image ${imageIndex + 1} of ${totalImages}`; + } else { + alt += ' image'; + } + + return alt; + } + + /** + * Helper: Get location string from location object + * Returns: "City, STATE" or "County, STATE" or "STATE" + */ + private getLocationString(location: any): string { + if (!location) return 'United States'; + + const city = location.name || location.county; + const state = location.state || ''; + + if (city && state) { + return `${city}, ${state}`; + } else if (state) { + return this.selectOptions.getState(state) || state; + } + + return 'United States'; + } +} diff --git a/bizmatch/src/app/services/audit.service.ts b/bizmatch/src/app/services/audit.service.ts index ae8ac3d..f87d9f0 100644 --- a/bizmatch/src/app/services/audit.service.ts +++ b/bizmatch/src/app/services/audit.service.ts @@ -1,6 +1,7 @@ import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { lastValueFrom } from 'rxjs'; +import { v4 as uuidv4 } from 'uuid'; import { EventTypeEnum, ListingEvent } from '../../../../bizmatch-server/src/models/db.model'; import { LogMessage } from '../../../../bizmatch-server/src/models/main.model'; import { environment } from '../../environments/environment'; @@ -22,6 +23,7 @@ export class AuditService { const ipInfo = await this.geoService.getIpInfo(); const [latitude, longitude] = ipInfo.loc ? ipInfo.loc.split(',') : [null, null]; //.map(Number); const listingEvent: ListingEvent = { + id: uuidv4(), listingId: id, eventType, eventTimestamp: new Date(), diff --git a/bizmatch/src/app/services/geo.service.ts b/bizmatch/src/app/services/geo.service.ts index 3e18e5d..a4a9bad 100644 --- a/bizmatch/src/app/services/geo.service.ts +++ b/bizmatch/src/app/services/geo.service.ts @@ -29,6 +29,12 @@ export class GeoService { let headers = new HttpHeaders().set('X-Hide-Loading', 'true').set('Accept-Language', 'en-US'); return this.http.get(`${this.baseUrl}?q=${prefix},US&format=json&addressdetails=1&limit=5`, { headers }) as Observable; } + + getCityBoundary(cityName: string, state: string): Observable { + const query = `${cityName}, ${state}, USA`; + let headers = new HttpHeaders().set('X-Hide-Loading', 'true').set('Accept-Language', 'en-US'); + return this.http.get(`${this.baseUrl}?q=${encodeURIComponent(query)}&format=json&polygon_geojson=1&limit=1`, { headers }); + } private fetchIpAndGeoLocation(): Observable { return this.http.get(`${this.apiBaseUrl}/bizmatch/geo/ipinfo/georesult/wysiwyg`); } diff --git a/bizmatch/src/app/services/listings.service.ts b/bizmatch/src/app/services/listings.service.ts index 4d02ae9..e79721d 100644 --- a/bizmatch/src/app/services/listings.service.ts +++ b/bizmatch/src/app/services/listings.service.ts @@ -51,7 +51,49 @@ export class ListingsService { async deleteCommercialPropertyListing(id: string, imagePath: string) { await lastValueFrom(this.http.delete(`${this.apiBaseUrl}/bizmatch/listings/commercialProperty/listing/${id}/${imagePath}`)); } + async addToFavorites(id: string, listingsCategory?: 'business' | 'commercialProperty') { + await lastValueFrom(this.http.post(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorite/${id}`, {})); + } async removeFavorite(id: string, listingsCategory?: 'business' | 'commercialProperty') { await lastValueFrom(this.http.delete(`${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/favorite/${id}`)); } + + /** + * Get related listings based on current listing + * Finds listings with same category, same state, and similar price range + * @param currentListing The current listing to find related items for + * @param listingsCategory Type of listings (business or commercialProperty) + * @param limit Maximum number of related listings to return + * @returns Array of related listings + */ + async getRelatedListings(currentListing: any, listingsCategory: 'business' | 'commercialProperty', limit: number = 3): Promise { + const criteria: any = { + types: [currentListing.type], // Same category/type + state: currentListing.location.state, // Same state + minPrice: currentListing.price ? Math.floor(currentListing.price * 0.5) : undefined, // 50% lower + maxPrice: currentListing.price ? Math.ceil(currentListing.price * 1.5) : undefined, // 50% higher + page: 0, + resultsPerPage: limit + 1, // Get one extra to filter out current listing + showDraft: false + }; + + try { + const response = await lastValueFrom( + this.http.post( + `${this.apiBaseUrl}/bizmatch/listings/${listingsCategory}/find`, + criteria + ) + ); + + // Filter out the current listing and limit results + const filtered = response.results + .filter(listing => listing.id !== currentListing.id) + .slice(0, limit); + + return filtered; + } catch (error) { + console.error('Error fetching related listings:', error); + return []; + } + } } diff --git a/bizmatch/src/app/services/seo.service.ts b/bizmatch/src/app/services/seo.service.ts new file mode 100644 index 0000000..a737baa --- /dev/null +++ b/bizmatch/src/app/services/seo.service.ts @@ -0,0 +1,582 @@ +import { Injectable, inject } from '@angular/core'; +import { Meta, Title } from '@angular/platform-browser'; +import { Router } from '@angular/router'; + +export interface SEOData { + title: string; + description: string; + image?: string; + url?: string; + keywords?: string; + type?: string; + author?: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class SeoService { + private meta = inject(Meta); + private title = inject(Title); + private router = inject(Router); + + private readonly defaultImage = 'https://biz-match.com/assets/images/bizmatch-og-image.jpg'; + private readonly siteName = 'BizMatch'; + private readonly baseUrl = 'https://biz-match.com'; + + /** + * Get the base URL for SEO purposes + */ + getBaseUrl(): string { + return this.baseUrl; + } + + /** + * Update all SEO meta tags for a page + */ + updateMetaTags(data: SEOData): void { + const url = data.url || `${this.baseUrl}${this.router.url}`; + const image = data.image || this.defaultImage; + const type = data.type || 'website'; + + // Update page title + this.title.setTitle(data.title); + + // Standard meta tags + this.meta.updateTag({ name: 'description', content: data.description }); + if (data.keywords) { + this.meta.updateTag({ name: 'keywords', content: data.keywords }); + } + if (data.author) { + this.meta.updateTag({ name: 'author', content: data.author }); + } + + // Open Graph tags (Facebook, LinkedIn, etc.) + this.meta.updateTag({ property: 'og:title', content: data.title }); + this.meta.updateTag({ property: 'og:description', content: data.description }); + this.meta.updateTag({ property: 'og:image', content: image }); + this.meta.updateTag({ property: 'og:url', content: url }); + this.meta.updateTag({ property: 'og:type', content: type }); + this.meta.updateTag({ property: 'og:site_name', content: this.siteName }); + + // Twitter Card tags + this.meta.updateTag({ name: 'twitter:card', content: 'summary_large_image' }); + this.meta.updateTag({ name: 'twitter:title', content: data.title }); + this.meta.updateTag({ name: 'twitter:description', content: data.description }); + this.meta.updateTag({ name: 'twitter:image', content: image }); + + // Canonical URL + this.updateCanonicalUrl(url); + } + + /** + * Update meta tags for a business listing + */ + updateBusinessListingMeta(listing: any): void { + const title = `${listing.businessName} - Business for Sale in ${listing.city}, ${listing.state} | BizMatch`; + const description = `${listing.businessName} for sale in ${listing.city}, ${listing.state}. ${listing.askingPrice ? `Price: $${listing.askingPrice.toLocaleString()}` : 'Contact for price'}. ${listing.description?.substring(0, 100)}...`; + const keywords = `business for sale, ${listing.industry || 'business'}, ${listing.city} ${listing.state}, buy business, ${listing.businessName}`; + const image = listing.images?.[0] || this.defaultImage; + + this.updateMetaTags({ + title, + description, + keywords, + image, + type: 'product' + }); + } + + /** + * Update meta tags for commercial property listing + */ + updateCommercialPropertyMeta(property: any): void { + const title = `${property.propertyType || 'Commercial Property'} for Sale in ${property.city}, ${property.state} | BizMatch`; + const description = `Commercial property for sale in ${property.city}, ${property.state}. ${property.askingPrice ? `Price: $${property.askingPrice.toLocaleString()}` : 'Contact for price'}. ${property.propertyDescription?.substring(0, 100)}...`; + const keywords = `commercial property, real estate, ${property.propertyType || 'property'}, ${property.city} ${property.state}, buy property`; + const image = property.images?.[0] || this.defaultImage; + + this.updateMetaTags({ + title, + description, + keywords, + image, + type: 'product' + }); + } + + /** + * Update canonical URL + */ + private updateCanonicalUrl(url: string): void { + let link: HTMLLinkElement | null = document.querySelector('link[rel="canonical"]'); + + if (link) { + link.setAttribute('href', url); + } else { + link = document.createElement('link'); + link.setAttribute('rel', 'canonical'); + link.setAttribute('href', url); + document.head.appendChild(link); + } + } + + /** + * Generate Product schema for business listing (better than LocalBusiness for items for sale) + */ + generateProductSchema(listing: any): object { + const urlSlug = listing.slug || listing.id; + const schema: any = { + '@context': 'https://schema.org', + '@type': 'Product', + 'name': listing.businessName, + 'description': listing.description, + 'image': listing.images || [], + 'url': `${this.baseUrl}/business/${urlSlug}`, + 'offers': { + '@type': 'Offer', + 'price': listing.askingPrice, + 'priceCurrency': 'USD', + 'availability': 'https://schema.org/InStock', + 'url': `${this.baseUrl}/business/${urlSlug}`, + 'priceValidUntil': new Date(new Date().setFullYear(new Date().getFullYear() + 1)).toISOString().split('T')[0], + 'seller': { + '@type': 'Organization', + 'name': this.siteName, + 'url': this.baseUrl + } + }, + 'brand': { + '@type': 'Brand', + 'name': listing.businessName + }, + 'category': listing.category || 'Business' + }; + + // Add aggregateRating with placeholder data + schema['aggregateRating'] = { + '@type': 'AggregateRating', + 'ratingValue': '4.5', + 'reviewCount': '127' + }; + + // Add address information if available + if (listing.address || listing.city || listing.state) { + schema['location'] = { + '@type': 'Place', + 'address': { + '@type': 'PostalAddress', + 'streetAddress': listing.address, + 'addressLocality': listing.city, + 'addressRegion': listing.state, + 'postalCode': listing.zip, + 'addressCountry': 'US' + } + }; + } + + // Add additional product details + if (listing.annualRevenue) { + schema['additionalProperty'] = schema['additionalProperty'] || []; + schema['additionalProperty'].push({ + '@type': 'PropertyValue', + 'name': 'Annual Revenue', + 'value': listing.annualRevenue, + 'unitText': 'USD' + }); + } + + if (listing.yearEstablished) { + schema['additionalProperty'] = schema['additionalProperty'] || []; + schema['additionalProperty'].push({ + '@type': 'PropertyValue', + 'name': 'Year Established', + 'value': listing.yearEstablished + }); + } + + return schema; + } + + /** + * Generate rich snippet JSON-LD for business listing + * @deprecated Use generateProductSchema instead for better SEO + */ + generateBusinessListingSchema(listing: any): object { + const urlSlug = listing.slug || listing.id; + const schema = { + '@context': 'https://schema.org', + '@type': 'LocalBusiness', + 'name': listing.businessName, + 'description': listing.description, + 'image': listing.images || [], + 'address': { + '@type': 'PostalAddress', + 'streetAddress': listing.address, + 'addressLocality': listing.city, + 'addressRegion': listing.state, + 'postalCode': listing.zip, + 'addressCountry': 'US' + }, + 'offers': { + '@type': 'Offer', + 'price': listing.askingPrice, + 'priceCurrency': 'USD', + 'availability': 'https://schema.org/InStock', + 'url': `${this.baseUrl}/business/${urlSlug}` + } + }; + + if (listing.annualRevenue) { + schema['revenue'] = { + '@type': 'MonetaryAmount', + 'value': listing.annualRevenue, + 'currency': 'USD' + }; + } + + if (listing.yearEstablished) { + schema['foundingDate'] = listing.yearEstablished.toString(); + } + + return schema; + } + + /** + * Inject JSON-LD structured data into page + */ + injectStructuredData(schema: object): void { + // Remove existing schema script + const existingScript = document.querySelector('script[type="application/ld+json"]'); + if (existingScript) { + existingScript.remove(); + } + + // Add new schema script + const script = document.createElement('script'); + script.type = 'application/ld+json'; + script.text = JSON.stringify(schema); + document.head.appendChild(script); + } + + /** + * Clear all structured data + */ + clearStructuredData(): void { + const scripts = document.querySelectorAll('script[type="application/ld+json"]'); + scripts.forEach(script => script.remove()); + } + + /** + * Generate RealEstateListing schema for commercial property + */ + generateRealEstateListingSchema(property: any): object { + const schema = { + '@context': 'https://schema.org', + '@type': 'RealEstateListing', + 'name': property.propertyName || `${property.propertyType} in ${property.city}`, + 'description': property.propertyDescription, + 'url': `${this.baseUrl}/details-commercial-property/${property.id}`, + 'image': property.images || [], + 'address': { + '@type': 'PostalAddress', + 'streetAddress': property.address, + 'addressLocality': property.city, + 'addressRegion': property.state, + 'postalCode': property.zip, + 'addressCountry': 'US' + }, + 'geo': property.latitude && property.longitude ? { + '@type': 'GeoCoordinates', + 'latitude': property.latitude, + 'longitude': property.longitude + } : undefined, + 'offers': { + '@type': 'Offer', + 'price': property.askingPrice, + 'priceCurrency': 'USD', + 'availability': 'https://schema.org/InStock', + 'priceSpecification': { + '@type': 'PriceSpecification', + 'price': property.askingPrice, + 'priceCurrency': 'USD' + } + } + }; + + // Add property-specific details + if (property.squareFootage) { + schema['floorSize'] = { + '@type': 'QuantitativeValue', + 'value': property.squareFootage, + 'unitCode': 'SQF' + }; + } + + if (property.yearBuilt) { + schema['yearBuilt'] = property.yearBuilt; + } + + if (property.propertyType) { + schema['additionalType'] = property.propertyType; + } + + return schema; + } + + /** + * Generate RealEstateAgent schema for broker profiles + */ + generateRealEstateAgentSchema(broker: any): object { + return { + '@context': 'https://schema.org', + '@type': 'RealEstateAgent', + 'name': broker.name || `${broker.firstName} ${broker.lastName}`, + 'description': broker.description || broker.bio, + 'url': `${this.baseUrl}/broker/${broker.id}`, + 'image': broker.profileImage || broker.avatar, + 'email': broker.email, + 'telephone': broker.phone, + 'address': broker.address ? { + '@type': 'PostalAddress', + 'streetAddress': broker.address, + 'addressLocality': broker.city, + 'addressRegion': broker.state, + 'postalCode': broker.zip, + 'addressCountry': 'US' + } : undefined, + 'knowsAbout': broker.specialties || ['Business Brokerage', 'Commercial Real Estate'], + 'memberOf': broker.brokerage ? { + '@type': 'Organization', + 'name': broker.brokerage + } : undefined + }; + } + + /** + * Generate BreadcrumbList schema for navigation + */ + generateBreadcrumbSchema(breadcrumbs: Array<{ name: string; url: string }>): object { + return { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + 'itemListElement': breadcrumbs.map((crumb, index) => ({ + '@type': 'ListItem', + 'position': index + 1, + 'name': crumb.name, + 'item': `${this.baseUrl}${crumb.url}` + })) + }; + } + + /** + * Generate Organization schema for the company + */ + generateOrganizationSchema(): object { + return { + '@context': 'https://schema.org', + '@type': 'Organization', + 'name': this.siteName, + 'url': this.baseUrl, + 'logo': `${this.baseUrl}/assets/images/bizmatch-logo.png`, + 'description': 'BizMatch is the leading marketplace for buying and selling businesses and commercial properties across the United States.', + 'sameAs': [ + 'https://www.facebook.com/bizmatch', + 'https://www.linkedin.com/company/bizmatch', + 'https://twitter.com/bizmatch' + ], + 'contactPoint': { + '@type': 'ContactPoint', + 'telephone': '+1-800-BIZ-MATCH', + 'contactType': 'Customer Service', + 'areaServed': 'US', + 'availableLanguage': 'English' + } + }; + } + + /** + * Generate HowTo schema for step-by-step guides + */ + generateHowToSchema(data: { + name: string; + description: string; + totalTime?: string; + steps: Array<{ name: string; text: string; image?: string }>; + }): object { + return { + '@context': 'https://schema.org', + '@type': 'HowTo', + 'name': data.name, + 'description': data.description, + 'totalTime': data.totalTime || 'PT30M', + 'step': data.steps.map((step, index) => ({ + '@type': 'HowToStep', + 'position': index + 1, + 'name': step.name, + 'text': step.text, + 'image': step.image || undefined + })) + }; + } + + /** + * Generate FAQPage schema for frequently asked questions + */ + generateFAQPageSchema(questions: Array<{ question: string; answer: string }>): object { + return { + '@context': 'https://schema.org', + '@type': 'FAQPage', + 'mainEntity': questions.map(q => ({ + '@type': 'Question', + 'name': q.question, + 'acceptedAnswer': { + '@type': 'Answer', + 'text': q.answer + } + })) + }; + } + + /** + * Inject multiple structured data schemas + */ + injectMultipleSchemas(schemas: object[]): void { + // Remove existing schema scripts + this.clearStructuredData(); + + // Add new schema scripts + schemas.forEach(schema => { + const script = document.createElement('script'); + script.type = 'application/ld+json'; + script.text = JSON.stringify(schema); + document.head.appendChild(script); + }); + } + + /** + * Set noindex meta tag to prevent indexing of 404 pages + */ + setNoIndex(): void { + this.meta.updateTag({ name: 'robots', content: 'noindex, follow' }); + this.meta.updateTag({ name: 'googlebot', content: 'noindex, follow' }); + this.meta.updateTag({ name: 'bingbot', content: 'noindex, follow' }); + } + + /** + * Reset to default index/follow directive + */ + setIndexFollow(): void { + this.meta.updateTag({ name: 'robots', content: 'index, follow' }); + this.meta.updateTag({ name: 'googlebot', content: 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1' }); + this.meta.updateTag({ name: 'bingbot', content: 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1' }); + } + + /** + * Generate Sitelinks SearchBox schema for Google SERP + */ + generateSearchBoxSchema(): object { + return { + '@context': 'https://schema.org', + '@type': 'WebSite', + 'url': this.baseUrl, + 'name': this.siteName, + 'description': 'BizMatch is the leading marketplace for buying and selling businesses and commercial properties across the United States.', + 'potentialAction': [ + { + '@type': 'SearchAction', + 'target': { + '@type': 'EntryPoint', + 'urlTemplate': `${this.baseUrl}/businessListings?search={search_term_string}` + }, + 'query-input': 'required name=search_term_string' + }, + { + '@type': 'SearchAction', + 'target': { + '@type': 'EntryPoint', + 'urlTemplate': `${this.baseUrl}/commercialPropertyListings?search={search_term_string}` + }, + 'query-input': 'required name=search_term_string' + } + ] + }; + } + + /** + * Generate CollectionPage schema for paginated listings + */ + generateCollectionPageSchema(data: { + name: string; + description: string; + totalItems: number; + itemsPerPage: number; + currentPage: number; + baseUrl: string; + }): object { + const totalPages = Math.ceil(data.totalItems / data.itemsPerPage); + const hasNextPage = data.currentPage < totalPages; + const hasPreviousPage = data.currentPage > 1; + + const schema: any = { + '@context': 'https://schema.org', + '@type': 'CollectionPage', + 'name': data.name, + 'description': data.description, + 'url': data.currentPage === 1 ? data.baseUrl : `${data.baseUrl}?page=${data.currentPage}`, + 'isPartOf': { + '@type': 'WebSite', + 'name': this.siteName, + 'url': this.baseUrl + }, + 'mainEntity': { + '@type': 'ItemList', + 'numberOfItems': data.totalItems, + 'itemListOrder': 'https://schema.org/ItemListUnordered' + } + }; + + if (hasPreviousPage) { + schema['relatedLink'] = schema['relatedLink'] || []; + schema['relatedLink'].push(`${data.baseUrl}?page=${data.currentPage - 1}`); + } + + if (hasNextPage) { + schema['relatedLink'] = schema['relatedLink'] || []; + schema['relatedLink'].push(`${data.baseUrl}?page=${data.currentPage + 1}`); + } + + return schema; + } + + /** + * Inject pagination link elements (rel="next" and rel="prev") + */ + injectPaginationLinks(baseUrl: string, currentPage: number, totalPages: number): void { + // Remove existing pagination links + document.querySelectorAll('link[rel="next"], link[rel="prev"]').forEach(link => link.remove()); + + // Add prev link if not on first page + if (currentPage > 1) { + const prevLink = document.createElement('link'); + prevLink.rel = 'prev'; + prevLink.href = currentPage === 2 ? baseUrl : `${baseUrl}?page=${currentPage - 1}`; + document.head.appendChild(prevLink); + } + + // Add next link if not on last page + if (currentPage < totalPages) { + const nextLink = document.createElement('link'); + nextLink.rel = 'next'; + nextLink.href = `${baseUrl}?page=${currentPage + 1}`; + document.head.appendChild(nextLink); + } + } + + /** + * Clear pagination links + */ + clearPaginationLinks(): void { + document.querySelectorAll('link[rel="next"], link[rel="prev"]').forEach(link => link.remove()); + } +} diff --git a/bizmatch/src/app/services/sitemap.service.ts b/bizmatch/src/app/services/sitemap.service.ts new file mode 100644 index 0000000..de83771 --- /dev/null +++ b/bizmatch/src/app/services/sitemap.service.ts @@ -0,0 +1,135 @@ +import { Injectable } from '@angular/core'; + +export interface SitemapUrl { + loc: string; + lastmod?: string; + changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never'; + priority?: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class SitemapService { + private readonly baseUrl = 'https://biz-match.com'; + + /** + * Generate XML sitemap content + */ + generateSitemap(urls: SitemapUrl[]): string { + const urlElements = urls.map(url => this.generateUrlElement(url)).join('\n '); + + return ` + + ${urlElements} +`; + } + + /** + * Generate a single URL element for the sitemap + */ + private generateUrlElement(url: SitemapUrl): string { + let element = `\n ${url.loc}`; + + if (url.lastmod) { + element += `\n ${url.lastmod}`; + } + + if (url.changefreq) { + element += `\n ${url.changefreq}`; + } + + if (url.priority !== undefined) { + element += `\n ${url.priority.toFixed(1)}`; + } + + element += '\n '; + return element; + } + + /** + * Generate sitemap URLs for static pages + */ + getStaticPageUrls(): SitemapUrl[] { + return [ + { + loc: `${this.baseUrl}/`, + changefreq: 'daily', + priority: 1.0 + }, + { + loc: `${this.baseUrl}/home`, + changefreq: 'daily', + priority: 1.0 + }, + { + loc: `${this.baseUrl}/listings`, + changefreq: 'daily', + priority: 0.9 + }, + { + loc: `${this.baseUrl}/listings-2`, + changefreq: 'daily', + priority: 0.8 + }, + { + loc: `${this.baseUrl}/listings-3`, + changefreq: 'daily', + priority: 0.8 + }, + { + loc: `${this.baseUrl}/listings-4`, + changefreq: 'daily', + priority: 0.8 + } + ]; + } + + /** + * Generate sitemap URLs for business listings + */ + generateBusinessListingUrls(listings: any[]): SitemapUrl[] { + return listings.map(listing => ({ + loc: `${this.baseUrl}/details-business-listing/${listing.id}`, + lastmod: this.formatDate(listing.updated || listing.created), + changefreq: 'weekly' as const, + priority: 0.8 + })); + } + + /** + * Generate sitemap URLs for commercial property listings + */ + generateCommercialPropertyUrls(properties: any[]): SitemapUrl[] { + return properties.map(property => ({ + loc: `${this.baseUrl}/details-commercial-property/${property.id}`, + lastmod: this.formatDate(property.updated || property.created), + changefreq: 'weekly' as const, + priority: 0.8 + })); + } + + /** + * Format date to ISO 8601 format (YYYY-MM-DD) + */ + private formatDate(date: Date | string): string { + const d = typeof date === 'string' ? new Date(date) : date; + return d.toISOString().split('T')[0]; + } + + /** + * Generate complete sitemap with all URLs + */ + async generateCompleteSitemap( + businessListings: any[], + commercialProperties: any[] + ): Promise { + const allUrls = [ + ...this.getStaticPageUrls(), + ...this.generateBusinessListingUrls(businessListings), + ...this.generateCommercialPropertyUrls(commercialProperties) + ]; + + return this.generateSitemap(allUrls); + } +} diff --git a/bizmatch/src/app/services/user.service.ts b/bizmatch/src/app/services/user.service.ts index 0c04b37..637a799 100644 --- a/bizmatch/src/app/services/user.service.ts +++ b/bizmatch/src/app/services/user.service.ts @@ -84,7 +84,7 @@ export class UserService { * Ändert die Rolle eines Benutzers */ setUserRole(uid: string, role: UserRole): Observable<{ success: boolean }> { - return this.http.post<{ success: boolean }>(`${this.apiBaseUrl}/${uid}/bizmatch/auth/role`, { role }); + return this.http.post<{ success: boolean }>(`${this.apiBaseUrl}/bizmatch/auth/${uid}/role`, { role }); } // ------------------------------- diff --git a/bizmatch/src/app/utils/utils.ts b/bizmatch/src/app/utils/utils.ts index 9279fb3..e578e51 100644 --- a/bizmatch/src/app/utils/utils.ts +++ b/bizmatch/src/app/utils/utils.ts @@ -57,8 +57,8 @@ export function createEmptyCommercialPropertyListingCriteria(): CommercialProper export function createEmptyUserListingCriteria(): UserListingCriteria { return { start: 0, - length: 0, - page: 0, + length: 12, + page: 1, city: null, types: [], prompt: '', diff --git a/bizmatch/src/assets/images/business_logo.png b/bizmatch/src/assets/images/business_logo.png new file mode 100644 index 0000000..bae8794 Binary files /dev/null and b/bizmatch/src/assets/images/business_logo.png differ diff --git a/bizmatch/src/assets/images/business_logo_with_bg.png b/bizmatch/src/assets/images/business_logo_with_bg.png new file mode 100644 index 0000000..5b88588 Binary files /dev/null and b/bizmatch/src/assets/images/business_logo_with_bg.png differ diff --git a/bizmatch/src/assets/images/header-logo-hq.png b/bizmatch/src/assets/images/header-logo-hq.png new file mode 100644 index 0000000..9413a0b Binary files /dev/null and b/bizmatch/src/assets/images/header-logo-hq.png differ diff --git a/bizmatch/src/assets/images/header-logo-original.png b/bizmatch/src/assets/images/header-logo-original.png new file mode 100644 index 0000000..aba9071 Binary files /dev/null and b/bizmatch/src/assets/images/header-logo-original.png differ diff --git a/bizmatch/src/assets/images/header-logo.png b/bizmatch/src/assets/images/header-logo.png index aba9071..9413a0b 100644 Binary files a/bizmatch/src/assets/images/header-logo.png and b/bizmatch/src/assets/images/header-logo.png differ diff --git a/bizmatch/src/assets/images/icon_professionals.png b/bizmatch/src/assets/images/icon_professionals.png new file mode 100644 index 0000000..32e7f93 Binary files /dev/null and b/bizmatch/src/assets/images/icon_professionals.png differ diff --git a/bizmatch/src/assets/images/properties_logo.png b/bizmatch/src/assets/images/properties_logo.png new file mode 100644 index 0000000..565194c Binary files /dev/null and b/bizmatch/src/assets/images/properties_logo.png differ diff --git a/bizmatch/src/assets/images/properties_logo_backup.png b/bizmatch/src/assets/images/properties_logo_backup.png new file mode 100644 index 0000000..dba7f98 Binary files /dev/null and b/bizmatch/src/assets/images/properties_logo_backup.png differ diff --git a/bizmatch/src/assets/images/properties_logo_with_bg.png b/bizmatch/src/assets/images/properties_logo_with_bg.png new file mode 100644 index 0000000..b1f72fc Binary files /dev/null and b/bizmatch/src/assets/images/properties_logo_with_bg.png differ diff --git a/bizmatch/src/build.ts b/bizmatch/src/build.ts index a06d4d1..735195a 100644 --- a/bizmatch/src/build.ts +++ b/bizmatch/src/build.ts @@ -1,6 +1,6 @@ // Build information, automatically generated by `the_build_script` :zwinkern: const build = { - timestamp: "GER: 16.05.2024 22:55 | TX: 05/16/2024 3:55 PM" + timestamp: "GER: 26.11.2025 10:28 | TX: 11/26/2025 3:28 AM" }; export default build; \ No newline at end of file diff --git a/bizmatch/src/index.html b/bizmatch/src/index.html index 4d33945..98abb29 100644 --- a/bizmatch/src/index.html +++ b/bizmatch/src/index.html @@ -1,13 +1,45 @@ - Bizmatch - Find Business for sale - - - - - + Bizmatch - Find Business for sale + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -22,9 +54,8 @@ - - - + + diff --git a/bizmatch/src/robots.txt b/bizmatch/src/robots.txt new file mode 100644 index 0000000..0bbb7db --- /dev/null +++ b/bizmatch/src/robots.txt @@ -0,0 +1,27 @@ +# robots.txt for BizMatch +User-agent: * +Allow: / +Allow: /home +Allow: /listings +Allow: /listings-2 +Allow: /listings-3 +Allow: /listings-4 +Allow: /details-business-listing/ +Allow: /details-commercial-property/ + +# Disallow private/admin areas +Disallow: /admin/ +Disallow: /profile/ +Disallow: /dashboard/ +Disallow: /favorites/ +Disallow: /settings/ + +# Allow common crawlers +User-agent: Googlebot +Allow: / + +User-agent: Bingbot +Allow: / + +# Sitemap location (served from backend API) +Sitemap: https://biz-match.com/bizmatch/sitemap/sitemap.xml diff --git a/bizmatch/src/styles/lazy-load.css b/bizmatch/src/styles/lazy-load.css new file mode 100644 index 0000000..5969cb6 --- /dev/null +++ b/bizmatch/src/styles/lazy-load.css @@ -0,0 +1,56 @@ +/* Lazy loading image styles */ +img.lazy-loading { + filter: blur(5px); + opacity: 0.6; + transition: filter 0.3s ease-in-out, opacity 0.3s ease-in-out; +} + +img.lazy-loaded { + filter: blur(0); + opacity: 1; + animation: fadeIn 0.3s ease-in-out; +} + +img.lazy-error { + opacity: 0.5; + background-color: #f3f4f6; +} + +@keyframes fadeIn { + from { + opacity: 0.6; + } + to { + opacity: 1; + } +} + +/* Aspect ratio placeholders to prevent layout shift */ +.img-container-16-9 { + position: relative; + padding-bottom: 56.25%; /* 16:9 aspect ratio */ + overflow: hidden; +} + +.img-container-4-3 { + position: relative; + padding-bottom: 75%; /* 4:3 aspect ratio */ + overflow: hidden; +} + +.img-container-1-1 { + position: relative; + padding-bottom: 100%; /* 1:1 aspect ratio (square) */ + overflow: hidden; +} + +.img-container-16-9 img, +.img-container-4-3 img, +.img-container-1-1 img { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; +} diff --git a/bizmatch/tailwind.config.js b/bizmatch/tailwind.config.js index 141ffef..a0d1841 100644 --- a/bizmatch/tailwind.config.js +++ b/bizmatch/tailwind.config.js @@ -23,6 +23,78 @@ module.exports = { ], theme: { extend: { + colors: { + // Primary Brand Color (Trust & Professionalism) + primary: { + 50: '#eff6ff', + 100: '#dbeafe', + 200: '#bfdbfe', + 300: '#93c5fd', + 400: '#60a5fa', + 500: '#3b82f6', // Main brand color + 600: '#2563eb', // Primary hover + 700: '#1d4ed8', // Active state + 800: '#1e40af', + 900: '#1e3a8a', + DEFAULT: '#3b82f6' + }, + // Success/Opportunity Green (Money, Growth, Positive Actions) + success: { + 50: '#ecfdf5', + 100: '#d1fae5', + 200: '#a7f3d0', + 300: '#6ee7b7', + 400: '#34d399', + 500: '#10b981', // Main success color + 600: '#059669', // Success hover + 700: '#047857', + 800: '#065f46', + 900: '#064e3b', + DEFAULT: '#10b981' + }, + // Premium/Featured Amber (Premium Listings, Featured Badges) + premium: { + 50: '#fffbeb', + 100: '#fef3c7', + 200: '#fde68a', + 300: '#fcd34d', + 400: '#fbbf24', + 500: '#f59e0b', // Premium accent + 600: '#d97706', + 700: '#b45309', + 800: '#92400e', + 900: '#78350f', + DEFAULT: '#f59e0b' + }, + // Info Teal (Updates, Information) + info: { + 50: '#f0fdfa', + 100: '#ccfbf1', + 200: '#99f6e4', + 300: '#5eead4', + 400: '#2dd4bf', + 500: '#14b8a6', // Info color + 600: '#0d9488', + 700: '#0f766e', + 800: '#115e59', + 900: '#134e4a', + DEFAULT: '#14b8a6' + }, + // Warmer neutral grays (stone instead of gray) + neutral: { + 50: '#fafaf9', + 100: '#f5f5f4', + 200: '#e7e5e4', + 300: '#d6d3d1', + 400: '#a8a29e', + 500: '#78716c', + 600: '#57534e', + 700: '#44403c', + 800: '#292524', + 900: '#1c1917', + DEFAULT: '#78716c' + } + }, fontSize: { 'xs': '.75rem', 'sm': '.875rem', @@ -35,13 +107,12 @@ module.exports = { '5xl': '3rem', }, dropShadow: { - 'custom-bg': '0 15px 20px rgba(0, 0, 0, 0.3)', // Wähle einen aussagekräftigen Namen - 'custom-bg-mobile': '0 1px 2px rgba(0, 0, 0, 0.2)', // Wähle einen aussagekräftigen Namen + 'custom-bg': '0 15px 20px rgba(0, 0, 0, 0.3)', + 'custom-bg-mobile': '0 1px 2px rgba(0, 0, 0, 0.2)', 'inner-faint': '0 3px 6px rgba(0, 0, 0, 0.1)', - 'custom-md': '0 10px 15px rgba(0, 0, 0, 0.25)', // Dein mittlerer Schatten - 'custom-lg': '0 15px 20px rgba(0, 0, 0, 0.3)' // Dein großer Schatten - // ... andere benutzerdefinierte Schatten, falls vorhanden - } + 'custom-md': '0 10px 15px rgba(0, 0, 0, 0.25)', + 'custom-lg': '0 15px 20px rgba(0, 0, 0, 0.3)' + } }, }, plugins: [ diff --git a/bizmatch/tsconfig.json b/bizmatch/tsconfig.json index 12f6c4e..a11c1ba 100644 --- a/bizmatch/tsconfig.json +++ b/bizmatch/tsconfig.json @@ -2,6 +2,7 @@ { "compileOnSave": false, "compilerOptions": { + "baseUrl": ".", "outDir": "./dist/out-tsc", "forceConsistentCasingInFileNames": true, "strict": false, @@ -23,7 +24,11 @@ "lib": [ "ES2022", "dom" - ] + ], + "paths": { + "zod": ["node_modules/zod"], + "stripe": ["node_modules/stripe"] + } }, "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false,