npm run serve:ssr funktioniert und Hamburger Menu bug fix

This commit is contained in:
knuthtimo-lab 2026-01-06 22:36:14 +01:00
parent 43027a54f7
commit 4f8fd77f7d
21 changed files with 371 additions and 111 deletions

View File

@ -26,6 +26,12 @@
"ssr": {
"entry": "server.ts"
},
"allowedCommonJsDependencies": [
"quill-delta",
"leaflet",
"dayjs",
"qs"
],
"polyfills": [
"zone.js"
],

View File

@ -3,17 +3,24 @@ import './src/ssr-dom-polyfill';
import { APP_BASE_HREF } from '@angular/common';
import { AngularNodeAppEngine, createNodeRequestHandler, writeResponseToNodeResponse } from '@angular/ssr/node';
import { ɵsetAngularAppEngineManifest as setAngularAppEngineManifest } from '@angular/ssr';
import express from 'express';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
// The Express app is exported so that it can be used by serverless Functions.
export function app(): express.Express {
export async function app(): Promise<express.Express> {
const server = express();
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');
const indexHtml = join(serverDistFolder, 'index.server.html');
// Explicitly load and set the Angular app engine manifest
// This is required for environments where the manifest is not auto-loaded
const manifestPath = join(serverDistFolder, 'angular-app-engine-manifest.mjs');
const manifest = await import(manifestPath);
setAngularAppEngineManifest(manifest.default);
const angularApp = new AngularNodeAppEngine();
server.set('view engine', 'html');
@ -27,27 +34,46 @@ export function app(): express.Express {
}));
// All regular routes use the Angular engine
server.get('*', (req, res, next) => {
angularApp
.handle(req)
.then((response) => {
server.get('*', async (req, res, next) => {
console.log(`[SSR] Handling request: ${req.method} ${req.url}`);
try {
const response = await angularApp.handle(req);
if (response) {
console.log(`[SSR] Response received for ${req.url}, status: ${response.status}`);
writeResponseToNodeResponse(response, res);
} else {
console.log(`[SSR] No response for ${req.url} - Angular engine returned null`);
console.log(`[SSR] This usually means the route couldn't be rendered. Check for:
1. Browser API usage in components
2. Missing platform checks
3. Errors during component initialization`);
res.sendStatus(404);
}
})
.catch((err) => next(err));
} catch (err) {
console.error(`[SSR] Error handling ${req.url}:`, err);
console.error(`[SSR] Stack trace:`, err.stack);
next(err);
}
});
return server;
}
function run(): void {
// Global error handlers for debugging
process.on('unhandledRejection', (reason, promise) => {
console.error('[SSR] Unhandled Rejection at:', promise, 'reason:', reason);
});
process.on('uncaughtException', (error) => {
console.error('[SSR] Uncaught Exception:', error);
console.error('[SSR] Stack:', error.stack);
});
async function run(): Promise<void> {
const port = process.env['PORT'] || 4200;
// Start up the Node server
const server = app();
const server = await app();
server.listen(port, () => {
console.log(`Node Express server listening on http://localhost:${port}`);
});

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { AfterViewInit, Component, HostListener } from '@angular/core';
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { AfterViewInit, Component, HostListener, PLATFORM_ID, inject } from '@angular/core';
import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/router';
import { initFlowbite } from 'flowbite';
import { filter } from 'rxjs/operators';
@ -29,6 +29,8 @@ export class AppComponent implements AfterViewInit {
build = build;
title = 'bizmatch';
actualRoute = '';
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
public constructor(
public loadingService: LoadingService,
@ -48,9 +50,11 @@ export class AppComponent implements AfterViewInit {
this.actualRoute = currentRoute.snapshot.url[0].path;
// Re-initialize Flowbite after navigation to ensure all components are ready
if (this.isBrowser) {
setTimeout(() => {
initFlowbite();
}, 50);
}
});
}
ngOnInit() {
@ -60,8 +64,10 @@ export class AppComponent implements AfterViewInit {
ngAfterViewInit() {
// Initialize Flowbite for dropdowns, modals, and other interactive components
// Note: Drawers work automatically with data-drawer-target attributes
if (this.isBrowser) {
initFlowbite();
}
}
@HostListener('window:keydown', ['$event'])
handleKeyboardEvent(event: KeyboardEvent) {

View File

@ -1,11 +1,15 @@
import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { provideServerRouting } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';
const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering()
provideServerRendering(),
provideServerRouting(serverRoutes)
]
};
export const config = mergeApplicationConfig(appConfig, serverConfig);

View File

@ -1,5 +1,6 @@
import { IMAGE_CONFIG } from '@angular/common';
import { APP_INITIALIZER, ApplicationConfig, ErrorHandler } from '@angular/core';
import { IMAGE_CONFIG, isPlatformBrowser } from '@angular/common';
import { APP_INITIALIZER, ApplicationConfig, ErrorHandler, PLATFORM_ID, inject } from '@angular/core';
import { provideClientHydration } from '@angular/platform-browser';
import { provideRouter, withEnabledBlockingInitialNavigation, withInMemoryScrolling } from '@angular/router';
import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
@ -19,10 +20,12 @@ import { GlobalErrorHandler } from './services/globalErrorHandler';
import { POSTHOG_INIT_PROVIDER } from './services/posthog.factory';
import { SelectOptionsService } from './services/select-options.service';
import { createLogger } from './utils/utils';
// provideClientHydration()
const logger = createLogger('ApplicationConfig');
export const appConfig: ApplicationConfig = {
providers: [
// Temporarily disabled for SSR debugging
// provideClientHydration(),
provideHttpClient(withInterceptorsFromDi()),
{
provide: APP_INITIALIZER,
@ -90,7 +93,6 @@ export const appConfig: ApplicationConfig = {
}),
provideFirebaseApp(() => initializeApp(environment.firebaseConfig)),
provideAuth(() => getAuth()),
// provideFirestore(() => getFirestore()),
],
};
function initServices(selectOptions: SelectOptionsService) {

View File

@ -0,0 +1,8 @@
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: '**',
renderMode: RenderMode.Server
}
];

View File

@ -1,6 +1,7 @@
import { Routes } from '@angular/router';
import { LogoutComponent } from './components/logout/logout.component';
import { NotFoundComponent } from './components/not-found/not-found.component';
import { TestSsrComponent } from './components/test-ssr/test-ssr.component';
import { EmailAuthorizedComponent } from './components/email-authorized/email-authorized.component';
import { EmailVerificationComponent } from './components/email-verification/email-verification.component';
@ -26,6 +27,10 @@ import { TermsOfUseComponent } from './pages/legal/terms-of-use.component';
import { PrivacyStatementComponent } from './pages/legal/privacy-statement.component';
export const routes: Routes = [
{
path: 'test-ssr',
component: TestSsrComponent,
},
{
path: 'businessListings',
component: BusinessListingsComponent,

View File

@ -1,4 +1,5 @@
import { AfterViewInit, Component, ElementRef, HostBinding, Input, OnDestroy, ViewChild } from '@angular/core';
import { AfterViewInit, Component, ElementRef, HostBinding, Input, OnDestroy, ViewChild, PLATFORM_ID, inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { createPopper, Instance as PopperInstance } from '@popperjs/core';
@Component({
@ -23,6 +24,8 @@ export class DropdownComponent implements AfterViewInit, OnDestroy {
@HostBinding('class.hidden') isHidden: boolean = true;
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
private popperInstance: PopperInstance | null = null;
isVisible: boolean = false;
private clickOutsideListener: any;
@ -30,6 +33,8 @@ export class DropdownComponent implements AfterViewInit, OnDestroy {
private hoverHideListener: any;
ngAfterViewInit() {
if (!this.isBrowser) return;
if (!this.triggerEl) {
console.error('Trigger element is not provided to the dropdown component.');
return;
@ -58,6 +63,8 @@ export class DropdownComponent implements AfterViewInit, OnDestroy {
}
private setupEventListeners() {
if (!this.isBrowser) return;
if (this.triggerType === 'click') {
this.triggerEl.addEventListener('click', () => this.toggle());
} else if (this.triggerType === 'hover') {
@ -74,6 +81,8 @@ export class DropdownComponent implements AfterViewInit, OnDestroy {
}
private removeEventListeners() {
if (!this.isBrowser) return;
if (this.triggerType === 'click') {
this.triggerEl.removeEventListener('click', () => this.toggle());
} else if (this.triggerType === 'hover') {
@ -104,7 +113,7 @@ export class DropdownComponent implements AfterViewInit, OnDestroy {
}
private handleClickOutside(event: MouseEvent) {
if (!this.isVisible) return;
if (!this.isVisible || !this.isBrowser) return;
const clickedElement = event.target as HTMLElement;
if (this.ignoreClickOutsideClass) {

View File

@ -31,8 +31,7 @@
}
<button type="button"
class="flex text-sm bg-neutral-400 rounded-full md:me-0 focus:ring-4 focus:ring-neutral-300 dark:focus:ring-neutral-600"
id="user-menu-button" aria-expanded="false" [attr.data-dropdown-toggle]="user ? 'user-login' : 'user-unknown'"
data-dropdown-placement="bottom">
id="user-menu-button" aria-expanded="false" [attr.data-dropdown-toggle]="user ? 'user-login' : 'user-unknown'">
<span class="sr-only">Open user menu</span>
@if(isProfessional || (authService.isAdmin() | async) && user?.hasProfile){
<img class="w-8 h-8 rounded-full object-cover" src="{{ profileUrl }}"

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { Component, HostListener, OnDestroy, OnInit, AfterViewInit } from '@angular/core';
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { Component, HostListener, OnDestroy, OnInit, AfterViewInit, PLATFORM_ID, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NavigationEnd, Router, RouterModule } from '@angular/router';
import { faUserGear } from '@fortawesome/free-solid-svg-icons';
@ -42,6 +42,8 @@ export class HeaderComponent implements OnInit, OnDestroy, AfterViewInit {
isMobile: boolean = false;
private destroy$ = new Subject<void>();
prompt: string;
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
// Aktueller Listing-Typ basierend auf Route
currentListingType: 'businessListings' | 'commercialPropertyListings' | 'brokerListings' | null = null;
@ -74,6 +76,16 @@ export class HeaderComponent implements OnInit, OnDestroy, AfterViewInit {
const excludedIds = ['sortDropdownButton', 'sortDropdownMobileButton', 'user-menu-button'];
if (!excludedIds.includes(target.id) && !target.closest('#user-menu-button')) {
this.sortDropdownVisible = false;
// Close User Menu if clicked outside
// We check if the click was inside the menu containers
const userLogin = document.getElementById('user-login');
const userUnknown = document.getElementById('user-unknown');
const clickedInsideMenu = (userLogin && userLogin.contains(target)) || (userUnknown && userUnknown.contains(target));
if (!clickedInsideMenu) {
this.closeDropdown();
}
}
}
@ -103,7 +115,7 @@ export class HeaderComponent implements OnInit, OnDestroy, AfterViewInit {
const previousUser = this.user;
this.user = u;
// Re-initialize Flowbite if user logged in/out state changed
if ((previousUser === null) !== (u === null)) {
if ((previousUser === null) !== (u === null) && this.isBrowser) {
setTimeout(() => initFlowbite(), 50);
}
});
@ -223,6 +235,8 @@ export class HeaderComponent implements OnInit, OnDestroy, AfterViewInit {
}
closeDropdown() {
if (!this.isBrowser) return;
const dropdownButton = document.getElementById('user-menu-button');
const dropdownMenu = this.user ? document.getElementById('user-login') : document.getElementById('user-unknown');
@ -233,6 +247,8 @@ export class HeaderComponent implements OnInit, OnDestroy, AfterViewInit {
}
closeMobileMenu() {
if (!this.isBrowser) return;
const targetElement = document.getElementById('navbar-user');
const triggerElement = document.querySelector('[data-collapse-toggle="navbar-user"]');
@ -293,12 +309,10 @@ export class HeaderComponent implements OnInit, OnDestroy, AfterViewInit {
};
}
ngAfterViewInit(): void {
// Initialize Flowbite after header DOM is fully rendered
// This ensures all dropdown elements exist before initialization
setTimeout(() => {
initFlowbite();
}, 0);
// Flowbite initialization is now handled manually or via AppComponent
}
ngOnDestroy() {

View File

@ -0,0 +1,24 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-test-ssr',
standalone: true,
template: `
<div>
<h1>SSR Test Component</h1>
<p>If you see this, SSR is working!</p>
</div>
`,
styles: [`
div {
padding: 20px;
background: #f0f0f0;
}
h1 { color: green; }
`]
})
export class TestSsrComponent {
constructor() {
console.log('[SSR] TestSsrComponent constructor called');
}
}

View File

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { Component, Input, SimpleChanges } from '@angular/core';
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { Component, Input, SimpleChanges, PLATFORM_ID, inject } from '@angular/core';
@Component({
selector: 'app-tooltip',
@ -12,6 +12,9 @@ export class TooltipComponent {
@Input() text: string;
@Input() isVisible: boolean = false;
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
ngOnInit() {
this.initializeTooltip();
}
@ -27,6 +30,8 @@ export class TooltipComponent {
}
private updateTooltipVisibility() {
if (!this.isBrowser) return;
const tooltipElement = document.getElementById(this.id);
if (tooltipElement) {
if (this.isVisible) {

View File

@ -1,11 +1,14 @@
import { inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { inject, PLATFORM_ID } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { KeycloakService } from '../services/keycloak.service';
export const authResolver: ResolveFn<boolean> = async (route, state) => {
const keycloakService: KeycloakService = inject(KeycloakService);
const platformId = inject(PLATFORM_ID);
const isBrowser = isPlatformBrowser(platformId);
if (!keycloakService.isLoggedIn()) {
if (!keycloakService.isLoggedIn() && isBrowser) {
await keycloakService.login({
redirectUri: window.location.href,
});

View File

@ -1,6 +1,7 @@
// auth.service.ts
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { HttpClient, HttpBackend, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable, inject, PLATFORM_ID } from '@angular/core';
import { FirebaseApp } from '@angular/fire/app';
import { GoogleAuthProvider, UserCredential, createUserWithEmailAndPassword, getAuth, signInWithCustomToken, signInWithEmailAndPassword, signInWithPopup } from 'firebase/auth';
import { BehaviorSubject, Observable, catchError, firstValueFrom, map, of, shareReplay, take, tap } from 'rxjs';
@ -14,8 +15,10 @@ export type UserRole = 'admin' | 'pro' | 'guest';
})
export class AuthService {
private app = inject(FirebaseApp);
private auth = getAuth(this.app);
private http = inject(HttpClient);
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
private auth = this.isBrowser ? getAuth(this.app) : null;
private http = new HttpClient(inject(HttpBackend));
private mailService = inject(MailService);
// Add a BehaviorSubject to track the current user role
private userRoleSubject = new BehaviorSubject<UserRole | null>(null);
@ -31,6 +34,26 @@ export class AuthService {
this.loadRoleFromToken();
}
// Helper methods for localStorage access (only in browser)
private setLocalStorageItem(key: string, value: string): void {
if (this.isBrowser) {
localStorage.setItem(key, value);
}
}
private getLocalStorageItem(key: string): string | null {
if (this.isBrowser) {
return localStorage.getItem(key);
}
return null;
}
private removeLocalStorageItem(key: string): void {
if (this.isBrowser) {
localStorage.removeItem(key);
}
}
private loadRoleFromToken(): void {
this.getToken().then(token => {
if (token) {
@ -54,10 +77,15 @@ export class AuthService {
}
// Registrierung mit Email und Passwort
async registerWithEmail(email: string, password: string): Promise<UserCredential> {
// Bestimmen der aktuellen Umgebung/Domain für die Verifizierungs-URL
let verificationUrl = '';
if (!this.isBrowser || !this.auth) {
throw new Error('Auth is only available in browser context');
}
// Prüfen der aktuellen Umgebung basierend auf dem Host
// Bestimmen der aktuellen Umgebung/Domain für die Verifizierungs-URL
let verificationUrl = 'https://www.bizmatch.net/email-authorized';
// Prüfen der aktuellen Umgebung basierend auf dem Host (nur im Browser)
if (this.isBrowser) {
const currentHost = window.location.hostname;
if (currentHost.includes('localhost')) {
@ -67,6 +95,7 @@ export class AuthService {
} else {
verificationUrl = 'https://www.bizmatch.net/email-authorized';
}
}
// ActionCode-Einstellungen mit der dynamischen URL
const actionCodeSettings = {
@ -93,10 +122,10 @@ export class AuthService {
}
// const token = await userCredential.user.getIdToken();
// localStorage.setItem('authToken', token);
// localStorage.setItem('refreshToken', userCredential.user.refreshToken);
// this.setLocalStorageItem('authToken', token);
// this.setLocalStorageItem('refreshToken', userCredential.user.refreshToken);
// if (userCredential.user.photoURL) {
// localStorage.setItem('photoURL', userCredential.user.photoURL);
// this.setLocalStorageItem('photoURL', userCredential.user.photoURL);
// }
return userCredential;
@ -104,13 +133,16 @@ export class AuthService {
// Login mit Email und Passwort
loginWithEmail(email: string, password: string): Promise<UserCredential> {
if (!this.isBrowser || !this.auth) {
throw new Error('Auth is only available in browser context');
}
return signInWithEmailAndPassword(this.auth, email, password).then(async userCredential => {
if (userCredential.user) {
const token = await userCredential.user.getIdToken();
localStorage.setItem('authToken', token);
localStorage.setItem('refreshToken', userCredential.user.refreshToken);
this.setLocalStorageItem('authToken', token);
this.setLocalStorageItem('refreshToken', userCredential.user.refreshToken);
if (userCredential.user.photoURL) {
localStorage.setItem('photoURL', userCredential.user.photoURL);
this.setLocalStorageItem('photoURL', userCredential.user.photoURL);
}
this.loadRoleFromToken();
}
@ -120,14 +152,17 @@ export class AuthService {
// Login mit Google
loginWithGoogle(): Promise<UserCredential> {
if (!this.isBrowser || !this.auth) {
throw new Error('Auth is only available in browser context');
}
const provider = new GoogleAuthProvider();
return signInWithPopup(this.auth, provider).then(async userCredential => {
if (userCredential.user) {
const token = await userCredential.user.getIdToken();
localStorage.setItem('authToken', token);
localStorage.setItem('refreshToken', userCredential.user.refreshToken);
this.setLocalStorageItem('authToken', token);
this.setLocalStorageItem('refreshToken', userCredential.user.refreshToken);
if (userCredential.user.photoURL) {
localStorage.setItem('photoURL', userCredential.user.photoURL);
this.setLocalStorageItem('photoURL', userCredential.user.photoURL);
}
this.loadRoleFromToken();
}
@ -137,13 +172,16 @@ export class AuthService {
// Logout: Token, RefreshToken und photoURL entfernen
logout(): Promise<void> {
localStorage.removeItem('authToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('photoURL');
this.removeLocalStorageItem('authToken');
this.removeLocalStorageItem('refreshToken');
this.removeLocalStorageItem('photoURL');
this.clearRoleCache();
this.userRoleSubject.next(null);
if (this.auth) {
return this.auth.signOut();
}
return Promise.resolve();
}
isAdmin(): Observable<boolean> {
return this.getUserRole().pipe(
map(role => role === 'admin'),
@ -202,10 +240,10 @@ export class AuthService {
// Force refresh the token to get updated custom claims
async refreshUserClaims(): Promise<void> {
this.clearRoleCache();
if (this.auth.currentUser) {
if (this.auth && this.auth.currentUser) {
await this.auth.currentUser.getIdToken(true);
const token = await this.auth.currentUser.getIdToken();
localStorage.setItem('authToken', token);
this.setLocalStorageItem('authToken', token);
this.loadRoleFromToken();
}
}
@ -234,7 +272,12 @@ export class AuthService {
}
// Versucht, mit dem RefreshToken einen neuen Access Token zu erhalten
async refreshToken(): Promise<string | null> {
const storedRefreshToken = localStorage.getItem('refreshToken');
const storedRefreshToken = this.getLocalStorageItem('refreshToken');
// SSR protection: refreshToken should only run in browser
if (!this.isBrowser) {
return null;
}
if (!storedRefreshToken) {
return null;
}
@ -250,8 +293,8 @@ export class AuthService {
// response enthält z.B. id_token, refresh_token, expires_in etc.
const newToken = response.id_token;
const newRefreshToken = response.refresh_token;
localStorage.setItem('authToken', newToken);
localStorage.setItem('refreshToken', newRefreshToken);
this.setLocalStorageItem('authToken', newToken);
this.setLocalStorageItem('refreshToken', newRefreshToken);
return newToken;
} catch (error) {
console.error('Error refreshing token:', error);
@ -266,7 +309,12 @@ export class AuthService {
* Ist auch das nicht möglich, wird null zurückgegeben.
*/
async getToken(): Promise<string | null> {
const token = localStorage.getItem('authToken');
const token = this.getLocalStorageItem('authToken');
// SSR protection: return null on server
if (!this.isBrowser) {
return null;
}
if (token && !this.isEMailVerified(token)) {
return null;
} else if (token && this.isTokenValid(token) && this.isEMailVerified(token)) {
@ -278,6 +326,9 @@ export class AuthService {
// Add this new method to sign in with a custom token
async signInWithCustomToken(token: string): Promise<void> {
if (!this.isBrowser || !this.auth) {
throw new Error('Auth is only available in browser context');
}
try {
// Sign in to Firebase with the custom token
const userCredential = await signInWithCustomToken(this.auth, token);
@ -285,11 +336,11 @@ export class AuthService {
// Store the authentication token
if (userCredential.user) {
const idToken = await userCredential.user.getIdToken();
localStorage.setItem('authToken', idToken);
localStorage.setItem('refreshToken', userCredential.user.refreshToken);
this.setLocalStorageItem('authToken', idToken);
this.setLocalStorageItem('refreshToken', userCredential.user.refreshToken);
if (userCredential.user.photoURL) {
localStorage.setItem('photoURL', userCredential.user.photoURL);
this.setLocalStorageItem('photoURL', userCredential.user.photoURL);
}
// Load user role from the token

View File

@ -1,4 +1,5 @@
import { Injectable } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { SortByOptions } from '../../../../bizmatch-server/src/models/db.model';
import { BusinessListingCriteria, CommercialPropertyListingCriteria, UserListingCriteria } from '../../../../bizmatch-server/src/models/main.model';
@ -27,6 +28,8 @@ interface FilterState {
export class FilterStateService {
private state: FilterState;
private stateSubjects: Map<ListingType, BehaviorSubject<any>> = new Map();
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
constructor() {
// Initialize state from sessionStorage or with defaults
@ -125,10 +128,12 @@ export class FilterStateService {
}
private saveToStorage(type: ListingType): void {
if (!this.isBrowser) return;
sessionStorage.setItem(type, JSON.stringify(this.state[type].criteria));
}
private saveSortByToStorage(type: ListingType, sortBy: SortByOptions | null): void {
if (!this.isBrowser) return;
const sortByKey = type === 'businessListings' ? 'businessSortBy' : type === 'commercialPropertyListings' ? 'commercialSortBy' : 'professionalsSortBy';
if (sortBy) {
@ -156,10 +161,12 @@ export class FilterStateService {
}
private loadCriteriaFromStorage(key: ListingType): CriteriaType {
if (this.isBrowser) {
const stored = sessionStorage.getItem(key);
if (stored) {
return JSON.parse(stored);
}
}
switch (key) {
case 'businessListings':
@ -172,6 +179,7 @@ export class FilterStateService {
}
private loadSortByFromStorage(key: string): SortByOptions | null {
if (!this.isBrowser) return null;
const stored = sessionStorage.getItem(key);
return stored && stored !== 'null' ? (stored as SortByOptions) : null;
}

View File

@ -1,5 +1,6 @@
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Injectable, PLATFORM_ID, inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { lastValueFrom, Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { CityAndStateResult, CountyResult, GeoResult, IpInfo } from '../../../../bizmatch-server/src/models/main.model';
@ -21,6 +22,8 @@ export class GeoService {
private readonly storageKey = 'ipInfo';
private readonly boundaryStoragePrefix = 'nominatim_boundary_';
private readonly cacheExpiration = 30 * 24 * 60 * 60 * 1000; // 30 days in milliseconds
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
constructor(private http: HttpClient) {}
@ -28,6 +31,8 @@ export class GeoService {
* Get cached boundary data from localStorage
*/
private getCachedBoundary(cacheKey: string): any | null {
if (!this.isBrowser) return null;
try {
const cached = localStorage.getItem(this.boundaryStoragePrefix + cacheKey);
if (!cached) {
@ -54,6 +59,8 @@ export class GeoService {
* Save boundary data to localStorage
*/
private setCachedBoundary(cacheKey: string, data: any): void {
if (!this.isBrowser) return;
try {
const cachedData: CachedBoundary = {
data: data,
@ -69,6 +76,8 @@ export class GeoService {
* Clear all cached boundary data
*/
clearBoundaryCache(): void {
if (!this.isBrowser) return;
try {
const keys = Object.keys(localStorage);
keys.forEach(key => {
@ -136,17 +145,21 @@ export class GeoService {
async getIpInfo(): Promise<IpInfo | null> {
// Versuche zuerst, die Daten aus dem sessionStorage zu holen
if (this.isBrowser) {
const storedData = sessionStorage.getItem(this.storageKey);
if (storedData) {
return JSON.parse(storedData);
}
}
try {
// Wenn keine Daten im Storage, hole sie vom Server
const data = await lastValueFrom(this.http.get<IpInfo>(`${this.apiBaseUrl}/bizmatch/geo/ipinfo/georesult/wysiwyg`));
// Speichere die Daten im sessionStorage
if (this.isBrowser) {
sessionStorage.setItem(this.storageKey, JSON.stringify(data));
}
return data;
} catch (error) {

View File

@ -1,5 +1,6 @@
import { isPlatformBrowser } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
import { lastValueFrom } from 'rxjs';
import { KeyValue, KeyValueAsSortBy, KeyValueStyle } from '../../../../bizmatch-server/src/models/main.model';
import { environment } from '../../environments/environment';
@ -9,9 +10,28 @@ import { environment } from '../../environments/environment';
})
export class SelectOptionsService {
private apiBaseUrl = environment.apiBaseUrl;
constructor(private http: HttpClient) {}
private platformId = inject(PLATFORM_ID);
constructor(private http: HttpClient) { }
async init() {
// Skip HTTP call on server-side to avoid blocking SSR
if (!isPlatformBrowser(this.platformId)) {
console.log('[SSR] SelectOptionsService.init() - Skipping HTTP call on server');
// Initialize with empty arrays - client will hydrate with real data
this.typesOfBusiness = [];
this.prices = [];
this.listingCategories = [];
this.customerTypes = [];
this.customerSubTypes = [];
this.states = [];
this.gender = [];
this.typesOfCommercialProperty = [];
this.distances = [];
this.sortByOptions = [];
return;
}
try {
const allSelectOptions = await lastValueFrom(this.http.get<any>(`${this.apiBaseUrl}/bizmatch/select-options`));
this.typesOfBusiness = allSelectOptions.typesOfBusiness;
this.prices = allSelectOptions.prices;
@ -23,6 +43,20 @@ export class SelectOptionsService {
this.typesOfCommercialProperty = allSelectOptions.typesOfCommercialProperty;
this.distances = allSelectOptions.distances;
this.sortByOptions = allSelectOptions.sortByOptions;
} catch (error) {
console.error('[SelectOptionsService] Failed to load select options:', error);
// Initialize with empty arrays as fallback
this.typesOfBusiness = this.typesOfBusiness || [];
this.prices = this.prices || [];
this.listingCategories = this.listingCategories || [];
this.customerTypes = this.customerTypes || [];
this.customerSubTypes = this.customerSubTypes || [];
this.states = this.states || [];
this.gender = this.gender || [];
this.typesOfCommercialProperty = this.typesOfCommercialProperty || [];
this.distances = this.distances || [];
this.sortByOptions = this.sortByOptions || [];
}
}
public typesOfBusiness: Array<KeyValueStyle>;

View File

@ -1,4 +1,5 @@
import { Injectable, inject } from '@angular/core';
import { Injectable, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { Meta, Title } from '@angular/platform-browser';
import { Router } from '@angular/router';
@ -19,6 +20,8 @@ export class SeoService {
private meta = inject(Meta);
private title = inject(Title);
private router = inject(Router);
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);
private readonly defaultImage = 'https://biz-match.com/assets/images/bizmatch-og-image.jpg';
private readonly siteName = 'BizMatch';
@ -109,6 +112,8 @@ export class SeoService {
* Update canonical URL
*/
private updateCanonicalUrl(url: string): void {
if (!this.isBrowser) return;
let link: HTMLLinkElement | null = document.querySelector('link[rel="canonical"]');
if (link) {
@ -267,6 +272,8 @@ export class SeoService {
* Inject JSON-LD structured data into page
*/
injectStructuredData(schema: object): void {
if (!this.isBrowser) return;
// Remove existing schema script
const existingScript = document.querySelector('script[type="application/ld+json"]');
if (existingScript) {
@ -284,6 +291,8 @@ export class SeoService {
* Clear all structured data
*/
clearStructuredData(): void {
if (!this.isBrowser) return;
const scripts = document.querySelectorAll('script[type="application/ld+json"]');
scripts.forEach(script => script.remove());
}
@ -480,6 +489,8 @@ export class SeoService {
* Inject multiple structured data schemas
*/
injectMultipleSchemas(schemas: object[]): void {
if (!this.isBrowser) return;
// Remove existing schema scripts
this.clearStructuredData();
@ -591,6 +602,8 @@ export class SeoService {
* Inject pagination link elements (rel="next" and rel="prev")
*/
injectPaginationLinks(baseUrl: string, currentPage: number, totalPages: number): void {
if (!this.isBrowser) return;
// Remove existing pagination links
document.querySelectorAll('link[rel="next"], link[rel="prev"]').forEach(link => link.remove());
@ -615,6 +628,8 @@ export class SeoService {
* Clear pagination links
*/
clearPaginationLinks(): void {
if (!this.isBrowser) return;
document.querySelectorAll('link[rel="next"], link[rel="prev"]').forEach(link => link.remove());
}
}

View File

@ -159,8 +159,10 @@ export function formatPhoneNumber(phone: string): string {
}
export const getSessionStorageHandler = function (criteriaType, path, value, previous, applyData) {
if (typeof sessionStorage !== 'undefined') {
sessionStorage.setItem(`${criteriaType}_criteria`, JSON.stringify(this));
console.log('Zusätzlicher Parameter:', criteriaType);
}
};
export const getSessionStorageHandlerWrapper = param => {
return function (path, value, previous, applyData) {
@ -191,6 +193,11 @@ export function map2User(jwt: string | null): KeycloakUser {
}
export function getImageDimensions(imageUrl: string): Promise<{ width: number; height: number }> {
return new Promise(resolve => {
// Only use Image in browser context
if (typeof Image === 'undefined') {
resolve({ width: 0, height: 0 });
return;
}
const img = new Image();
img.onload = () => {
resolve({ width: img.width, height: img.height });
@ -295,9 +302,11 @@ export function checkAndUpdate(changed: boolean, condition: boolean, assignment:
return changed || condition;
}
export function removeSortByStorage() {
if (typeof sessionStorage !== 'undefined') {
sessionStorage.removeItem('businessSortBy');
sessionStorage.removeItem('commercialSortBy');
sessionStorage.removeItem('professionalsSortBy');
}
}
// -----------------------------
// Criteria Proxy
@ -311,8 +320,11 @@ export function getCriteriaStateObject(criteriaType: 'businessListings' | 'comme
} else {
initialState = createEmptyUserListingCriteria();
}
if (typeof sessionStorage !== 'undefined') {
const storedState = sessionStorage.getItem(`${criteriaType}`);
return storedState ? JSON.parse(storedState) : initialState;
}
return initialState;
}
export function getCriteriaProxy(path: string, component: any): BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria {
if ('businessListings' === path) {
@ -327,7 +339,9 @@ export function getCriteriaProxy(path: string, component: any): BusinessListingC
}
export function createEnhancedProxy(obj: BusinessListingCriteria | CommercialPropertyListingCriteria | UserListingCriteria, component: any) {
const sessionStorageHandler = function (path, value, previous, applyData) {
if (typeof sessionStorage !== 'undefined') {
sessionStorage.setItem(`${obj.criteriaType}`, JSON.stringify(this));
}
};
return onChange(obj, function (path, value, previous, applyData) {
@ -341,16 +355,20 @@ export function createEnhancedProxy(obj: BusinessListingCriteria | CommercialPro
});
}
export function getCriteriaByListingCategory(listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty') {
if (typeof sessionStorage === 'undefined') return null;
const storedState =
listingsCategory === 'business'
? sessionStorage.getItem('businessListings')
: listingsCategory === 'commercialProperty'
? sessionStorage.getItem('commercialPropertyListings')
: sessionStorage.getItem('brokerListings');
return JSON.parse(storedState);
return storedState ? JSON.parse(storedState) : null;
}
export function getSortByListingCategory(listingsCategory: 'business' | 'professionals_brokers' | 'commercialProperty') {
if (typeof sessionStorage === 'undefined') return null;
const storedSortBy =
listingsCategory === 'business' ? sessionStorage.getItem('businessSortBy') : listingsCategory === 'commercialProperty' ? sessionStorage.getItem('commercialSortBy') : sessionStorage.getItem('professionalsSortBy');
const sortBy = storedSortBy && storedSortBy !== 'null' ? (storedSortBy as SortByOptions) : null;

View File

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

View File

@ -1,10 +1,20 @@
// IMPORTANT: DOM polyfill must be imported FIRST, before any browser-dependent libraries
import './ssr-dom-polyfill';
import { bootstrapApplication } from '@angular/platform-browser';
import { bootstrapApplication, BootstrapContext } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { config } from './app/app.config.server';
const bootstrap = () => bootstrapApplication(AppComponent, config);
const bootstrap = (context: BootstrapContext) => {
console.log('[SSR] Bootstrap function called');
const appRef = bootstrapApplication(AppComponent, config, context);
appRef.then(() => {
console.log('[SSR] Application bootstrapped successfully');
}).catch((err) => {
console.error('[SSR] Bootstrap error:', err);
console.error('[SSR] Error stack:', err.stack);
});
return appRef;
};
export default bootstrap;