From b52e47b65393754925332061bd994173226d767b Mon Sep 17 00:00:00 2001 From: Timo Date: Sat, 3 Jan 2026 12:53:37 +0100 Subject: [PATCH] feat: Initialize Angular SSR application with core pages, components, and server setup. --- .claude/settings.local.json | 12 ++ bizmatch-server/prod.dump | Bin .../src/listings/business-listing.service.ts | 44 +++- .../src/sitemap/sitemap.controller.ts | 13 +- .../src/sitemap/sitemap.service.ts | 82 +++++++- bizmatch/angular.json | 9 +- bizmatch/package.json | 8 +- bizmatch/server.ts | 5 +- .../components/footer/footer.component.html | 2 +- .../components/header/header.component.html | 190 ++++++++---------- .../login-register.component.html | 2 +- .../pages/details/base-details.component.ts | 58 ++++-- .../details-business-listing.component.html | 95 ++++++--- .../details-business-listing.component.ts | 187 +++++++++-------- ...commercial-property-listing.component.html | 98 ++++++--- ...s-commercial-property-listing.component.ts | 26 ++- bizmatch/src/app/pages/details/details.scss | 10 + .../src/app/pages/home/home.component.html | 63 +----- bizmatch/src/app/pages/home/home.component.ts | 47 ----- .../broker-listings.component.html | 2 +- .../business-listings.component.html | 114 ++++++----- .../business-listings.component.ts | 43 +++- bizmatch/src/build.ts | 2 +- bizmatch/src/environments/environment.base.ts | 3 +- bizmatch/src/main.server.ts | 3 + bizmatch/src/robots.txt | 141 +++++++++++-- bizmatch/src/ssr-dom-polyfill.ts | 163 +++++++++++++++ bizmatch/ssr-dom-preload.mjs | 154 ++++++++++++++ 28 files changed, 1115 insertions(+), 461 deletions(-) create mode 100644 .claude/settings.local.json mode change 100755 => 100644 bizmatch-server/prod.dump create mode 100644 bizmatch/src/ssr-dom-polyfill.ts create mode 100644 bizmatch/ssr-dom-preload.mjs diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..0314919 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(npm install)", + "Bash(docker ps:*)", + "Bash(docker cp:*)", + "Bash(docker exec:*)", + "Bash(find:*)", + "Bash(docker restart:*)" + ] + } +} diff --git a/bizmatch-server/prod.dump b/bizmatch-server/prod.dump old mode 100755 new mode 100644 diff --git a/bizmatch-server/src/listings/business-listing.service.ts b/bizmatch-server/src/listings/business-listing.service.ts index ea3188c..ac7be83 100644 --- a/bizmatch-server/src/listings/business-listing.service.ts +++ b/bizmatch-server/src/listings/business-listing.service.ts @@ -31,20 +31,35 @@ export class BusinessListingService { const cityGeo = this.geoService.getCityWithCoords(criteria.state, criteria.city.name); whereConditions.push(sql`${getDistanceQuery(businesses_json, cityGeo.latitude, cityGeo.longitude)} <= ${criteria.radius}`); } - if (criteria.types && criteria.types.length > 0) { - whereConditions.push(inArray(sql`${businesses_json.data}->>'type'`, criteria.types)); + if (criteria.types && Array.isArray(criteria.types) && criteria.types.length > 0) { + const validTypes = criteria.types.filter(t => t !== null && t !== undefined && t !== ''); + if (validTypes.length > 0) { + whereConditions.push(inArray(sql`${businesses_json.data}->>'type'`, validTypes)); + } } if (criteria.state) { whereConditions.push(sql`(${businesses_json.data}->'location'->>'state') = ${criteria.state}`); } - if (criteria.minPrice) { - whereConditions.push(gte(sql`(${businesses_json.data}->>'price')::double precision`, criteria.minPrice)); + if (criteria.minPrice !== undefined && criteria.minPrice !== null) { + whereConditions.push( + and( + sql`(${businesses_json.data}->>'price') IS NOT NULL`, + sql`(${businesses_json.data}->>'price') != ''`, + gte(sql`REPLACE(${businesses_json.data}->>'price', ',', '')::double precision`, criteria.minPrice) + ) + ); } - if (criteria.maxPrice) { - whereConditions.push(lte(sql`(${businesses_json.data}->>'price')::double precision`, criteria.maxPrice)); + if (criteria.maxPrice !== undefined && criteria.maxPrice !== null) { + whereConditions.push( + and( + sql`(${businesses_json.data}->>'price') IS NOT NULL`, + sql`(${businesses_json.data}->>'price') != ''`, + lte(sql`REPLACE(${businesses_json.data}->>'price', ',', '')::double precision`, criteria.maxPrice) + ) + ); } if (criteria.minRevenue) { @@ -87,8 +102,14 @@ export class BusinessListingService { whereConditions.push(eq(sql`(${businesses_json.data}->>'franchiseResale')::boolean`, criteria.franchiseResale)); } - if (criteria.title) { - whereConditions.push(sql`(${businesses_json.data}->>'title') ILIKE ${`%${criteria.title}%`} OR (${businesses_json.data}->>'description') ILIKE ${`%${criteria.title}%`}`); + if (criteria.title && criteria.title.trim() !== '') { + const searchTerm = `%${criteria.title.trim()}%`; + whereConditions.push( + or( + sql`(${businesses_json.data}->>'title') ILIKE ${searchTerm}`, + sql`(${businesses_json.data}->>'description') ILIKE ${searchTerm}` + ) + ); } if (criteria.brokerName) { const { firstname, lastname } = splitName(criteria.brokerName); @@ -122,9 +143,16 @@ export class BusinessListingService { const whereConditions = this.getWhereConditions(criteria, user); + // Uncomment for debugging filter issues: + // this.logger.info('Filter Criteria:', { criteria }); + // this.logger.info('Where Conditions Count:', { count: whereConditions.length }); + if (whereConditions.length > 0) { const whereClause = and(...whereConditions); query.where(whereClause); + + // Uncomment for debugging SQL queries: + // this.logger.info('Generated SQL:', { sql: query.toSQL() }); } // Sortierung diff --git a/bizmatch-server/src/sitemap/sitemap.controller.ts b/bizmatch-server/src/sitemap/sitemap.controller.ts index e9aa9a0..076c4f1 100644 --- a/bizmatch-server/src/sitemap/sitemap.controller.ts +++ b/bizmatch-server/src/sitemap/sitemap.controller.ts @@ -3,7 +3,7 @@ import { SitemapService } from './sitemap.service'; @Controller() export class SitemapController { - constructor(private readonly sitemapService: SitemapService) {} + constructor(private readonly sitemapService: SitemapService) { } /** * Main sitemap index - lists all sitemap files @@ -48,4 +48,15 @@ export class SitemapController { async getCommercialSitemap(@Param('page', ParseIntPipe) page: number): Promise { return await this.sitemapService.generateCommercialSitemap(page); } + + /** + * Broker profiles sitemap (paginated) + * Route: /sitemap/brokers-1.xml, /sitemap/brokers-2.xml, etc. + */ + @Get('sitemap/brokers-:page.xml') + @Header('Content-Type', 'application/xml') + @Header('Cache-Control', 'public, max-age=3600') + async getBrokerSitemap(@Param('page', ParseIntPipe) page: number): Promise { + return await this.sitemapService.generateBrokerSitemap(page); + } } diff --git a/bizmatch-server/src/sitemap/sitemap.service.ts b/bizmatch-server/src/sitemap/sitemap.service.ts index f4946b4..fdad97c 100644 --- a/bizmatch-server/src/sitemap/sitemap.service.ts +++ b/bizmatch-server/src/sitemap/sitemap.service.ts @@ -21,7 +21,7 @@ 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) {} + constructor(@Inject(PG_CONNECTION) private readonly db: NodePgDatabase) { } /** * Generate sitemap index (main sitemap.xml) @@ -32,26 +32,36 @@ export class SitemapService { // Add static pages sitemap sitemaps.push({ - loc: `${this.baseUrl}/sitemap/static.xml`, + loc: `${this.baseUrl}/bizmatch/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); + const businessPages = Math.ceil(businessCount / this.URLS_PER_SITEMAP) || 1; for (let page = 1; page <= businessPages; page++) { sitemaps.push({ - loc: `${this.baseUrl}/sitemap/business-${page}.xml`, + loc: `${this.baseUrl}/bizmatch/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); + const commercialPages = Math.ceil(commercialCount / this.URLS_PER_SITEMAP) || 1; for (let page = 1; page <= commercialPages; page++) { sitemaps.push({ - loc: `${this.baseUrl}/sitemap/commercial-${page}.xml`, + loc: `${this.baseUrl}/bizmatch/sitemap/commercial-${page}.xml`, + lastmod: this.formatDate(new Date()), + }); + } + + // Count broker profiles + const brokerCount = await this.getBrokerProfilesCount(); + const brokerPages = Math.ceil(brokerCount / this.URLS_PER_SITEMAP) || 1; + for (let page = 1; page <= brokerPages; page++) { + sitemaps.push({ + loc: `${this.baseUrl}/bizmatch/sitemap/brokers-${page}.xml`, lastmod: this.formatDate(new Date()), }); } @@ -289,4 +299,64 @@ ${sitemapElements} const d = typeof date === 'string' ? new Date(date) : date; return d.toISOString().split('T')[0]; } + + /** + * Generate broker profiles sitemap (paginated) + */ + async generateBrokerSitemap(page: number): Promise { + const offset = (page - 1) * this.URLS_PER_SITEMAP; + const urls = await this.getBrokerProfileUrls(offset, this.URLS_PER_SITEMAP); + return this.buildXmlSitemap(urls); + } + + /** + * Count broker profiles (professionals with showInDirectory=true) + */ + private async getBrokerProfilesCount(): Promise { + try { + const result = await this.db + .select({ count: sql`count(*)` }) + .from(schema.users_json) + .where(sql` + (${schema.users_json.data}->>'customerType') = 'professional' + AND (${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE + `); + + return Number(result[0]?.count || 0); + } catch (error) { + console.error('Error counting broker profiles:', error); + return 0; + } + } + + /** + * Get broker profile URLs from database (paginated) + */ + private async getBrokerProfileUrls(offset: number, limit: number): Promise { + try { + const brokers = await this.db + .select({ + email: schema.users_json.email, + updated: sql`(${schema.users_json.data}->>'updated')::timestamptz`, + created: sql`(${schema.users_json.data}->>'created')::timestamptz`, + }) + .from(schema.users_json) + .where(sql` + (${schema.users_json.data}->>'customerType') = 'professional' + AND (${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE + `) + .limit(limit) + .offset(offset); + + return brokers.map(broker => ({ + loc: `${this.baseUrl}/details-user/${encodeURIComponent(broker.email)}`, + lastmod: this.formatDate(broker.updated || broker.created), + changefreq: 'weekly' as const, + priority: 0.7, + })); + } catch (error) { + console.error('Error fetching broker profiles for sitemap:', error); + return []; + } + } } diff --git a/bizmatch/angular.json b/bizmatch/angular.json index 044519b..d14ffd5 100644 --- a/bizmatch/angular.json +++ b/bizmatch/angular.json @@ -21,6 +21,11 @@ "outputPath": "dist/bizmatch", "index": "src/index.html", "browser": "src/main.ts", + "server": "src/main.server.ts", + "prerender": false, + "ssr": { + "entry": "server.ts" + }, "polyfills": [ "zone.js" ], @@ -33,6 +38,7 @@ }, "src/favicon.ico", "src/assets", + "src/robots.txt", { "glob": "**/*", "input": "node_modules/leaflet/dist/images", @@ -65,7 +71,8 @@ "development": { "optimization": false, "extractLicenses": false, - "sourceMap": true + "sourceMap": true, + "ssr": false }, "dev": { "fileReplacements": [ diff --git a/bizmatch/package.json b/bizmatch/package.json index 3832496..9beb830 100644 --- a/bizmatch/package.json +++ b/bizmatch/package.json @@ -8,9 +8,13 @@ "build": "node version.js && ng build", "build.dev": "node version.js && ng build --configuration dev --output-hashing=all", "build.prod": "node version.js && ng build --configuration prod --output-hashing=all", + "build:ssr": "node version.js && ng build --configuration prod", + "build:ssr:dev": "node version.js && ng build --configuration dev", "watch": "ng build --watch --configuration development", "test": "ng test", - "serve:ssr:bizmatch": "node dist/bizmatch/server/server.mjs" + "serve:ssr": "node dist/bizmatch/server/server.mjs", + "serve:ssr:bizmatch": "node dist/bizmatch/server/server.mjs", + "dev:ssr": "NODE_OPTIONS='--import ./ssr-dom-preload.mjs' ng serve" }, "private": true, "dependencies": { @@ -82,4 +86,4 @@ "tailwindcss": "^3.4.4", "typescript": "~5.4.5" } -} +} \ No newline at end of file diff --git a/bizmatch/server.ts b/bizmatch/server.ts index 7083b14..482a478 100644 --- a/bizmatch/server.ts +++ b/bizmatch/server.ts @@ -1,3 +1,6 @@ +// IMPORTANT: DOM polyfill must be imported FIRST, before any browser-dependent libraries +import './src/ssr-dom-polyfill'; + import { APP_BASE_HREF } from '@angular/common'; import { CommonEngine } from '@angular/ssr'; import express from 'express'; @@ -44,7 +47,7 @@ export function app(): express.Express { } function run(): void { - const port = process.env['PORT'] || 4000; + const port = process.env['PORT'] || 4200; // Start up the Node server const server = app(); diff --git a/bizmatch/src/app/components/footer/footer.component.html b/bizmatch/src/app/components/footer/footer.component.html index 59d6610..55ebbf2 100644 --- a/bizmatch/src/app/components/footer/footer.component.html +++ b/bizmatch/src/app/components/footer/footer.component.html @@ -3,7 +3,7 @@
- + BizMatch Logo

© {{ currentYear }} Bizmatch All rights reserved.

diff --git a/bizmatch/src/app/components/header/header.component.html b/bizmatch/src/app/components/header/header.component.html index 3045e65..412ad9b 100644 --- a/bizmatch/src/app/components/header/header.component.html +++ b/bizmatch/src/app/components/header/header.component.html @@ -1,227 +1,211 @@ + \ No newline at end of file diff --git a/bizmatch/src/app/components/login-register/login-register.component.html b/bizmatch/src/app/components/login-register/login-register.component.html index d4b6977..e13ee56 100644 --- a/bizmatch/src/app/components/login-register/login-register.component.html +++ b/bizmatch/src/app/components/login-register/login-register.component.html @@ -79,7 +79,7 @@ - {{ isLoginMode ? 'Sign in with Email' : 'Register' }} + {{ isLoginMode ? 'Sign in with Email' : 'Sign Up' }} diff --git a/bizmatch/src/app/pages/details/base-details.component.ts b/bizmatch/src/app/pages/details/base-details.component.ts index 2f6cbab..bc96533 100644 --- a/bizmatch/src/app/pages/details/base-details.component.ts +++ b/bizmatch/src/app/pages/details/base-details.component.ts @@ -1,6 +1,8 @@ -import { Component } from '@angular/core'; +import { Component, inject, PLATFORM_ID } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; import { Control, DomEvent, DomUtil, icon, Icon, latLng, Layer, Map, MapOptions, Marker, tileLayer } from 'leaflet'; import { BusinessListing, CommercialPropertyListing } from '../../../../../bizmatch-server/src/models/db.model'; + @Component({ selector: 'app-base-details', template: ``, @@ -12,28 +14,39 @@ export abstract class BaseDetailsComponent { mapOptions: MapOptions; mapLayers: Layer[] = []; mapCenter: any; - mapZoom: number = 13; // Standardzoomlevel + mapZoom: number = 13; protected listing: BusinessListing | CommercialPropertyListing; + protected isBrowser: boolean; + private platformId = inject(PLATFORM_ID); + constructor() { - this.mapOptions = { - layers: [ - tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - attribution: '© OpenStreetMap contributors', - }), - ], - zoom: this.mapZoom, - center: latLng(0, 0), // Platzhalter, wird später gesetzt - }; + this.isBrowser = isPlatformBrowser(this.platformId); + // Only initialize mapOptions in browser context + if (this.isBrowser) { + this.mapOptions = { + layers: [ + tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + }), + ], + zoom: this.mapZoom, + center: latLng(0, 0), + }; + } } + protected configureMap() { + if (!this.isBrowser) { + return; // Skip on server + } + const latitude = this.listing.location.latitude; const longitude = this.listing.location.longitude; if (latitude !== null && latitude !== undefined && - longitude !== null && longitude !== undefined) { + longitude !== null && longitude !== undefined) { this.mapCenter = latLng(latitude, longitude); - // Build address string from available location data const addressParts = []; if (this.listing.location.housenumber) addressParts.push(this.listing.location.housenumber); if (this.listing.location.street) addressParts.push(this.listing.location.street); @@ -53,7 +66,6 @@ export abstract class BaseDetailsComponent { }), }); - // Add popup to marker with address if (fullAddress) { marker.bindPopup(`
@@ -76,8 +88,12 @@ export abstract class BaseDetailsComponent { }; } } + onMapReady(map: Map) { - // Build comprehensive address for the control + if (!this.isBrowser) { + return; + } + const addressParts = []; if (this.listing.location.housenumber) addressParts.push(this.listing.location.housenumber); if (this.listing.location.street) addressParts.push(this.listing.location.street); @@ -99,10 +115,8 @@ export abstract class BaseDetailsComponent {
`; - // Verhindere, dass die Karte durch das Klicken des Links bewegt wird DomEvent.disableClickPropagation(container); - // Füge einen Event Listener für den Link hinzu const link = container.querySelector('#view-full-map') as HTMLElement; if (link) { DomEvent.on(link, 'click', (e: Event) => { @@ -117,12 +131,20 @@ export abstract class BaseDetailsComponent { addressControl.addTo(map); } } + openFullMap() { + if (!this.isBrowser) { + return; + } + const latitude = this.listing.location.latitude; const longitude = this.listing.location.longitude; - const address = `${this.listing.location.housenumber} ${this.listing.location.street}, ${this.listing.location.name ? this.listing.location.name : this.listing.location.county}, ${this.listing.location.state}`; const url = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=15/${latitude}/${longitude}`; window.open(url, '_blank'); } } + + + + diff --git a/bizmatch/src/app/pages/details/details-business-listing/details-business-listing.component.html b/bizmatch/src/app/pages/details/details-business-listing/details-business-listing.component.html index d049cd1..8abb3b8 100644 --- a/bizmatch/src/app/pages/details/details-business-listing/details-business-listing.component.html +++ b/bizmatch/src/app/pages/details/details-business-listing/details-business-listing.component.html @@ -5,10 +5,8 @@ }
- @if(listing){ @@ -19,30 +17,38 @@

-
@if(listing && listingUser && (listingUser?.email===user?.email || (authService.isAdmin() | async))){
-
} @if(user){
-
- - - +
+ +
+ +
+ +
+ +
+ +

Location Map

-
+
@@ -80,19 +111,24 @@
- +
- + - +
- +
- +
@@ -107,13 +143,17 @@ + \ No newline at end of file diff --git a/bizmatch/src/app/pages/details/details-business-listing/details-business-listing.component.ts b/bizmatch/src/app/pages/details/details-business-listing/details-business-listing.component.ts index 90bc47b..30b1ce0 100644 --- a/bizmatch/src/app/pages/details/details-business-listing/details-business-listing.component.ts +++ b/bizmatch/src/app/pages/details/details-business-listing/details-business-listing.component.ts @@ -2,7 +2,6 @@ import { ChangeDetectorRef, Component } from '@angular/core'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { LeafletModule } from '@bluehalo/ngx-leaflet'; -import { ShareButton } from 'ngx-sharebuttons/button'; import { lastValueFrom } from 'rxjs'; import { BusinessListing, EventTypeEnum, ShareByEMail, User } from '../../../../../../bizmatch-server/src/models/db.model'; import { KeycloakUser, MailInfo } from '../../../../../../bizmatch-server/src/models/main.model'; @@ -25,15 +24,16 @@ import { UserService } from '../../../services/user.service'; import { SharedModule } from '../../../shared/shared/shared.module'; import { createMailInfo, map2User } from '../../../utils/utils'; // Import für Leaflet -// Benannte Importe für Leaflet +// Note: Leaflet requires browser environment - protected by isBrowser checks in base class import { circle, Circle, Control, DomEvent, DomUtil, icon, Icon, latLng, LatLngBounds, Marker, polygon, Polygon, tileLayer } from 'leaflet'; import dayjs from 'dayjs'; import { AuthService } from '../../../services/auth.service'; import { BaseDetailsComponent } from '../base-details.component'; +import { ShareButton } from 'ngx-sharebuttons/button'; @Component({ selector: 'app-details-business-listing', standalone: true, - imports: [SharedModule, ValidatedInputComponent, ValidatedTextareaComponent, ShareButton, ValidatedNgSelectComponent, LeafletModule, BreadcrumbsComponent], + imports: [SharedModule, ValidatedInputComponent, ValidatedTextareaComponent, ValidatedNgSelectComponent, LeafletModule, BreadcrumbsComponent, ShareButton], providers: [], templateUrl: './details-business-listing.component.html', styleUrl: '../details.scss', @@ -231,28 +231,27 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent { { label: 'Category', value: this.selectOptions.getBusiness(this.listing.type) }, { label: 'Located in', - value: `${this.listing.location.name ? this.listing.location.name : this.listing.location.county ? this.listing.location.county : ''} ${ - this.listing.location.name || this.listing.location.county ? ', ' : '' - }${this.selectOptions.getState(this.listing.location.state)}`, + value: `${this.listing.location.name ? this.listing.location.name : this.listing.location.county ? this.listing.location.county : ''} ${this.listing.location.name || this.listing.location.county ? ', ' : '' + }${this.selectOptions.getState(this.listing.location.state)}`, }, { label: 'Asking Price', value: `${this.listing.price ? `$${this.listing.price.toLocaleString()}` : 'undisclosed '}` }, { label: 'Sales revenue', value: `${this.listing.salesRevenue ? `$${this.listing.salesRevenue.toLocaleString()}` : 'undisclosed '}` }, { label: 'Cash flow', value: `${this.listing.cashFlow ? `$${this.listing.cashFlow.toLocaleString()}` : 'undisclosed '}` }, ...(this.listing.ffe ? [ - { - label: 'Furniture, Fixtures / Equipment Value (FFE)', - value: `$${this.listing.ffe.toLocaleString()}`, - }, - ] + { + label: 'Furniture, Fixtures / Equipment Value (FFE)', + value: `$${this.listing.ffe.toLocaleString()}`, + }, + ] : []), ...(this.listing.inventory ? [ - { - label: 'Inventory at Cost Value', - value: `$${this.listing.inventory.toLocaleString()}`, - }, - ] + { + label: 'Inventory at Cost Value', + value: `$${this.listing.inventory.toLocaleString()}`, + }, + ] : []), { label: 'Type of Real Estate', value: typeOfRealEstate }, { label: 'Employees', value: this.listing.employees }, @@ -308,6 +307,26 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent { createEvent(eventType: EventTypeEnum) { this.auditService.createEvent(this.listing.id, eventType, this.user?.email); } + + shareToFacebook() { + const url = encodeURIComponent(window.location.href); + window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`, '_blank', 'width=600,height=400'); + this.createEvent('facebook'); + } + + shareToTwitter() { + const url = encodeURIComponent(window.location.href); + const text = encodeURIComponent(this.listing?.title || 'Check out this business listing'); + window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank', 'width=600,height=400'); + this.createEvent('x'); + } + + shareToLinkedIn() { + const url = encodeURIComponent(window.location.href); + window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${url}`, '_blank', 'width=600,height=400'); + this.createEvent('linkedin'); + } + getDaysListed() { return dayjs().diff(this.listing.created, 'day'); } @@ -330,7 +349,7 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent { // Check if we have valid coordinates (null-safe check) if (latitude !== null && latitude !== undefined && - longitude !== null && longitude !== undefined) { + longitude !== null && longitude !== undefined) { this.mapCenter = latLng(latitude, longitude); @@ -340,23 +359,23 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent { // Fetch city boundary from Nominatim API this.geoService.getCityBoundary(cityName, state).subscribe({ - next: (data) => { - if (data && data.length > 0 && data[0].geojson && data[0].geojson.type === 'Polygon') { - const coordinates = data[0].geojson.coordinates[0]; // Get outer boundary + next: (data) => { + if (data && data.length > 0 && data[0].geojson && data[0].geojson.type === 'Polygon') { + const coordinates = data[0].geojson.coordinates[0]; // Get outer boundary - // Convert GeoJSON coordinates [lon, lat] to Leaflet LatLng [lat, lon] - const latlngs = coordinates.map((coord: number[]) => latLng(coord[1], coord[0])); + // Convert GeoJSON coordinates [lon, lat] to Leaflet LatLng [lat, lon] + const latlngs = coordinates.map((coord: number[]) => latLng(coord[1], coord[0])); - // Create red outlined polygon for city boundary - const cityPolygon = polygon(latlngs, { - color: '#ef4444', // Red color (like Google Maps) - fillColor: '#ef4444', - fillOpacity: 0.1, - weight: 2 - }); + // Create red outlined polygon for city boundary + const cityPolygon = polygon(latlngs, { + color: '#ef4444', // Red color (like Google Maps) + fillColor: '#ef4444', + fillOpacity: 0.1, + weight: 2 + }); - // Add popup to polygon - cityPolygon.bindPopup(` + // Add popup to polygon + cityPolygon.bindPopup(`
General Area:
${cityName}, ${county ? county + ', ' : ''}${state}
@@ -364,74 +383,74 @@ export class DetailsBusinessListingComponent extends BaseDetailsComponent {
`); - this.mapLayers = [ - tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - attribution: '© OpenStreetMap contributors', - }), - cityPolygon - ]; + this.mapLayers = [ + tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + }), + cityPolygon + ]; - // Fit map to polygon bounds - const bounds = cityPolygon.getBounds(); - this.mapOptions = { - ...this.mapOptions, - center: bounds.getCenter(), - zoom: this.mapZoom, - }; - } else if (data && data.length > 0 && data[0].geojson && data[0].geojson.type === 'MultiPolygon') { - // Handle MultiPolygon case (cities with multiple areas) - const allPolygons: Polygon[] = []; + // Fit map to polygon bounds + const bounds = cityPolygon.getBounds(); + this.mapOptions = { + ...this.mapOptions, + center: bounds.getCenter(), + zoom: this.mapZoom, + }; + } else if (data && data.length > 0 && data[0].geojson && data[0].geojson.type === 'MultiPolygon') { + // Handle MultiPolygon case (cities with multiple areas) + const allPolygons: Polygon[] = []; - data[0].geojson.coordinates.forEach((polygonCoords: number[][][]) => { - const latlngs = polygonCoords[0].map((coord: number[]) => latLng(coord[1], coord[0])); - const cityPolygon = polygon(latlngs, { - color: '#ef4444', - fillColor: '#ef4444', - fillOpacity: 0.1, - weight: 2 + data[0].geojson.coordinates.forEach((polygonCoords: number[][][]) => { + const latlngs = polygonCoords[0].map((coord: number[]) => latLng(coord[1], coord[0])); + const cityPolygon = polygon(latlngs, { + color: '#ef4444', + fillColor: '#ef4444', + fillOpacity: 0.1, + weight: 2 + }); + allPolygons.push(cityPolygon); }); - allPolygons.push(cityPolygon); - }); - // Add popup to first polygon - if (allPolygons.length > 0) { - allPolygons[0].bindPopup(` + // Add popup to first polygon + if (allPolygons.length > 0) { + allPolygons[0].bindPopup(`
General Area:
${cityName}, ${county ? county + ', ' : ''}${state}
City boundary shown for privacy.
Exact location provided after contact.
`); - } + } - this.mapLayers = [ - tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - attribution: '© OpenStreetMap contributors', - }), - ...allPolygons - ]; + this.mapLayers = [ + tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + }), + ...allPolygons + ]; - // Calculate combined bounds - if (allPolygons.length > 0) { - const bounds = new LatLngBounds([]); - allPolygons.forEach(p => bounds.extend(p.getBounds())); - this.mapOptions = { - ...this.mapOptions, - center: bounds.getCenter(), - zoom: this.mapZoom, - }; + // Calculate combined bounds + if (allPolygons.length > 0) { + const bounds = new LatLngBounds([]); + allPolygons.forEach(p => bounds.extend(p.getBounds())); + this.mapOptions = { + ...this.mapOptions, + center: bounds.getCenter(), + zoom: this.mapZoom, + }; + } + } else { + // Fallback: Use circle if no polygon data available + this.useFallbackCircle(latitude, longitude, cityName, county, state); } - } else { - // Fallback: Use circle if no polygon data available + }, + error: (err) => { + console.error('Error fetching city boundary:', err); + // Fallback: Use circle on error this.useFallbackCircle(latitude, longitude, cityName, county, state); } - }, - error: (err) => { - console.error('Error fetching city boundary:', err); - // Fallback: Use circle on error - this.useFallbackCircle(latitude, longitude, cityName, county, state); - } - }); + }); } // Case 2: Only state available (NEW) - show state-level circle else if (state) { diff --git a/bizmatch/src/app/pages/details/details-commercial-property-listing/details-commercial-property-listing.component.html b/bizmatch/src/app/pages/details/details-commercial-property-listing/details-commercial-property-listing.component.html index b200e20..3f1e678 100644 --- a/bizmatch/src/app/pages/details/details-commercial-property-listing/details-commercial-property-listing.component.html +++ b/bizmatch/src/app/pages/details/details-commercial-property-listing/details-commercial-property-listing.component.html @@ -6,10 +6,8 @@ @if(listing){

{{ listing?.title }}

-
@@ -17,33 +15,41 @@

-
@if(listing && listingUser && (listingUser?.email===user?.email || (authService.isAdmin() | async))){
-
} @if(user){
-
- - - +
+ +
+ +
+ +
+ +
+ +

Location Map

-
+
@@ -89,20 +120,26 @@

Please include your contact info below

- - + +
- + - +
- +
- +
@@ -120,13 +157,17 @@ } -
+ \ No newline at end of file diff --git a/bizmatch/src/app/pages/details/details-commercial-property-listing/details-commercial-property-listing.component.ts b/bizmatch/src/app/pages/details/details-commercial-property-listing/details-commercial-property-listing.component.ts index 4207846..2e661ab 100644 --- a/bizmatch/src/app/pages/details/details-commercial-property-listing/details-commercial-property-listing.component.ts +++ b/bizmatch/src/app/pages/details/details-commercial-property-listing/details-commercial-property-listing.component.ts @@ -5,7 +5,6 @@ import { LeafletModule } from '@bluehalo/ngx-leaflet'; import { faTimes } from '@fortawesome/free-solid-svg-icons'; import dayjs from 'dayjs'; import { GalleryModule, ImageItem } from 'ng-gallery'; -import { ShareButton } from 'ngx-sharebuttons/button'; import { lastValueFrom } from 'rxjs'; import { CommercialPropertyListing, EventTypeEnum, ShareByEMail, User } from '../../../../../../bizmatch-server/src/models/db.model'; import { CommercialPropertyListingCriteria, ErrorResponse, KeycloakUser, MailInfo } from '../../../../../../bizmatch-server/src/models/main.model'; @@ -29,11 +28,12 @@ import { SharedModule } from '../../../shared/shared/shared.module'; import { createMailInfo, map2User } from '../../../utils/utils'; import { BaseDetailsComponent } from '../base-details.component'; import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component'; +import { ShareButton } from 'ngx-sharebuttons/button'; @Component({ selector: 'app-details-commercial-property-listing', standalone: true, - imports: [SharedModule, ValidatedInputComponent, ValidatedTextareaComponent, ShareButton, ValidatedNgSelectComponent, GalleryModule, LeafletModule, BreadcrumbsComponent], + imports: [SharedModule, ValidatedInputComponent, ValidatedTextareaComponent, ValidatedNgSelectComponent, GalleryModule, LeafletModule, BreadcrumbsComponent, ShareButton], providers: [], templateUrl: './details-commercial-property-listing.component.html', styleUrl: '../details.scss', @@ -162,7 +162,7 @@ export class DetailsCommercialPropertyListingComponent extends BaseDetailsCompon yearBuilt: (this.listing as any).yearBuilt, images: this.listing.imageOrder?.length > 0 ? this.listing.imageOrder.map(img => - `${this.env.imageBaseUrl}/pictures/property/${this.listing.imagePath}/${this.listing.serialId}/${img}`) + `${this.env.imageBaseUrl}/pictures/property/${this.listing.imagePath}/${this.listing.serialId}/${img}`) : [] }; this.seoService.updateCommercialPropertyMeta(propertyData); @@ -282,6 +282,26 @@ export class DetailsCommercialPropertyListingComponent extends BaseDetailsCompon createEvent(eventType: EventTypeEnum) { this.auditService.createEvent(this.listing.id, eventType, this.user?.email); } + + shareToFacebook() { + const url = encodeURIComponent(window.location.href); + window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`, '_blank', 'width=600,height=400'); + this.createEvent('facebook'); + } + + shareToTwitter() { + const url = encodeURIComponent(window.location.href); + const text = encodeURIComponent(this.listing?.title || 'Check out this commercial property'); + window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank', 'width=600,height=400'); + this.createEvent('x'); + } + + shareToLinkedIn() { + const url = encodeURIComponent(window.location.href); + window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${url}`, '_blank', 'width=600,height=400'); + this.createEvent('linkedin'); + } + getDaysListed() { return dayjs().diff(this.listing.created, 'day'); } diff --git a/bizmatch/src/app/pages/details/details.scss b/bizmatch/src/app/pages/details/details.scss index fe366d4..4258549 100644 --- a/bizmatch/src/app/pages/details/details.scss +++ b/bizmatch/src/app/pages/details/details.scss @@ -58,6 +58,7 @@ button.share { margin-right: 4px; margin-left: 2px; border-radius: 4px; + cursor: pointer; i { font-size: 15px; } @@ -71,6 +72,15 @@ button.share { .share-email { background-color: #ff961c; } +.share-facebook { + background-color: #1877f2; +} +.share-twitter { + background-color: #000000; +} +.share-linkedin { + background-color: #0a66c2; +} :host ::ng-deep .ng-select-container { height: 42px !important; border-radius: 0.5rem; diff --git a/bizmatch/src/app/pages/home/home.component.html b/bizmatch/src/app/pages/home/home.component.html index ae9f399..b99d85f 100644 --- a/bizmatch/src/app/pages/home/home.component.html +++ b/bizmatch/src/app/pages/home/home.component.html @@ -6,7 +6,7 @@ } @else { Log In - Register + Sign Up } @@ -24,7 +24,7 @@ Account } @else { Log In - Register + Sign Up } } -
- {{ selectOptions.getBusiness(listing.type) }} + {{ + selectOptions.getBusiness(listing.type) }}

{{ listing.title }} @if(listing.draft) { - Draft + Draft }

- + {{ selectOptions.getState(listing.location.state) }} @@ -97,8 +101,7 @@ [ngClass]="{ 'bg-emerald-100 text-emerald-800 border-emerald-300': badge === 'NEW', 'bg-teal-100 text-teal-800 border-teal-300': badge === 'UPDATED' - }" - > + }"> {{ badge }} } @@ -112,28 +115,28 @@

Sales revenue: - {{ listing?.salesRevenue != null ? (listing.salesRevenue | currency : 'USD' : 'symbol' : '1.0-0') : 'undisclosed' }} + {{ listing?.salesRevenue != null ? (listing.salesRevenue | currency : 'USD' : 'symbol' : '1.0-0') : + 'undisclosed' }}

Net profit: - {{ listing?.cashFlow != null ? (listing.cashFlow | currency : 'USD' : 'symbol' : '1.0-0') : 'undisclosed' }} + {{ listing?.cashFlow != null ? (listing.cashFlow | currency : 'USD' : 'symbol' : '1.0-0') : 'undisclosed' + }}

- Location: {{ listing.location.name ? listing.location.name : listing.location.county ? listing.location.county : this.selectOptions.getState(listing.location.state) }} + Location: {{ listing.location.name ? listing.location.name : listing.location.county ? + listing.location.county : this.selectOptions.getState(listing.location.state) }}

Years established: {{ listing.established }}

@if(listing.imageName) { + [alt]="altText.generateListingCardLogoAlt(listing)" + class="absolute bottom-[80px] right-[20px] h-[45px] w-auto" width="100" height="45" /> }
@@ -144,39 +147,33 @@ } @else if (listings?.length === 0) {
- + + fill="#EEF2FF" /> + fill="white" stroke="#E5E7EB" /> - + stroke="#E5E7EB" /> + + fill="#A5B4FC" stroke="#818CF8" /> + fill="#4F46E5" /> + fill="#4F46E5" /> + fill="#4F46E5" /> @@ -186,14 +183,17 @@

No listings found

-

We couldn't find any businesses matching your criteria.
Try adjusting your filters or explore popular categories below.

+

We couldn't find any businesses + matching your criteria.
Try adjusting your filters or explore popular categories below.

- -
@@ -204,22 +204,28 @@ Popular Categories
- - - - - -
@@ -247,5 +253,7 @@
- -
+ +
\ No newline at end of file diff --git a/bizmatch/src/app/pages/listings/business-listings/business-listings.component.ts b/bizmatch/src/app/pages/listings/business-listings/business-listings.component.ts index eac8016..06aaf53 100644 --- a/bizmatch/src/app/pages/listings/business-listings/business-listings.component.ts +++ b/bizmatch/src/app/pages/listings/business-listings/business-listings.component.ts @@ -77,7 +77,7 @@ export class BusinessListingsComponent implements OnInit, OnDestroy { private route: ActivatedRoute, private seoService: SeoService, private authService: AuthService, - ) {} + ) { } async ngOnInit(): Promise { // Load user for favorites functionality @@ -259,6 +259,47 @@ export class BusinessListingsComponent implements OnInit, OnDestroy { } } + /** + * Share a listing - opens native share dialog or copies to clipboard + */ + async shareListing(event: Event, listing: BusinessListing): Promise { + event.stopPropagation(); + event.preventDefault(); + + const url = `${window.location.origin}/business/${listing.slug || listing.id}`; + const title = listing.title || 'Business Listing'; + + // Try native share API first (works on mobile and some desktop browsers) + if (navigator.share) { + try { + await navigator.share({ + title: title, + text: `Check out this business: ${title}`, + url: url, + }); + } catch (err) { + // User cancelled or share failed - fall back to clipboard + this.copyToClipboard(url); + } + } else { + // Fallback: open Facebook share dialog + const encodedUrl = encodeURIComponent(url); + window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`, '_blank', 'width=600,height=400'); + } + } + + /** + * Copy URL to clipboard and show feedback + */ + private copyToClipboard(url: string): void { + navigator.clipboard.writeText(url).then(() => { + // Could add a toast notification here + console.log('Link copied to clipboard!'); + }).catch(err => { + console.error('Failed to copy link:', err); + }); + } + ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); diff --git a/bizmatch/src/build.ts b/bizmatch/src/build.ts index 06a51f9..e96f83c 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: 01.12.2025 20:23 | TX: 12/01/2025 1:23 PM" + timestamp: "GER: 02.01.2026 23:17 | TX: 01/02/2026 4:17 PM" }; export default build; \ No newline at end of file diff --git a/bizmatch/src/environments/environment.base.ts b/bizmatch/src/environments/environment.base.ts index 5a61704..105a49b 100644 --- a/bizmatch/src/environments/environment.base.ts +++ b/bizmatch/src/environments/environment.base.ts @@ -1,4 +1,5 @@ -export const hostname = window.location.hostname; +// SSR-safe: check if window exists (it doesn't on server-side) +const hostname = typeof window !== 'undefined' ? window.location.hostname : 'localhost'; export const environment_base = { // apiBaseUrl: 'http://localhost:3000', apiBaseUrl: `http://${hostname}:4200`, diff --git a/bizmatch/src/main.server.ts b/bizmatch/src/main.server.ts index 4b9d4d1..c8b11af 100644 --- a/bizmatch/src/main.server.ts +++ b/bizmatch/src/main.server.ts @@ -1,3 +1,6 @@ +// IMPORTANT: DOM polyfill must be imported FIRST, before any browser-dependent libraries +import './ssr-dom-polyfill'; + import { bootstrapApplication } from '@angular/platform-browser'; import { AppComponent } from './app/app.component'; import { config } from './app/app.config.server'; diff --git a/bizmatch/src/robots.txt b/bizmatch/src/robots.txt index 0bbb7db..e8ca0c8 100644 --- a/bizmatch/src/robots.txt +++ b/bizmatch/src/robots.txt @@ -1,27 +1,140 @@ -# robots.txt for BizMatch +# robots.txt for BizMatch - Business Marketplace +# https://biz-match.com +# Last updated: 2026-01-02 + +# =========================================== +# Default rules for all crawlers +# =========================================== User-agent: * + +# Allow all public pages Allow: / Allow: /home -Allow: /listings -Allow: /listings-2 -Allow: /listings-3 -Allow: /listings-4 -Allow: /details-business-listing/ -Allow: /details-commercial-property/ +Allow: /businessListings +Allow: /commercialPropertyListings +Allow: /brokerListings +Allow: /business/* +Allow: /commercial-property/* +Allow: /details-user/* +Allow: /terms-of-use +Allow: /privacy-statement # Disallow private/admin areas Disallow: /admin/ -Disallow: /profile/ -Disallow: /dashboard/ -Disallow: /favorites/ -Disallow: /settings/ +Disallow: /account +Disallow: /myListings +Disallow: /myFavorites +Disallow: /createBusinessListing +Disallow: /createCommercialPropertyListing +Disallow: /editBusinessListing/* +Disallow: /editCommercialPropertyListing/* +Disallow: /login +Disallow: /logout +Disallow: /register +Disallow: /emailUs -# Allow common crawlers +# Disallow duplicate content / API routes +Disallow: /api/ +Disallow: /bizmatch/ + +# Disallow search result pages with parameters (to avoid duplicate content) +Disallow: /*?*sortBy= +Disallow: /*?*page= +Disallow: /*?*start= + +# =========================================== +# Google-specific rules +# =========================================== User-agent: Googlebot Allow: / +Crawl-delay: 1 +# Allow Google to index images +User-agent: Googlebot-Image +Allow: /assets/ +Disallow: /assets/leaflet/ + +# =========================================== +# Bing-specific rules +# =========================================== User-agent: Bingbot Allow: / +Crawl-delay: 2 -# Sitemap location (served from backend API) -Sitemap: https://biz-match.com/bizmatch/sitemap/sitemap.xml +# =========================================== +# Other major search engines +# =========================================== +User-agent: DuckDuckBot +Allow: / +Crawl-delay: 2 + +User-agent: Slurp +Allow: / +Crawl-delay: 2 + +User-agent: Yandex +Allow: / +Crawl-delay: 5 + +User-agent: Baiduspider +Allow: / +Crawl-delay: 5 + +# =========================================== +# AI/LLM Crawlers (Answer Engine Optimization) +# =========================================== +User-agent: GPTBot +Allow: / +Allow: /businessListings +Allow: /business/* +Disallow: /admin/ +Disallow: /account + +User-agent: ChatGPT-User +Allow: / + +User-agent: Claude-Web +Allow: / + +User-agent: Anthropic-AI +Allow: / + +User-agent: PerplexityBot +Allow: / + +User-agent: Cohere-ai +Allow: / + +# =========================================== +# Block unwanted bots +# =========================================== +User-agent: AhrefsBot +Disallow: / + +User-agent: SemrushBot +Disallow: / + +User-agent: MJ12bot +Disallow: / + +User-agent: DotBot +Disallow: / + +User-agent: BLEXBot +Disallow: / + +# =========================================== +# Sitemap locations +# =========================================== +# Main sitemap index (dynamically generated, contains all sub-sitemaps) +Sitemap: https://biz-match.com/bizmatch/sitemap.xml + +# Individual sitemaps (auto-listed in sitemap index) +# - https://biz-match.com/bizmatch/sitemap/static.xml +# - https://biz-match.com/bizmatch/sitemap/business-1.xml +# - https://biz-match.com/bizmatch/sitemap/commercial-1.xml + +# =========================================== +# Host directive (for Yandex) +# =========================================== +Host: https://biz-match.com diff --git a/bizmatch/src/ssr-dom-polyfill.ts b/bizmatch/src/ssr-dom-polyfill.ts new file mode 100644 index 0000000..05086b7 --- /dev/null +++ b/bizmatch/src/ssr-dom-polyfill.ts @@ -0,0 +1,163 @@ +/** + * DOM Polyfills for Server-Side Rendering + * + * This file must be imported BEFORE any browser-only libraries like Leaflet. + * It provides minimal stubs for browser globals that are required during module loading. + */ + +// Create a minimal screen mock +const screenMock = { + width: 1920, + height: 1080, + availWidth: 1920, + availHeight: 1080, + colorDepth: 24, + pixelDepth: 24, + deviceXDPI: 96, + deviceYDPI: 96, + logicalXDPI: 96, + logicalYDPI: 96, +}; + +// Create a minimal document mock +const documentMock = { + createElement: (tag: string) => ({ + style: {}, + setAttribute: () => { }, + getAttribute: () => null, + appendChild: () => { }, + removeChild: () => { }, + classList: { + add: () => { }, + remove: () => { }, + contains: () => false, + }, + tagName: tag.toUpperCase(), + }), + createElementNS: (ns: string, tag: string) => ({ + style: {}, + setAttribute: () => { }, + getAttribute: () => null, + appendChild: () => { }, + getBBox: () => ({ x: 0, y: 0, width: 0, height: 0 }), + tagName: tag.toUpperCase(), + }), + createTextNode: () => ({}), + head: { appendChild: () => { }, removeChild: () => { } }, + body: { appendChild: () => { }, removeChild: () => { } }, + documentElement: { + style: {}, + clientWidth: 1920, + clientHeight: 1080, + }, + addEventListener: () => { }, + removeEventListener: () => { }, + querySelector: () => null, + querySelectorAll: () => [], + getElementById: () => null, + getElementsByTagName: () => [], + getElementsByClassName: () => [], +}; + +// Create a minimal window mock for libraries that check for window existence during load +const windowMock = { + requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(callback, 16), + cancelAnimationFrame: (id: number) => clearTimeout(id), + addEventListener: () => { }, + removeEventListener: () => { }, + getComputedStyle: () => ({ + getPropertyValue: () => '', + }), + matchMedia: () => ({ + matches: false, + addListener: () => { }, + removeListener: () => { }, + addEventListener: () => { }, + removeEventListener: () => { }, + }), + document: documentMock, + screen: screenMock, + devicePixelRatio: 1, + navigator: { + userAgent: 'node', + platform: 'server', + language: 'en', + languages: ['en'], + onLine: true, + geolocation: null, + }, + location: { + hostname: 'localhost', + href: 'http://localhost', + protocol: 'http:', + pathname: '/', + search: '', + hash: '', + host: 'localhost', + origin: 'http://localhost', + }, + history: { + pushState: () => { }, + replaceState: () => { }, + back: () => { }, + forward: () => { }, + go: () => { }, + length: 0, + }, + localStorage: { + getItem: () => null, + setItem: () => { }, + removeItem: () => { }, + clear: () => { }, + }, + sessionStorage: { + getItem: () => null, + setItem: () => { }, + removeItem: () => { }, + clear: () => { }, + }, + setTimeout, + clearTimeout, + setInterval, + clearInterval, + innerWidth: 1920, + innerHeight: 1080, + outerWidth: 1920, + outerHeight: 1080, + scrollX: 0, + scrollY: 0, + pageXOffset: 0, + pageYOffset: 0, + scrollTo: () => { }, + scroll: () => { }, + Image: class Image { }, + HTMLElement: class HTMLElement { }, + SVGElement: class SVGElement { }, +}; + +// Only set globals if they don't exist (i.e., we're in Node.js) +if (typeof window === 'undefined') { + (global as any).window = windowMock; +} + +if (typeof document === 'undefined') { + (global as any).document = documentMock; +} + +if (typeof navigator === 'undefined') { + (global as any).navigator = windowMock.navigator; +} + +if (typeof screen === 'undefined') { + (global as any).screen = screenMock; +} + +if (typeof HTMLElement === 'undefined') { + (global as any).HTMLElement = windowMock.HTMLElement; +} + +if (typeof SVGElement === 'undefined') { + (global as any).SVGElement = windowMock.SVGElement; +} + +export { }; diff --git a/bizmatch/ssr-dom-preload.mjs b/bizmatch/ssr-dom-preload.mjs new file mode 100644 index 0000000..6123516 --- /dev/null +++ b/bizmatch/ssr-dom-preload.mjs @@ -0,0 +1,154 @@ +/** + * Node.js Preload Script for SSR Development + * + * This script creates DOM global mocks BEFORE any modules are loaded. + * It only applies in the main thread - NOT in worker threads (sass, esbuild). + */ + +import { isMainThread } from 'node:worker_threads'; + +// Only apply polyfills in the main thread, not in workers +if (!isMainThread) { + // Skip polyfills in worker threads to avoid breaking sass/esbuild + // console.log('[SSR] Skipping polyfills in worker thread'); +} else { + // Create screen mock + const screenMock = { + width: 1920, + height: 1080, + availWidth: 1920, + availHeight: 1080, + colorDepth: 24, + pixelDepth: 24, + deviceXDPI: 96, + deviceYDPI: 96, + logicalXDPI: 96, + logicalYDPI: 96, + }; + + // Create document mock + const documentMock = { + createElement: (tag) => ({ + style: {}, + setAttribute: () => { }, + getAttribute: () => null, + appendChild: () => { }, + removeChild: () => { }, + classList: { add: () => { }, remove: () => { }, contains: () => false }, + tagName: tag?.toUpperCase() || 'DIV', + }), + createElementNS: (ns, tag) => ({ + style: {}, + setAttribute: () => { }, + getAttribute: () => null, + appendChild: () => { }, + getBBox: () => ({ x: 0, y: 0, width: 0, height: 0 }), + tagName: tag?.toUpperCase() || 'SVG', + }), + createTextNode: () => ({}), + head: { appendChild: () => { }, removeChild: () => { } }, + body: { appendChild: () => { }, removeChild: () => { } }, + documentElement: { + style: {}, + clientWidth: 1920, + clientHeight: 1080, + querySelector: () => null, + querySelectorAll: () => [], + getAttribute: () => null, + setAttribute: () => { }, + }, + addEventListener: () => { }, + removeEventListener: () => { }, + querySelector: () => null, + querySelectorAll: () => [], + getElementById: () => null, + getElementsByTagName: () => [], + getElementsByClassName: () => [], + }; + + // Create window mock + const windowMock = { + requestAnimationFrame: (callback) => setTimeout(callback, 16), + cancelAnimationFrame: (id) => clearTimeout(id), + addEventListener: () => { }, + removeEventListener: () => { }, + getComputedStyle: () => ({ getPropertyValue: () => '' }), + matchMedia: () => ({ + matches: false, + addListener: () => { }, + removeListener: () => { }, + addEventListener: () => { }, + removeEventListener: () => { }, + }), + document: documentMock, + screen: screenMock, + devicePixelRatio: 1, + navigator: { + userAgent: 'node', + platform: 'server', + language: 'en', + languages: ['en'], + onLine: true, + geolocation: null, + }, + location: { + hostname: 'localhost', + href: 'http://localhost', + protocol: 'http:', + pathname: '/', + search: '', + hash: '', + host: 'localhost', + origin: 'http://localhost', + }, + history: { + pushState: () => { }, + replaceState: () => { }, + back: () => { }, + forward: () => { }, + go: () => { }, + length: 0, + }, + localStorage: { + getItem: () => null, + setItem: () => { }, + removeItem: () => { }, + clear: () => { }, + }, + sessionStorage: { + getItem: () => null, + setItem: () => { }, + removeItem: () => { }, + clear: () => { }, + }, + setTimeout, + clearTimeout, + setInterval, + clearInterval, + innerWidth: 1920, + innerHeight: 1080, + outerWidth: 1920, + outerHeight: 1080, + scrollX: 0, + scrollY: 0, + pageXOffset: 0, + pageYOffset: 0, + scrollTo: () => { }, + scroll: () => { }, + Image: class Image { }, + HTMLElement: class HTMLElement { }, + SVGElement: class SVGElement { }, + }; + + // Set globals + globalThis.window = windowMock; + globalThis.document = documentMock; + globalThis.navigator = windowMock.navigator; + globalThis.screen = screenMock; + globalThis.HTMLElement = windowMock.HTMLElement; + globalThis.SVGElement = windowMock.SVGElement; + globalThis.localStorage = windowMock.localStorage; + globalThis.sessionStorage = windowMock.sessionStorage; + + console.log('[SSR] DOM polyfills loaded'); +}