30 Commits

Author SHA1 Message Date
fca746cef6 schema 2026-02-07 16:02:52 +01:00
79098f59c6 asdas 2026-02-06 19:25:51 -06:00
345761da87 remove quill 2026-02-06 19:25:45 -06:00
7e00b4d71b xcvxcv 2026-02-06 19:22:14 -06:00
715220f6d5 quill activated 2026-02-06 19:18:26 -06:00
dc79ac3df7 comment quill 2026-02-06 19:13:16 -06:00
a545b84f6c variable 2026-02-06 18:57:41 -06:00
2d293d8b12 fix 2026-02-06 15:06:54 -06:00
d008b50892 changes 2026-02-06 14:46:12 -06:00
1a1eaa46ae add css 2026-02-06 13:31:04 -06:00
a9dcb66e5b hashing 2026-02-06 13:25:51 -06:00
33ea71dc12 update 2026-02-06 12:28:30 -06:00
91bcf3c2ed test 2026-02-06 12:11:38 -06:00
36ef7eb4bf access to whole repo 2026-02-06 11:32:35 -06:00
ae12eb87f0 neuer build 2026-02-06 11:21:39 -06:00
0b4e4207d1 change sentences 2026-02-06 10:10:18 -06:00
53537226cd SEO 2026-02-06 12:59:47 +01:00
00597a796a app.use('/pictures', express.static('pictures')); 2026-02-05 17:54:49 -06:00
8b3c79b5ff budgets 2026-02-05 17:27:57 -06:00
a7d3d2d958 revert 2026-02-05 17:24:12 -06:00
49528a5c37 revert 2026-02-05 17:18:28 -06:00
047c723364 commented app.use('/pictures', express.static('pictures')); 2026-02-05 16:42:29 -06:00
39c93e7178 Sitemap 2026-02-05 13:09:25 +01:00
6f1109d593 Schema.org 2026-02-05 12:49:09 +01:00
70a50e0ff6 faq 2026-02-04 21:45:52 +01:00
23f7caedeb feat: Add comprehensive user authentication, listing management, and core UI components. 2026-02-04 21:32:25 +01:00
737329794c perf: Lighthouse optimizations - lazy loading, contrast fixes, LCP preload, SEO links 2026-02-04 15:47:40 +01:00
Timo Knuth
ff7ef0f423 Add Google site verification file 2026-02-04 11:01:54 +01:00
Timo Knuth
e25722d806 fix: SEO meta tags and H1 headings optimization
- Shortened meta titles for better SERP display (businessListings, commercialPropertyListings)
- Optimized meta descriptions to fit within 160 characters (3 pages)
- Enhanced H1 headings with descriptive, keyword-rich text (3 pages)
- Addresses Seobility recommendations for improved search visibility

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 20:19:30 +01:00
Timo Knuth
bf735ed60f feat: SEO improvements and image optimization
- Enhanced SEO service with meta tags and structured data
- Updated sitemap service and robots.txt
- Optimized listing components for better SEO
- Compressed images (saved ~31MB total)
- Added .gitattributes to enforce LF line endings

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 19:48:30 +01:00
86 changed files with 4553 additions and 4159 deletions

View File

@@ -26,7 +26,9 @@
"Bash(ls:*)", "Bash(ls:*)",
"WebFetch(domain:angular.dev)", "WebFetch(domain:angular.dev)",
"Bash(killall:*)", "Bash(killall:*)",
"Bash(echo:*)" "Bash(echo:*)",
"Bash(npm run build:*)",
"Bash(npx tsc:*)"
] ]
} }
} }

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
.git
.idea
.vscode
dist
coverage

4
.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.jpeg binary

View File

@@ -1,19 +1,25 @@
# Build Stage # --- STAGE 1: Build ---
FROM node:18-alpine AS build FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app
# HIER KEIN NODE_ENV=production setzen! Wir brauchen devDependencies zum Bauen.
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
# Runtime Stage # --- STAGE 2: Runtime ---
FROM node:18-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
COPY --from=build /app/dist /app/dist
COPY --from=build /app/package*.json /app/
RUN npm install --production # HIER ist es richtig!
ENV NODE_ENV=production
CMD ["node", "dist/main.js"] COPY --from=builder /app/dist /app/dist
COPY --from=builder /app/package*.json /app/
# Installiert nur "dependencies" (Nest core, TypeORM, Helmet, Sharp etc.)
# "devDependencies" (TypeScript, Jest, ESLint) werden weggelassen.
RUN npm ci --omit=dev
# WICHTIG: Pfad prüfen (siehe Punkt 2 unten)
CMD ["node", "dist/src/main.js"]

View File

@@ -1,48 +0,0 @@
services:
app:
image: node:22-alpine
container_name: bizmatch-app
working_dir: /app
volumes:
- ./:/app
- node_modules:/app/node_modules
ports:
- '3001:3001'
env_file:
- .env
environment:
- NODE_ENV=development
- DATABASE_URL
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
networks:
- bizmatch
postgres:
container_name: bizmatchdb
image: postgres:17-alpine
restart: unless-stopped
volumes:
- bizmatch-db-data:/var/lib/postgresql/data
env_file:
- .env
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- '5434:5432'
networks:
- bizmatch
volumes:
bizmatch-db-data:
driver: local
node_modules:
driver: local
networks:
bizmatch:
external: true

View File

@@ -47,6 +47,7 @@
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"groq-sdk": "^0.5.0", "groq-sdk": "^0.5.0",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"helmet": "^8.1.0",
"nest-winston": "^1.9.4", "nest-winston": "^1.9.4",
"nestjs-cls": "^5.4.0", "nestjs-cls": "^5.4.0",
"nodemailer": "^7.0.12", "nodemailer": "^7.0.12",

View File

@@ -1,6 +1,7 @@
import { LoggerService } from '@nestjs/common'; import { LoggerService } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import express from 'express'; import express from 'express';
import helmet from 'helmet';
import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
@@ -22,6 +23,37 @@ async function bootstrap() {
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
allowedHeaders: 'Content-Type, Accept, Authorization, x-hide-loading', allowedHeaders: 'Content-Type, Accept, Authorization, x-hide-loading',
}); });
// Security Headers with helmet
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "https://fonts.googleapis.com"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
imgSrc: ["'self'", "data:", "https:", "blob:"],
connectSrc: ["'self'", "https://api.bizmatch.net", "https://*.firebaseapp.com", "https://*.googleapis.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'self'"],
},
},
crossOriginEmbedderPolicy: false, // Disable for now to avoid breaking existing functionality
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
frameguard: {
action: 'sameorigin', // Allow same-origin framing
},
crossOriginOpenerPolicy: { policy: 'same-origin-allow-popups' }, // Allow popups for OAuth
crossOriginResourcePolicy: { policy: 'cross-origin' }, // Allow cross-origin resources
}),
);
await app.listen(process.env.PORT || 3001); await app.listen(process.env.PORT || 3001);
} }
bootstrap(); bootstrap();

View File

@@ -18,7 +18,7 @@ interface SitemapIndexEntry {
@Injectable() @Injectable()
export class SitemapService { export class SitemapService {
private readonly baseUrl = 'https://biz-match.com'; private readonly baseUrl = 'https://www.bizmatch.net';
private readonly URLS_PER_SITEMAP = 10000; // Google best practice private readonly URLS_PER_SITEMAP = 10000; // Google best practice
constructor(@Inject(PG_CONNECTION) private readonly db: NodePgDatabase<typeof schema>) { } constructor(@Inject(PG_CONNECTION) private readonly db: NodePgDatabase<typeof schema>) { }

View File

@@ -1,13 +1,41 @@
# STAGE 1: Build
FROM node:22-alpine AS builder
# Wir erstellen ein Arbeitsverzeichnis, das eine Ebene über dem Projekt liegt
WORKDIR /usr/src/app
# 1. Wir kopieren die Backend-Models an die Stelle, wo Angular sie erwartet
# Deine Pfade suchen nach ../bizmatch-server, also legen wir es daneben.
COPY bizmatch-server/src/models ./bizmatch-server/src/models
# 2. Jetzt kümmern wir uns um das Frontend
# Wir kopieren erst die package Files für besseres Caching
COPY bizmatch/package*.json ./bizmatch/
# Wechseln in den Frontend Ordner zum Installieren
WORKDIR /usr/src/app/bizmatch
RUN npm ci
# 3. Den Rest des Frontends kopieren
COPY bizmatch/ .
# 4. Bauen
RUN npm run build:ssr
# --- STAGE 2: Runtime ---
FROM node:22-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
# GANZEN dist-Ordner kopieren, nicht nur bizmatch ENV NODE_ENV=production
COPY dist ./dist ENV PORT=4000
COPY package*.json ./
# Kopiere das Ergebnis aus dem Builder (Pfad beachten!)
COPY --from=builder /usr/src/app/bizmatch/dist /app/dist
COPY --from=builder /usr/src/app/bizmatch/package*.json /app/
RUN npm ci --omit=dev RUN npm ci --omit=dev
EXPOSE 4200 EXPOSE 4000
CMD ["node", "dist/bizmatch/server/server.mjs"] CMD ["node", "dist/bizmatch/server/server.mjs"]

View File

@@ -69,8 +69,8 @@
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
"maximumWarning": "2kb", "maximumWarning": "30kb",
"maximumError": "4kb" "maximumError": "30kb"
} }
], ],
"outputHashing": "all" "outputHashing": "all"
@@ -101,7 +101,8 @@
], ],
"optimization": true, "optimization": true,
"extractLicenses": false, "extractLicenses": false,
"sourceMap": true "sourceMap": true,
"outputHashing": "all"
} }
}, },
"defaultConfiguration": "production" "defaultConfiguration": "production"

View File

@@ -1,10 +0,0 @@
services:
bizmatch-ssr:
build: .
image: bizmatch-ssr
container_name: bizmatch-ssr
restart: unless-stopped
ports:
- '4200:4200'
environment:
NODE_ENV: DEVELOPMENT

View File

@@ -0,0 +1 @@
google-site-verification: googleccd5315437d68a49.html

View File

@@ -26,6 +26,74 @@ export async function app(): Promise<express.Express> {
server.set('view engine', 'html'); server.set('view engine', 'html');
server.set('views', browserDistFolder); server.set('views', browserDistFolder);
// Sitemap XML endpoints - MUST be before static files middleware
server.get('/sitemap.xml', async (req, res) => {
try {
const sitemapIndexXml = `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap>
<loc>https://www.bizmatch.net/sitemap-static.xml</loc>
<lastmod>${new Date().toISOString().split('T')[0]}</lastmod>
</sitemap>
</sitemapindex>`;
res.header('Content-Type', 'application/xml; charset=utf-8');
res.send(sitemapIndexXml);
} catch (error) {
console.error('[SSR] Error generating sitemap index:', error);
res.status(500).send('Error generating sitemap');
}
});
server.get('/sitemap-static.xml', async (req, res) => {
try {
const sitemapXml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://www.bizmatch.net/</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://www.bizmatch.net/home</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://www.bizmatch.net/businessListings</loc>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://www.bizmatch.net/commercialPropertyListings</loc>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://www.bizmatch.net/brokerListings</loc>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://www.bizmatch.net/terms-of-use</loc>
<changefreq>monthly</changefreq>
<priority>0.5</priority>
</url>
<url>
<loc>https://www.bizmatch.net/privacy-statement</loc>
<changefreq>monthly</changefreq>
<priority>0.5</priority>
</url>
</urlset>`;
res.header('Content-Type', 'application/xml; charset=utf-8');
res.send(sitemapXml);
} catch (error) {
console.error('[SSR] Error generating static sitemap:', error);
res.status(500).send('Error generating sitemap');
}
});
// Example Express Rest API endpoints // Example Express Rest API endpoints
// server.get('/api/**', (req, res) => { }); // server.get('/api/**', (req, res) => { });
// Serve static files from /browser // Serve static files from /browser
@@ -36,6 +104,8 @@ export async function app(): Promise<express.Express> {
// All regular routes use the Angular engine // All regular routes use the Angular engine
server.get('*', async (req, res, next) => { server.get('*', async (req, res, next) => {
console.log(`[SSR] Handling request: ${req.method} ${req.url}`); console.log(`[SSR] Handling request: ${req.method} ${req.url}`);
// Cache SSR-rendered pages at CDN level
res.setHeader('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=600');
try { try {
const response = await angularApp.handle(req); const response = await angularApp.handle(req);
if (response) { if (response) {

View File

@@ -1,8 +1,24 @@
import { RenderMode, ServerRoute } from '@angular/ssr'; import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [ export const serverRoutes: ServerRoute[] = [
{ { path: 'home', renderMode: RenderMode.Server }, // Das hatten wir vorhin gefixt
path: '**',
renderMode: RenderMode.Server // WICHTIG: Alle geschützten Routen nur im Browser rendern!
} // Damit überspringt der Server den AuthGuard Check komplett und schickt
// nur eine leere Hülle (index.html), die der Browser dann füllt.
{ path: 'account', renderMode: RenderMode.Client },
{ path: 'account/**', renderMode: RenderMode.Client },
{ path: 'myListings', renderMode: RenderMode.Client },
{ path: 'myFavorites', renderMode: RenderMode.Client },
{ path: 'createBusinessListing', renderMode: RenderMode.Client },
{ path: 'createCommercialPropertyListing', renderMode: RenderMode.Client },
{ path: 'editBusinessListing/**', renderMode: RenderMode.Client },
{ path: 'editCommercialPropertyListing/**', renderMode: RenderMode.Client },
// Statische Seiten
{ path: 'terms-of-use', renderMode: RenderMode.Prerender },
{ path: 'privacy-statement', renderMode: RenderMode.Prerender },
// Fallback
{ path: '**', renderMode: RenderMode.Server }
]; ];

View File

@@ -1,31 +1,25 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
// Core components (eagerly loaded - needed for initial navigation)
import { LogoutComponent } from './components/logout/logout.component'; import { LogoutComponent } from './components/logout/logout.component';
import { NotFoundComponent } from './components/not-found/not-found.component'; import { NotFoundComponent } from './components/not-found/not-found.component';
import { TestSsrComponent } from './components/test-ssr/test-ssr.component'; import { TestSsrComponent } from './components/test-ssr/test-ssr.component';
import { EmailAuthorizedComponent } from './components/email-authorized/email-authorized.component'; import { EmailAuthorizedComponent } from './components/email-authorized/email-authorized.component';
import { EmailVerificationComponent } from './components/email-verification/email-verification.component'; import { EmailVerificationComponent } from './components/email-verification/email-verification.component';
import { LoginRegisterComponent } from './components/login-register/login-register.component'; import { LoginRegisterComponent } from './components/login-register/login-register.component';
// Guards
import { AuthGuard } from './guards/auth.guard'; import { AuthGuard } from './guards/auth.guard';
import { ListingCategoryGuard } from './guards/listing-category.guard'; import { ListingCategoryGuard } from './guards/listing-category.guard';
import { UserListComponent } from './pages/admin/user-list/user-list.component';
import { DetailsBusinessListingComponent } from './pages/details/details-business-listing/details-business-listing.component'; // Public pages - HomeComponent stays eagerly loaded as landing page
import { DetailsCommercialPropertyListingComponent } from './pages/details/details-commercial-property-listing/details-commercial-property-listing.component';
import { DetailsUserComponent } from './pages/details/details-user/details-user.component';
import { HomeComponent } from './pages/home/home.component'; import { HomeComponent } from './pages/home/home.component';
import { BrokerListingsComponent } from './pages/listings/broker-listings/broker-listings.component';
import { BusinessListingsComponent } from './pages/listings/business-listings/business-listings.component';
import { CommercialPropertyListingsComponent } from './pages/listings/commercial-property-listings/commercial-property-listings.component';
import { AccountComponent } from './pages/subscription/account/account.component';
import { EditBusinessListingComponent } from './pages/subscription/edit-business-listing/edit-business-listing.component';
import { EditCommercialPropertyListingComponent } from './pages/subscription/edit-commercial-property-listing/edit-commercial-property-listing.component';
import { EmailUsComponent } from './pages/subscription/email-us/email-us.component';
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 { SuccessComponent } from './pages/success/success.component';
import { TermsOfUseComponent } from './pages/legal/terms-of-use.component'; import { TermsOfUseComponent } from './pages/legal/terms-of-use.component';
import { PrivacyStatementComponent } from './pages/legal/privacy-statement.component'; import { PrivacyStatementComponent } from './pages/legal/privacy-statement.component';
// Note: All listing and details components are now lazy-loaded for better initial bundle size
export const routes: Routes = [ export const routes: Routes = [
{ {
path: 'test-ssr', path: 'test-ssr',
@@ -33,17 +27,17 @@ export const routes: Routes = [
}, },
{ {
path: 'businessListings', path: 'businessListings',
component: BusinessListingsComponent, loadComponent: () => import('./pages/listings/business-listings/business-listings.component').then(m => m.BusinessListingsComponent),
runGuardsAndResolvers: 'always', runGuardsAndResolvers: 'always',
}, },
{ {
path: 'commercialPropertyListings', path: 'commercialPropertyListings',
component: CommercialPropertyListingsComponent, loadComponent: () => import('./pages/listings/commercial-property-listings/commercial-property-listings.component').then(m => m.CommercialPropertyListingsComponent),
runGuardsAndResolvers: 'always', runGuardsAndResolvers: 'always',
}, },
{ {
path: 'brokerListings', path: 'brokerListings',
component: BrokerListingsComponent, loadComponent: () => import('./pages/listings/broker-listings/broker-listings.component').then(m => m.BrokerListingsComponent),
runGuardsAndResolvers: 'always', runGuardsAndResolvers: 'always',
}, },
{ {
@@ -54,11 +48,11 @@ export const routes: Routes = [
// Listings Details - New SEO-friendly slug-based URLs // Listings Details - New SEO-friendly slug-based URLs
{ {
path: 'business/:slug', path: 'business/:slug',
component: DetailsBusinessListingComponent, loadComponent: () => import('./pages/details/details-business-listing/details-business-listing.component').then(m => m.DetailsBusinessListingComponent),
}, },
{ {
path: 'commercial-property/:slug', path: 'commercial-property/:slug',
component: DetailsCommercialPropertyListingComponent, loadComponent: () => import('./pages/details/details-commercial-property-listing/details-commercial-property-listing.component').then(m => m.DetailsCommercialPropertyListingComponent),
}, },
// Backward compatibility redirects for old UUID-based URLs // Backward compatibility redirects for old UUID-based URLs
{ {
@@ -96,61 +90,61 @@ export const routes: Routes = [
// User Details // User Details
{ {
path: 'details-user/:id', path: 'details-user/:id',
component: DetailsUserComponent, loadComponent: () => import('./pages/details/details-user/details-user.component').then(m => m.DetailsUserComponent),
}, },
// ######### // #########
// User edit // User edit (lazy-loaded)
{ {
path: 'account', path: 'account',
component: AccountComponent, loadComponent: () => import('./pages/subscription/account/account.component').then(m => m.AccountComponent),
canActivate: [AuthGuard], canActivate: [AuthGuard],
}, },
{ {
path: 'account/:id', path: 'account/:id',
component: AccountComponent, loadComponent: () => import('./pages/subscription/account/account.component').then(m => m.AccountComponent),
canActivate: [AuthGuard], canActivate: [AuthGuard],
}, },
// ######### // #########
// Create, Update Listings // Create, Update Listings (lazy-loaded)
{ {
path: 'editBusinessListing/:id', path: 'editBusinessListing/:id',
component: EditBusinessListingComponent, loadComponent: () => import('./pages/subscription/edit-business-listing/edit-business-listing.component').then(m => m.EditBusinessListingComponent),
canActivate: [AuthGuard], canActivate: [AuthGuard],
}, },
{ {
path: 'createBusinessListing', path: 'createBusinessListing',
component: EditBusinessListingComponent, loadComponent: () => import('./pages/subscription/edit-business-listing/edit-business-listing.component').then(m => m.EditBusinessListingComponent),
canActivate: [AuthGuard], canActivate: [AuthGuard],
}, },
{ {
path: 'editCommercialPropertyListing/:id', path: 'editCommercialPropertyListing/:id',
component: EditCommercialPropertyListingComponent, loadComponent: () => import('./pages/subscription/edit-commercial-property-listing/edit-commercial-property-listing.component').then(m => m.EditCommercialPropertyListingComponent),
canActivate: [AuthGuard], canActivate: [AuthGuard],
}, },
{ {
path: 'createCommercialPropertyListing', path: 'createCommercialPropertyListing',
component: EditCommercialPropertyListingComponent, loadComponent: () => import('./pages/subscription/edit-commercial-property-listing/edit-commercial-property-listing.component').then(m => m.EditCommercialPropertyListingComponent),
canActivate: [AuthGuard], canActivate: [AuthGuard],
}, },
// ######### // #########
// My Listings // My Listings (lazy-loaded)
{ {
path: 'myListings', path: 'myListings',
component: MyListingComponent, loadComponent: () => import('./pages/subscription/my-listing/my-listing.component').then(m => m.MyListingComponent),
canActivate: [AuthGuard], canActivate: [AuthGuard],
}, },
// ######### // #########
// My Favorites // My Favorites (lazy-loaded)
{ {
path: 'myFavorites', path: 'myFavorites',
component: FavoritesComponent, loadComponent: () => import('./pages/subscription/favorites/favorites.component').then(m => m.FavoritesComponent),
canActivate: [AuthGuard], canActivate: [AuthGuard],
}, },
// ######### // #########
// EMAil Us // Email Us (lazy-loaded)
{ {
path: 'emailUs', path: 'emailUs',
component: EmailUsComponent, loadComponent: () => import('./pages/subscription/email-us/email-us.component').then(m => m.EmailUsComponent),
// canActivate: [AuthGuard], // canActivate: [AuthGuard],
}, },
// ######### // #########
@@ -174,9 +168,11 @@ export const routes: Routes = [
path: 'success', path: 'success',
component: SuccessComponent, component: SuccessComponent,
}, },
// #########
// Admin Pages (lazy-loaded)
{ {
path: 'admin/users', path: 'admin/users',
component: UserListComponent, loadComponent: () => import('./pages/admin/user-list/user-list.component').then(m => m.UserListComponent),
canActivate: [AuthGuard], canActivate: [AuthGuard],
}, },
// ######### // #########

View File

@@ -13,7 +13,7 @@ import { ConfirmationService } from './confirmation.service';
<button <button
(click)="confirmationService.reject()" (click)="confirmationService.reject()"
type="button" type="button"
class="absolute top-3 end-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white" class="absolute top-3 end-2.5 text-gray-600 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
> >
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14"> <svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" /> <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
@@ -21,11 +21,11 @@ import { ConfirmationService } from './confirmation.service';
<span class="sr-only">Close modal</span> <span class="sr-only">Close modal</span>
</button> </button>
<div class="p-4 md:p-5 text-center"> <div class="p-4 md:p-5 text-center">
<svg class="mx-auto mb-4 text-gray-400 w-12 h-12 dark:text-gray-200" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20"> <svg class="mx-auto mb-4 text-gray-600 w-12 h-12 dark:text-gray-200" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 11V6m0 8h.01M19 10a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /> <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 11V6m0 8h.01M19 10a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg> </svg>
@let confirmation = (confirmationService.confirmation$ | async); @let confirmation = (confirmationService.confirmation$ | async);
<h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-400">{{ confirmation?.message }}</h3> <h3 class="mb-5 text-lg font-normal text-gray-500 dark:text-gray-600">{{ confirmation?.message }}</h3>
@if(confirmation?.buttons==='both'){ @if(confirmation?.buttons==='both'){
<button <button
(click)="confirmationService.accept()" (click)="confirmationService.accept()"
@@ -37,7 +37,7 @@ import { ConfirmationService } from './confirmation.service';
<button <button
(click)="confirmationService.reject()" (click)="confirmationService.reject()"
type="button" type="button"
class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700" class="py-2.5 px-5 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-lg border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-600 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
> >
No, cancel No, cancel
</button> </button>

View File

@@ -9,7 +9,7 @@
<button <button
(click)="eMailService.reject()" (click)="eMailService.reject()"
type="button" type="button"
class="end-2.5 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white" class="end-2.5 text-gray-600 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
> >
<svg class="w-3 h-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14"> <svg class="w-3 h-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" /> <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />

View File

@@ -3,7 +3,7 @@
<div class="flex flex-col lg:flex-row items-center mb-4 lg:mb-0"> <div class="flex flex-col lg:flex-row items-center mb-4 lg:mb-0">
<!-- <img src="/assets/images/header-logo.png" alt="BizMatch Logo" class="h-8 mb-2 lg:mb-0 lg:mr-4" /> --> <!-- <img src="/assets/images/header-logo.png" alt="BizMatch Logo" class="h-8 mb-2 lg:mb-0 lg:mr-4" /> -->
<a routerLink="/home" class="flex items-center space-x-3 rtl:space-x-reverse"> <a routerLink="/home" class="flex items-center space-x-3 rtl:space-x-reverse">
<img src="/assets/images/header-logo.png" class="h-8 mb-2 lg:mb-0 lg:mr-4" alt="BizMatch Logo" /> <img src="/assets/images/header-logo.png" class="h-8 mb-2 lg:mb-0 lg:mr-4" alt="BizMatch Logo" width="120" height="32" />
</a> </a>
<p class="text-sm text-neutral-600 text-center lg:text-left">© {{ currentYear }} Bizmatch All rights reserved.</p> <p class="text-sm text-neutral-600 text-center lg:text-left">© {{ currentYear }} Bizmatch All rights reserved.</p>
</div> </div>
@@ -18,14 +18,13 @@
<div class="flex flex-col lg:flex-row items-center order-2 lg:order-3"> <div class="flex flex-col lg:flex-row items-center order-2 lg:order-3">
<div class="mb-4 lg:mb-0 lg:mr-6 text-center lg:text-right"> <div class="mb-4 lg:mb-0 lg:mr-6 text-center lg:text-right">
<p class="text-sm text-neutral-600 mb-1 lg:mb-2">BizMatch, Inc., 1001 Blucher Street, Corpus</p> <p class="text-sm text-neutral-600 mb-1 lg:mb-2">Contact: BizMatch, Inc.</p>
<p class="text-sm text-neutral-600">Christi, Texas 78401</p>
</div> </div>
<div class="mb-4 lg:mb-0 flex flex-col items-center lg:items-end"> <div class="mb-4 lg:mb-0 flex flex-col items-center lg:items-end">
<a class="text-sm text-neutral-600 mb-1 lg:mb-2 hover:text-primary-600 w-full"> <i <a href="tel:+1-800-840-6025" class="text-sm text-neutral-600 mb-1 lg:mb-2 hover:text-primary-600 w-full"> <i
class="fas fa-phone-alt mr-2"></i>1-800-840-6025 </a> class="fas fa-phone-alt mr-2"></i>1-800-840-6025 </a>
<a class="text-sm text-neutral-600 hover:text-primary-600"> <i <a href="mailto:info@bizmatch.net" class="text-sm text-neutral-600 hover:text-primary-600"> <i
class="fas fa-envelope mr-2"></i>info&#64;bizmatch.net </a> class="fas fa-envelope mr-2"></i>info&#64;bizmatch.net </a>
</div> </div>
</div> </div>

View File

@@ -2,14 +2,14 @@
<div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4"> <div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4">
<a routerLink="/home" class="flex items-center space-x-3 rtl:space-x-reverse"> <a routerLink="/home" class="flex items-center space-x-3 rtl:space-x-reverse">
<img src="/assets/images/header-logo.png" class="h-10 w-auto" <img src="/assets/images/header-logo.png" class="h-10 w-auto"
alt="BizMatch - Business Marketplace for Buying and Selling Businesses" /> alt="BizMatch - Business Marketplace for Buying and Selling Businesses" width="150" height="40" />
</a> </a>
<div class="flex items-center md:order-2 space-x-3 rtl:space-x-reverse"> <div class="flex items-center md:order-2 space-x-3 rtl:space-x-reverse">
<!-- Filter button --> <!-- Filter button -->
@if(isFilterUrl()){ @if(isFilterUrl()){
<div class="relative"> <div class="relative">
<button type="button" id="sortDropdownButton" <button type="button" id="sortDropdownButton" aria-label="Sort listings" aria-haspopup="listbox"
class="max-sm:hidden px-4 py-2 text-sm font-medium bg-white border border-neutral-200 rounded-lg hover:bg-neutral-100 hover:text-primary-600 dark:bg-neutral-800 dark:text-neutral-400 dark:border-neutral-600 dark:hover:text-white dark:hover:bg-neutral-700" class="max-sm:hidden px-4 py-2 text-sm font-medium bg-white border border-neutral-200 rounded-lg hover:bg-neutral-100 hover:text-primary-600 dark:bg-neutral-800 dark:text-neutral-400 dark:border-neutral-600 dark:hover:text-white dark:hover:bg-neutral-700"
(click)="toggleSortDropdown()" (click)="toggleSortDropdown()"
[ngClass]="{ 'text-primary-500': selectOptions.getSortByOption(sortBy) !== 'Sort', 'text-neutral-900': selectOptions.getSortByOption(sortBy) === 'Sort' }"> [ngClass]="{ 'text-primary-500': selectOptions.getSortByOption(sortBy) !== 'Sort', 'text-neutral-900': selectOptions.getSortByOption(sortBy) === 'Sort' }">
@@ -192,7 +192,7 @@
class="inline-flex items-center py-2 px-3 rounded hover:bg-neutral-100 md:hover:bg-transparent md:hover:text-primary-600 md:p-0 dark:text-white md:dark:hover:text-primary-500 dark:hover:bg-neutral-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-neutral-700" class="inline-flex items-center py-2 px-3 rounded hover:bg-neutral-100 md:hover:bg-transparent md:hover:text-primary-600 md:p-0 dark:text-white md:dark:hover:text-primary-500 dark:hover:bg-neutral-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-neutral-700"
(click)="closeMenusAndSetCriteria('brokerListings')"> (click)="closeMenusAndSetCriteria('brokerListings')">
<img src="/assets/images/icon_professionals.png" alt="Professionals" <img src="/assets/images/icon_professionals.png" alt="Professionals"
class="w-5 h-5 mr-2 object-contain bg-transparent" style="mix-blend-mode: darken;" /> class="w-5 h-5 mr-2 object-contain bg-transparent" style="mix-blend-mode: darken;" width="20" height="20" />
<span>Professionals</span> <span>Professionals</span>
</a> </a>
</li> </li>
@@ -201,7 +201,7 @@
</div> </div>
<!-- Mobile filter button --> <!-- Mobile filter button -->
<div class="md:hidden flex justify-center pb-4"> <div class="md:hidden flex justify-center pb-4">
<button (click)="toggleSortDropdown()" type="button" id="sortDropdownMobileButton" <button (click)="toggleSortDropdown()" type="button" id="sortDropdownMobileButton" aria-label="Sort listings" aria-haspopup="listbox"
class="mx-4 w-1/2 px-4 py-2 text-sm font-medium bg-white border border-neutral-200 rounded-lg hover:bg-neutral-100 hover:text-primary-600 focus:ring-2 focus:ring-primary-600 focus:text-primary-600 dark:bg-neutral-800 dark:text-neutral-400 dark:border-neutral-600 dark:hover:text-white dark:hover:bg-neutral-700" class="mx-4 w-1/2 px-4 py-2 text-sm font-medium bg-white border border-neutral-200 rounded-lg hover:bg-neutral-100 hover:text-primary-600 focus:ring-2 focus:ring-primary-600 focus:text-primary-600 dark:bg-neutral-800 dark:text-neutral-400 dark:border-neutral-600 dark:hover:text-white dark:hover:bg-neutral-700"
[ngClass]="{ 'text-primary-500': selectOptions.getSortByOption(sortBy) !== 'Sort', 'text-neutral-900': selectOptions.getSortByOption(sortBy) === 'Sort' }"> [ngClass]="{ 'text-primary-500': selectOptions.getSortByOption(sortBy) !== 'Sort', 'text-neutral-900': selectOptions.getSortByOption(sortBy) === 'Sort' }">
<i class="fas fa-sort mr-2"></i>{{ selectOptions.getSortByOption(sortBy) }} <i class="fas fa-sort mr-2"></i>{{ selectOptions.getSortByOption(sortBy) }}

View File

@@ -2,7 +2,7 @@ import { CommonModule, isPlatformBrowser } from '@angular/common';
import { Component, HostListener, OnDestroy, OnInit, AfterViewInit, PLATFORM_ID, inject } from '@angular/core'; import { Component, HostListener, OnDestroy, OnInit, AfterViewInit, PLATFORM_ID, inject } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { NavigationEnd, Router, RouterModule } from '@angular/router'; import { NavigationEnd, Router, RouterModule } from '@angular/router';
import { faUserGear } from '@fortawesome/free-solid-svg-icons'; import { APP_ICONS } from '../../utils/fontawesome-icons';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { Collapse, Dropdown, initFlowbite } from 'flowbite'; import { Collapse, Dropdown, initFlowbite } from 'flowbite';
import { filter, Observable, Subject, takeUntil } from 'rxjs'; import { filter, Observable, Subject, takeUntil } from 'rxjs';
@@ -35,7 +35,7 @@ export class HeaderComponent implements OnInit, OnDestroy, AfterViewInit {
keycloakUser: KeycloakUser; keycloakUser: KeycloakUser;
user: User; user: User;
activeItem; activeItem;
faUserGear = faUserGear; faUserGear = APP_ICONS.faUserGear;
profileUrl: string; profileUrl: string;
env = environment; env = environment;
private filterDropdown: Dropdown | null = null; private filterDropdown: Dropdown | null = null;

View File

@@ -35,7 +35,7 @@
placeholder="Please enter E-Mail Address" placeholder="Please enter E-Mail Address"
class="w-full px-3 py-2 pl-10 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" class="w-full px-3 py-2 pl-10 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/> />
<fa-icon [icon]="envelope" class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></fa-icon> <fa-icon [icon]="envelope" class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-600"></fa-icon>
</div> </div>
</div> </div>
@@ -50,7 +50,7 @@
placeholder="Please enter Password" placeholder="Please enter Password"
class="w-full px-3 py-2 pl-10 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" class="w-full px-3 py-2 pl-10 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/> />
<fa-icon [icon]="lock" class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></fa-icon> <fa-icon [icon]="lock" class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-600"></fa-icon>
</div> </div>
</div> </div>
@@ -65,7 +65,7 @@
placeholder="Repeat Password" placeholder="Repeat Password"
class="w-full px-3 py-2 pl-10 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" class="w-full px-3 py-2 pl-10 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/> />
<fa-icon [icon]="lock" class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></fa-icon> <fa-icon [icon]="lock" class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-600"></fa-icon>
</div> </div>
</div> </div>

View File

@@ -3,7 +3,7 @@ import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { faArrowRight, faEnvelope, faLock, faUserPlus } from '@fortawesome/free-solid-svg-icons'; import { APP_ICONS } from '../../utils/fontawesome-icons';
import { AuthService } from '../../services/auth.service'; import { AuthService } from '../../services/auth.service';
import { LoadingService } from '../../services/loading.service'; import { LoadingService } from '../../services/loading.service';
@Component({ @Component({
@@ -18,10 +18,10 @@ export class LoginRegisterComponent {
confirmPassword: string = ''; confirmPassword: string = '';
isLoginMode: boolean = true; // true: Login, false: Registration isLoginMode: boolean = true; // true: Login, false: Registration
errorMessage: string = ''; errorMessage: string = '';
envelope = faEnvelope; envelope = APP_ICONS.faEnvelope;
lock = faLock; lock = APP_ICONS.faLock;
arrowRight = faArrowRight; arrowRight = APP_ICONS.faArrowRight;
userplus = faUserPlus; userplus = APP_ICONS.faUserPlus;
constructor(private authService: AuthService, private route: ActivatedRoute, private router: Router, private loadingService: LoadingService) {} constructor(private authService: AuthService, private route: ActivatedRoute, private router: Router, private loadingService: LoadingService) {}
ngOnInit(): void { ngOnInit(): void {

View File

@@ -16,7 +16,7 @@
<div class="p-6 space-y-6"> <div class="p-6 space-y-6">
<div class="flex space-x-4 mb-4"> <div class="flex space-x-4 mb-4">
<button class="text-primary-600 font-medium border-b-2 border-primary-600 pb-2">Filter ({{ numberOfResults$ | async }})</button> <button class="text-primary-600 font-medium border-b-2 border-primary-600 pb-2">Filter ({{ numberOfResults$ | async }})</button>
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-primary-500" (click)="clearFilter()"></i> <button type="button" aria-label="Clear all filters" data-tooltip-target="tooltip-light" class="flex self-center ml-2 cursor-pointer text-primary-500 p-1" (click)="clearFilter()"><i class="fa-solid fa-trash-can"></i></button>
<div id="tooltip-light" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip"> <div id="tooltip-light" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip">
Clear all Filter Clear all Filter
<div class="tooltip-arrow" data-popper-arrow></div> <div class="tooltip-arrow" data-popper-arrow></div>
@@ -25,22 +25,22 @@
<!-- Display active filters as tags --> <!-- Display active filters as tags -->
<div class="flex flex-wrap gap-2 mb-4" *ngIf="hasActiveFilters()"> <div class="flex flex-wrap gap-2 mb-4" *ngIf="hasActiveFilters()">
<span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button> State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
</span> </span>
<span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button> City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
</span> </span>
<span *ngIf="criteria.types?.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.types?.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Types: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button> Types: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
</span> </span>
<span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Professional Name: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button> Professional Name: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
</span> </span>
<span *ngIf="criteria.companyName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.companyName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Company: {{ criteria.companyName }} <button (click)="removeFilter('companyName')" class="ml-1 text-red-500 hover:text-red-700">×</button> Company: {{ criteria.companyName }} <button (click)="removeFilter('companyName')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
</span> </span>
<span *ngIf="criteria.counties?.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.counties?.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Areas Served: {{ criteria.counties.join(', ') }} <button (click)="removeFilter('counties')" class="ml-1 text-red-500 hover:text-red-700">×</button> Areas Served: {{ criteria.counties.join(', ') }} <button (click)="removeFilter('counties')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
</span> </span>
</div> </div>
@if(criteria.criteriaType==='brokerListings') { @if(criteria.criteriaType==='brokerListings') {
@@ -48,7 +48,7 @@
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label for="state" class="block mb-2 text-sm font-medium text-neutral-900">Location - State</label> <label for="state" class="block mb-2 text-sm font-medium text-neutral-900">Location - State</label>
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select> <ng-select id="state-select" aria-label="Location - State" class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select>
</div> </div>
<div> <div>
<app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-neutral-900 font-medium" [state]="criteria.state"></app-validated-city> <app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-neutral-900 font-medium" [state]="criteria.state"></app-validated-city>
@@ -141,7 +141,7 @@
<div *ngIf="!isModal" class="space-y-6 pb-10"> <div *ngIf="!isModal" class="space-y-6 pb-10">
<div class="flex space-x-4 mb-4"> <div class="flex space-x-4 mb-4">
<h3 class="text-xl font-semibold text-neutral-900">Filter ({{ numberOfResults$ | async }})</h3> <h3 class="text-xl font-semibold text-neutral-900">Filter ({{ numberOfResults$ | async }})</h3>
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-primary-500" (click)="clearFilter()"></i> <button type="button" aria-label="Clear all filters" data-tooltip-target="tooltip-light" class="flex self-center ml-2 cursor-pointer text-primary-500 p-1" (click)="clearFilter()"><i class="fa-solid fa-trash-can"></i></button>
<div id="tooltip-light" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip"> <div id="tooltip-light" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip">
Clear all Filter Clear all Filter
<div class="tooltip-arrow" data-popper-arrow></div> <div class="tooltip-arrow" data-popper-arrow></div>
@@ -150,29 +150,29 @@
<!-- Display active filters as tags --> <!-- Display active filters as tags -->
<div class="flex flex-wrap gap-2" *ngIf="hasActiveFilters()"> <div class="flex flex-wrap gap-2" *ngIf="hasActiveFilters()">
<span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button> State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
</span> </span>
<span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button> City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
</span> </span>
<span *ngIf="criteria.types?.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.types?.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Types: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button> Types: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
</span> </span>
<span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Professional Name: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button> Professional Name: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
</span> </span>
<span *ngIf="criteria.companyName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.companyName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Company: {{ criteria.companyName }} <button (click)="removeFilter('companyName')" class="ml-1 text-red-500 hover:text-red-700">×</button> Company: {{ criteria.companyName }} <button (click)="removeFilter('companyName')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
</span> </span>
<span *ngIf="criteria.counties?.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.counties?.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Areas Served: {{ criteria.counties.join(', ') }} <button (click)="removeFilter('counties')" class="ml-1 text-red-500 hover:text-red-700">×</button> Areas Served: {{ criteria.counties.join(', ') }} <button (click)="removeFilter('counties')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
</span> </span>
</div> </div>
@if(criteria.criteriaType==='brokerListings') { @if(criteria.criteriaType==='brokerListings') {
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label for="state" class="block mb-2 text-sm font-medium text-neutral-900">Location - State</label> <label for="state" class="block mb-2 text-sm font-medium text-neutral-900">Location - State</label>
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select> <ng-select id="state-select" aria-label="Location - State" class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select>
</div> </div>
<div> <div>
<app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-neutral-900 font-medium" [state]="criteria.state"></app-validated-city> <app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-neutral-900 font-medium" [state]="criteria.state"></app-validated-city>

View File

@@ -16,7 +16,7 @@
<div class="p-6 space-y-6"> <div class="p-6 space-y-6">
<div class="flex space-x-4 mb-4"> <div class="flex space-x-4 mb-4">
<button class="text-primary-600 font-medium border-b-2 border-primary-600 pb-2">Filter ({{ numberOfResults$ | async }})</button> <button class="text-primary-600 font-medium border-b-2 border-primary-600 pb-2">Filter ({{ numberOfResults$ | async }})</button>
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-primary-500" (click)="clearFilter()"></i> <button type="button" aria-label="Clear all filters" data-tooltip-target="tooltip-light" class="flex self-center ml-2 cursor-pointer text-primary-500 p-1" (click)="clearFilter()"><i class="fa-solid fa-trash-can"></i></button>
<div id="tooltip-light" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip"> <div id="tooltip-light" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip">
Clear all Filter Clear all Filter
<div class="tooltip-arrow" data-popper-arrow></div> <div class="tooltip-arrow" data-popper-arrow></div>
@@ -25,22 +25,22 @@
<!-- Display active filters as tags --> <!-- Display active filters as tags -->
<div class="flex flex-wrap gap-2 mb-4" *ngIf="hasActiveFilters()"> <div class="flex flex-wrap gap-2 mb-4" *ngIf="hasActiveFilters()">
<span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button> State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
</span> </span>
<span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button> City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
</span> </span>
<span *ngIf="criteria.minPrice || criteria.maxPrice" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.minPrice || criteria.maxPrice" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700">×</button> Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
</span> </span>
<span *ngIf="criteria.types.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.types.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button> Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
</span> </span>
<span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button> Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
</span> </span>
<span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button> Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
</span> </span>
</div> </div>
@if(criteria.criteriaType==='commercialPropertyListings') { @if(criteria.criteriaType==='commercialPropertyListings') {
@@ -48,7 +48,7 @@
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label for="state" class="block mb-2 text-sm font-medium text-neutral-900">Location - State</label> <label for="state" class="block mb-2 text-sm font-medium text-neutral-900">Location - State</label>
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select> <ng-select id="state-select" aria-label="Location - State" class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select>
</div> </div>
<div> <div>
<app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-neutral-900 font-medium" [state]="criteria.state"></app-validated-city> <app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-neutral-900 font-medium" [state]="criteria.state"></app-validated-city>
@@ -137,7 +137,7 @@
<div *ngIf="!isModal" class="space-y-6 pb-10"> <div *ngIf="!isModal" class="space-y-6 pb-10">
<div class="flex space-x-4 mb-4"> <div class="flex space-x-4 mb-4">
<h3 class="text-xl font-semibold text-neutral-900">Filter ({{ numberOfResults$ | async }})</h3> <h3 class="text-xl font-semibold text-neutral-900">Filter ({{ numberOfResults$ | async }})</h3>
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-primary-500" (click)="clearFilter()"></i> <button type="button" aria-label="Clear all filters" data-tooltip-target="tooltip-light" class="flex self-center ml-2 cursor-pointer text-primary-500 p-1" (click)="clearFilter()"><i class="fa-solid fa-trash-can"></i></button>
<div id="tooltip-light" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip"> <div id="tooltip-light" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip">
Clear all Filter Clear all Filter
<div class="tooltip-arrow" data-popper-arrow></div> <div class="tooltip-arrow" data-popper-arrow></div>
@@ -146,29 +146,29 @@
<!-- Display active filters as tags --> <!-- Display active filters as tags -->
<div class="flex flex-wrap gap-2" *ngIf="hasActiveFilters()"> <div class="flex flex-wrap gap-2" *ngIf="hasActiveFilters()">
<span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button> State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
</span> </span>
<span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button> City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
</span> </span>
<span *ngIf="criteria.minPrice || criteria.maxPrice" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.minPrice || criteria.maxPrice" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700">×</button> Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
</span> </span>
<span *ngIf="criteria.types.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.types.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button> Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
</span> </span>
<span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button> Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
</span> </span>
<span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button> Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]" aria-label="Remove filter">×</button>
</span> </span>
</div> </div>
@if(criteria.criteriaType==='commercialPropertyListings') { @if(criteria.criteriaType==='commercialPropertyListings') {
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label for="state" class="block mb-2 text-sm font-medium text-neutral-900">Location - State</label> <label for="state" class="block mb-2 text-sm font-medium text-neutral-900">Location - State</label>
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select> <ng-select id="state-select" aria-label="Location - State" class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select>
</div> </div>
<div> <div>
<app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-neutral-900 font-medium" [state]="criteria.state"></app-validated-city> <app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-neutral-900 font-medium" [state]="criteria.state"></app-validated-city>

View File

@@ -16,7 +16,7 @@
<div class="p-6 space-y-6"> <div class="p-6 space-y-6">
<div class="flex space-x-4 mb-4"> <div class="flex space-x-4 mb-4">
<button class="text-primary-600 font-medium border-b-2 border-primary-600 pb-2">Filter ({{ numberOfResults$ | async }})</button> <button class="text-primary-600 font-medium border-b-2 border-primary-600 pb-2">Filter ({{ numberOfResults$ | async }})</button>
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-primary-500" (click)="clearFilter()"></i> <button type="button" aria-label="Clear all filters" data-tooltip-target="tooltip-light" class="flex self-center ml-2 cursor-pointer text-primary-500 p-1" (click)="clearFilter()"><i class="fa-solid fa-trash-can"></i></button>
<div id="tooltip-light" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip"> <div id="tooltip-light" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip">
Clear all Filter Clear all Filter
<div class="tooltip-arrow" data-popper-arrow></div> <div class="tooltip-arrow" data-popper-arrow></div>
@@ -25,44 +25,44 @@
<!-- Display active filters as tags --> <!-- Display active filters as tags -->
<div class="flex flex-wrap gap-2 mb-4" *ngIf="hasActiveFilters()"> <div class="flex flex-wrap gap-2 mb-4" *ngIf="hasActiveFilters()">
<span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button> State: {{ criteria.state }} <button (click)="removeFilter('state')" aria-label="Remove state filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
</span> </span>
<span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button> City: {{ criteria.city.name }} <button (click)="removeFilter('city')" aria-label="Remove city filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
</span> </span>
<span *ngIf="criteria.minPrice || criteria.maxPrice" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.minPrice || criteria.maxPrice" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700">×</button> Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" aria-label="Remove price filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
</span> </span>
<span *ngIf="criteria.minRevenue || criteria.maxRevenue" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.minRevenue || criteria.maxRevenue" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Revenue: {{ criteria.minRevenue || 'Any' }} - {{ criteria.maxRevenue || 'Any' }} <button (click)="removeFilter('revenue')" class="ml-1 text-red-500 hover:text-red-700">×</button> Revenue: {{ criteria.minRevenue || 'Any' }} - {{ criteria.maxRevenue || 'Any' }} <button (click)="removeFilter('revenue')" aria-label="Remove revenue filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
</span> </span>
<span *ngIf="criteria.minCashFlow || criteria.maxCashFlow" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.minCashFlow || criteria.maxCashFlow" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Cashflow: {{ criteria.minCashFlow || 'Any' }} - {{ criteria.maxCashFlow || 'Any' }} <button (click)="removeFilter('cashflow')" class="ml-1 text-red-500 hover:text-red-700">×</button> Cashflow: {{ criteria.minCashFlow || 'Any' }} - {{ criteria.maxCashFlow || 'Any' }} <button (click)="removeFilter('cashflow')" aria-label="Remove cashflow filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
</span> </span>
<span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button> Title: {{ criteria.title }} <button (click)="removeFilter('title')" aria-label="Remove title filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
</span> </span>
<span *ngIf="criteria.types.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.types.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button> Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" aria-label="Remove categories filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
</span> </span>
<span *ngIf="selectedPropertyType" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="selectedPropertyType" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Property Type: {{ getSelectedPropertyTypeName() }} <button (click)="removeFilter('propertyType')" class="ml-1 text-red-500 hover:text-red-700">×</button> Property Type: {{ getSelectedPropertyTypeName() }} <button (click)="removeFilter('propertyType')" aria-label="Remove property type filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
</span> </span>
<span *ngIf="criteria.minNumberEmployees || criteria.maxNumberEmployees" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.minNumberEmployees || criteria.maxNumberEmployees" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Employees: {{ criteria.minNumberEmployees || 'Any' }} - {{ criteria.maxNumberEmployees || 'Any' }} <button (click)="removeFilter('employees')" class="ml-1 text-red-500 hover:text-red-700">×</button> Employees: {{ criteria.minNumberEmployees || 'Any' }} - {{ criteria.maxNumberEmployees || 'Any' }} <button (click)="removeFilter('employees')" aria-label="Remove employees filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
</span> </span>
<span *ngIf="criteria.establishedMin" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.establishedMin" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Established: {{ criteria.establishedMin || 'Any' }} <button (click)="removeFilter('established')" class="ml-1 text-red-500 hover:text-red-700">×</button> Established: {{ criteria.establishedMin || 'Any' }} <button (click)="removeFilter('established')" aria-label="Remove established filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
</span> </span>
<span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button> Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" aria-label="Remove broker filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
</span> </span>
</div> </div>
<div class="grid grid-cols-1 gap-6"> <div class="grid grid-cols-1 gap-6">
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label for="state" class="block mb-2 text-sm font-medium text-neutral-900">Location - State</label> <label for="state-select" class="block mb-2 text-sm font-medium text-neutral-900">Location - State</label>
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select> <ng-select id="state-select" class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state" aria-label="Location - State"></ng-select>
</div> </div>
<div> <div>
<app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-neutral-900 font-medium" [state]="criteria.state"></app-validated-city> <app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-neutral-900 font-medium" [state]="criteria.state"></app-validated-city>
@@ -148,6 +148,7 @@
[multiple]="true" [multiple]="true"
[closeOnSelect]="true" [closeOnSelect]="true"
placeholder="Select categories" placeholder="Select categories"
aria-label="Category"
></ng-select> ></ng-select>
</div> </div>
<div> <div>
@@ -160,6 +161,7 @@
[ngModel]="selectedPropertyType" [ngModel]="selectedPropertyType"
(ngModelChange)="onPropertyTypeChange($event)" (ngModelChange)="onPropertyTypeChange($event)"
placeholder="Select property type" placeholder="Select property type"
aria-label="Type of Property"
></ng-select> ></ng-select>
</div> </div>
<div> <div>
@@ -220,7 +222,7 @@
<div *ngIf="!isModal" class="space-y-6"> <div *ngIf="!isModal" class="space-y-6">
<div class="flex space-x-4 mb-4"> <div class="flex space-x-4 mb-4">
<h3 class="text-xl font-semibold text-neutral-900">Filter ({{ numberOfResults$ | async }})</h3> <h3 class="text-xl font-semibold text-neutral-900">Filter ({{ numberOfResults$ | async }})</h3>
<i data-tooltip-target="tooltip-light" class="fa-solid fa-trash-can flex self-center ml-2 hover:cursor-pointer text-primary-500" (click)="clearFilter()"></i> <button type="button" aria-label="Clear all filters" data-tooltip-target="tooltip-light" class="flex self-center ml-2 cursor-pointer text-primary-500 p-1" (click)="clearFilter()"><i class="fa-solid fa-trash-can"></i></button>
<div id="tooltip-light" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip"> <div id="tooltip-light" role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-neutral-900 bg-white border border-neutral-200 rounded-lg shadow-sm opacity-0 tooltip">
Clear all Filter Clear all Filter
<div class="tooltip-arrow" data-popper-arrow></div> <div class="tooltip-arrow" data-popper-arrow></div>
@@ -229,44 +231,44 @@
<!-- Display active filters as tags --> <!-- Display active filters as tags -->
<div class="flex flex-wrap gap-2" *ngIf="hasActiveFilters()"> <div class="flex flex-wrap gap-2" *ngIf="hasActiveFilters()">
<span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.state" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
State: {{ criteria.state }} <button (click)="removeFilter('state')" class="ml-1 text-red-500 hover:text-red-700">×</button> State: {{ criteria.state }} <button (click)="removeFilter('state')" aria-label="Remove state filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
</span> </span>
<span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.city" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
City: {{ criteria.city.name }} <button (click)="removeFilter('city')" class="ml-1 text-red-500 hover:text-red-700">×</button> City: {{ criteria.city.name }} <button (click)="removeFilter('city')" aria-label="Remove city filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
</span> </span>
<span *ngIf="criteria.minPrice || criteria.maxPrice" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.minPrice || criteria.maxPrice" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" class="ml-1 text-red-500 hover:text-red-700">×</button> Price: {{ criteria.minPrice || 'Any' }} - {{ criteria.maxPrice || 'Any' }} <button (click)="removeFilter('price')" aria-label="Remove price filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
</span> </span>
<span *ngIf="criteria.minRevenue || criteria.maxRevenue" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.minRevenue || criteria.maxRevenue" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Revenue: {{ criteria.minRevenue || 'Any' }} - {{ criteria.maxRevenue || 'Any' }} <button (click)="removeFilter('revenue')" class="ml-1 text-red-500 hover:text-red-700">×</button> Revenue: {{ criteria.minRevenue || 'Any' }} - {{ criteria.maxRevenue || 'Any' }} <button (click)="removeFilter('revenue')" aria-label="Remove revenue filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
</span> </span>
<span *ngIf="criteria.minCashFlow || criteria.maxCashFlow" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.minCashFlow || criteria.maxCashFlow" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Cashflow: {{ criteria.minCashFlow || 'Any' }} - {{ criteria.maxCashFlow || 'Any' }} <button (click)="removeFilter('cashflow')" class="ml-1 text-red-500 hover:text-red-700">×</button> Cashflow: {{ criteria.minCashFlow || 'Any' }} - {{ criteria.maxCashFlow || 'Any' }} <button (click)="removeFilter('cashflow')" aria-label="Remove cashflow filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
</span> </span>
<span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.title" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Title: {{ criteria.title }} <button (click)="removeFilter('title')" class="ml-1 text-red-500 hover:text-red-700">×</button> Title: {{ criteria.title }} <button (click)="removeFilter('title')" aria-label="Remove title filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
</span> </span>
<span *ngIf="criteria.types.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.types.length" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" class="ml-1 text-red-500 hover:text-red-700">×</button> Categories: {{ criteria.types.join(', ') }} <button (click)="removeFilter('types')" aria-label="Remove categories filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
</span> </span>
<span *ngIf="selectedPropertyType" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="selectedPropertyType" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Property Type: {{ getSelectedPropertyTypeName() }} <button (click)="removeFilter('propertyType')" class="ml-1 text-red-500 hover:text-red-700">×</button> Property Type: {{ getSelectedPropertyTypeName() }} <button (click)="removeFilter('propertyType')" aria-label="Remove property type filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
</span> </span>
<span *ngIf="criteria.minNumberEmployees || criteria.maxNumberEmployees" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.minNumberEmployees || criteria.maxNumberEmployees" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Employees: {{ criteria.minNumberEmployees || 'Any' }} - {{ criteria.maxNumberEmployees || 'Any' }} <button (click)="removeFilter('employees')" class="ml-1 text-red-500 hover:text-red-700">×</button> Employees: {{ criteria.minNumberEmployees || 'Any' }} - {{ criteria.maxNumberEmployees || 'Any' }} <button (click)="removeFilter('employees')" aria-label="Remove employees filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
</span> </span>
<span *ngIf="criteria.establishedMin" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.establishedMin" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Years established: {{ criteria.establishedMin || 'Any' }} <button (click)="removeFilter('established')" class="ml-1 text-red-500 hover:text-red-700">×</button> Years established: {{ criteria.establishedMin || 'Any' }} <button (click)="removeFilter('established')" aria-label="Remove established filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
</span> </span>
<span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center"> <span *ngIf="criteria.brokerName" class="bg-neutral-200 text-neutral-800 text-xs font-medium px-2.5 py-0.5 rounded flex items-center">
Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" class="ml-1 text-red-500 hover:text-red-700">×</button> Broker: {{ criteria.brokerName }} <button (click)="removeFilter('brokerName')" aria-label="Remove broker filter" class="ml-1 text-red-500 hover:text-red-700 min-w-[24px] min-h-[24px]">×</button>
</span> </span>
</div> </div>
@if(criteria.criteriaType==='businessListings') { @if(criteria.criteriaType==='businessListings') {
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label for="state" class="block mb-2 text-sm font-medium text-neutral-900">Location - State</label> <label for="state" class="block mb-2 text-sm font-medium text-neutral-900">Location - State</label>
<ng-select class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select> <ng-select id="state-select" aria-label="Location - State" class="custom" [items]="selectOptions?.states" bindLabel="name" bindValue="value" [ngModel]="criteria.state" (ngModelChange)="setState($event)" name="state"></ng-select>
</div> </div>
<div> <div>
<app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-neutral-900 font-medium" [state]="criteria.state"></app-validated-city> <app-validated-city label="Location - City" name="city" [ngModel]="criteria.city" (ngModelChange)="setCity($event)" labelClasses="text-neutral-900 font-medium" [state]="criteria.state"></app-validated-city>

View File

@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, forwardRef } from '@angular/core'; import { Component, forwardRef, ViewEncapsulation } from '@angular/core';
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'; import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
import { QuillModule } from 'ngx-quill'; import { QuillModule } from 'ngx-quill';
import { BaseInputComponent } from '../base-input/base-input.component'; import { BaseInputComponent } from '../base-input/base-input.component';
@@ -9,9 +9,11 @@ import { ValidationMessagesService } from '../validation-messages.service';
@Component({ @Component({
selector: 'app-validated-quill', selector: 'app-validated-quill',
templateUrl: './validated-quill.component.html', templateUrl: './validated-quill.component.html',
styles: `quill-editor { styleUrls: ['../../../../node_modules/quill/dist/quill.snow.css'],
styles: [`quill-editor {
width: 100%; width: 100%;
}`, }`],
encapsulation: ViewEncapsulation.None,
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, QuillModule, TooltipComponent], imports: [CommonModule, FormsModule, QuillModule, TooltipComponent],
providers: [ providers: [

View File

@@ -1,15 +1,27 @@
import { Injectable } from '@angular/core'; import { Injectable, PLATFORM_ID, Inject } from '@angular/core';
import { CanActivate, Router } from '@angular/router'; import { CanActivate, Router } from '@angular/router';
import { AuthService } from '../services/auth.service'; import { AuthService } from '../services/auth.service';
import { createLogger } from '../utils/utils'; import { isPlatformBrowser } from '@angular/common';
const logger = createLogger('AuthGuard');
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class AuthGuard implements CanActivate { export class AuthGuard implements CanActivate {
constructor(private authService: AuthService, private router: Router) {}
constructor(
private authService: AuthService,
private router: Router,
@Inject(PLATFORM_ID) private platformId: Object
) {}
async canActivate(): Promise<boolean> { async canActivate(): Promise<boolean> {
// 1. SSR CHECK: Wenn wir auf dem Server sind, immer erlauben!
// Der Server soll nicht redirecten, sondern einfach das HTML rendern.
if (!isPlatformBrowser(this.platformId)) {
return true;
}
// 2. CLIENT CHECK: Das läuft nur im Browser
const token = await this.authService.getToken(); const token = await this.authService.getToken();
if (token) { if (token) {
return true; return true;

View File

@@ -5,8 +5,8 @@
} }
<div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg overflow-hidden relative"> <div class="bg-white rounded-lg drop-shadow-custom-bg-mobile md:drop-shadow-custom-bg overflow-hidden relative">
<button (click)="historyService.goBack()" <button (click)="historyService.goBack()" aria-label="Go back"
class="absolute top-4 right-4 bg-red-500 text-white rounded-full w-8 h-8 flex items-center justify-center hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 print:hidden"> class="absolute top-4 right-4 bg-red-500 text-white rounded-full w-10 h-10 flex items-center justify-center hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50 print:hidden">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
@if(listing){ @if(listing){

View File

@@ -31,13 +31,27 @@ import dayjs from 'dayjs';
import { AuthService } from '../../../services/auth.service'; import { AuthService } from '../../../services/auth.service';
import { BaseDetailsComponent } from '../base-details.component'; import { BaseDetailsComponent } from '../base-details.component';
import { ShareButton } from 'ngx-sharebuttons/button'; import { ShareButton } from 'ngx-sharebuttons/button';
import { provideShareButtonsOptions, SharerMethods, withConfig } from 'ngx-sharebuttons';
import { shareIcons } from 'ngx-sharebuttons/icons';
@Component({ @Component({
selector: 'app-details-business-listing', selector: 'app-details-business-listing',
standalone: true, standalone: true,
imports: [SharedModule, ValidatedInputComponent, ValidatedTextareaComponent, ValidatedNgSelectComponent, LeafletModule, BreadcrumbsComponent, ShareButton, NgOptimizedImage], imports: [SharedModule, ValidatedInputComponent, ValidatedTextareaComponent, ValidatedNgSelectComponent, LeafletModule, BreadcrumbsComponent, ShareButton, NgOptimizedImage],
providers: [], providers: [
provideShareButtonsOptions(
shareIcons(),
withConfig({
debug: false,
sharerMethod: SharerMethods.Anchor,
}),
),
],
templateUrl: './details-business-listing.component.html', templateUrl: './details-business-listing.component.html',
styleUrl: '../details.scss', styleUrls: [
'../details.scss',
'../../../../../node_modules/leaflet/dist/leaflet.css',
'../../../../../node_modules/ngx-sharebuttons/themes/default.scss'
],
}) })
export class DetailsBusinessListingComponent extends BaseDetailsComponent { export class DetailsBusinessListingComponent extends BaseDetailsComponent {
// listings: Array<BusinessListing>; // listings: Array<BusinessListing>;

View File

@@ -6,8 +6,8 @@
@if(listing){ @if(listing){
<div class="p-6 relative"> <div class="p-6 relative">
<h1 class="text-3xl font-bold mb-4 break-words">{{ listing?.title }}</h1> <h1 class="text-3xl font-bold mb-4 break-words">{{ listing?.title }}</h1>
<button (click)="historyService.goBack()" <button (click)="historyService.goBack()" aria-label="Go back"
class="print:hidden absolute top-4 right-4 bg-red-500 text-white rounded-full w-8 h-8 flex items-center justify-center hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50"> class="print:hidden absolute top-4 right-4 bg-red-500 text-white rounded-full w-10 h-10 flex items-center justify-center hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
<div class="flex flex-col lg:flex-row"> <div class="flex flex-col lg:flex-row">

View File

@@ -3,9 +3,9 @@ import { NgOptimizedImage } from '@angular/common';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { LeafletModule } from '@bluehalo/ngx-leaflet'; import { LeafletModule } from '@bluehalo/ngx-leaflet';
import { faTimes } from '@fortawesome/free-solid-svg-icons'; import { APP_ICONS } from '../../../utils/fontawesome-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { GalleryModule, ImageItem } from 'ng-gallery'; import { GALLERY_CONFIG, GalleryConfig, GalleryModule, ImageItem } from 'ng-gallery';
import { lastValueFrom } from 'rxjs'; import { lastValueFrom } from 'rxjs';
import { CommercialPropertyListing, EventTypeEnum, ShareByEMail, User } from '../../../../../../bizmatch-server/src/models/db.model'; import { CommercialPropertyListing, EventTypeEnum, ShareByEMail, User } from '../../../../../../bizmatch-server/src/models/db.model';
import { CommercialPropertyListingCriteria, ErrorResponse, KeycloakUser, MailInfo } from '../../../../../../bizmatch-server/src/models/main.model'; import { CommercialPropertyListingCriteria, ErrorResponse, KeycloakUser, MailInfo } from '../../../../../../bizmatch-server/src/models/main.model';
@@ -30,14 +30,35 @@ import { createMailInfo, map2User } from '../../../utils/utils';
import { BaseDetailsComponent } from '../base-details.component'; import { BaseDetailsComponent } from '../base-details.component';
import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component'; import { BreadcrumbItem, BreadcrumbsComponent } from '../../../components/breadcrumbs/breadcrumbs.component';
import { ShareButton } from 'ngx-sharebuttons/button'; import { ShareButton } from 'ngx-sharebuttons/button';
import { provideShareButtonsOptions, SharerMethods, withConfig } from 'ngx-sharebuttons';
import { shareIcons } from 'ngx-sharebuttons/icons';
@Component({ @Component({
selector: 'app-details-commercial-property-listing', selector: 'app-details-commercial-property-listing',
standalone: true, standalone: true,
imports: [SharedModule, ValidatedInputComponent, ValidatedTextareaComponent, ValidatedNgSelectComponent, GalleryModule, LeafletModule, BreadcrumbsComponent, ShareButton, NgOptimizedImage], imports: [SharedModule, ValidatedInputComponent, ValidatedTextareaComponent, ValidatedNgSelectComponent, GalleryModule, LeafletModule, BreadcrumbsComponent, ShareButton, NgOptimizedImage],
providers: [], providers: [
provideShareButtonsOptions(
shareIcons(),
withConfig({
debug: false,
sharerMethod: SharerMethods.Anchor,
}),
),
{
provide: GALLERY_CONFIG,
useValue: {
autoHeight: true,
imageSize: 'cover',
} as GalleryConfig,
},
],
templateUrl: './details-commercial-property-listing.component.html', templateUrl: './details-commercial-property-listing.component.html',
styleUrl: '../details.scss', styleUrls: [
'../details.scss',
'../../../../../node_modules/leaflet/dist/leaflet.css',
'../../../../../node_modules/ngx-sharebuttons/themes/default.scss'
],
}) })
export class DetailsCommercialPropertyListingComponent extends BaseDetailsComponent { export class DetailsCommercialPropertyListingComponent extends BaseDetailsComponent {
responsiveOptions = [ responsiveOptions = [
@@ -69,7 +90,7 @@ export class DetailsCommercialPropertyListingComponent extends BaseDetailsCompon
ts = new Date().getTime(); ts = new Date().getTime();
env = environment; env = environment;
errorResponse: ErrorResponse; errorResponse: ErrorResponse;
faTimes = faTimes; faTimes = APP_ICONS.faTimes;
propertyDetails = []; propertyDetails = [];
images: Array<ImageItem> = []; images: Array<ImageItem> = [];
relatedListings: CommercialPropertyListing[] = []; relatedListings: CommercialPropertyListing[] = [];

View File

@@ -42,8 +42,8 @@
} }
<!-- <img src="https://placehold.co/45x60" class="w-11 h-14" /> --> <!-- <img src="https://placehold.co/45x60" class="w-11 h-14" /> -->
</div> </div>
<button (click)="historyService.goBack()" <button (click)="historyService.goBack()" aria-label="Go back"
class="absolute top-4 right-4 bg-red-500 text-white rounded-full w-8 h-8 flex items-center justify-center hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50"> class="absolute top-4 right-4 bg-red-500 text-white rounded-full w-10 h-10 flex items-center justify-center hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
</div> </div>

View File

@@ -19,13 +19,27 @@ import { UserService } from '../../../services/user.service';
import { SharedModule } from '../../../shared/shared/shared.module'; import { SharedModule } from '../../../shared/shared/shared.module';
import { formatPhoneNumber, map2User } from '../../../utils/utils'; import { formatPhoneNumber, map2User } from '../../../utils/utils';
import { ShareButton } from 'ngx-sharebuttons/button'; import { ShareButton } from 'ngx-sharebuttons/button';
import { provideShareButtonsOptions, SharerMethods, withConfig } from 'ngx-sharebuttons';
import { shareIcons } from 'ngx-sharebuttons/icons';
@Component({ @Component({
selector: 'app-details-user', selector: 'app-details-user',
standalone: true, standalone: true,
imports: [SharedModule, BreadcrumbsComponent, NgOptimizedImage, ShareButton], imports: [SharedModule, BreadcrumbsComponent, NgOptimizedImage, ShareButton],
providers: [
provideShareButtonsOptions(
shareIcons(),
withConfig({
debug: false,
sharerMethod: SharerMethods.Anchor,
}),
),
],
templateUrl: './details-user.component.html', templateUrl: './details-user.component.html',
styleUrl: '../details.scss', styleUrls: [
'../details.scss',
'../../../../../node_modules/ngx-sharebuttons/themes/default.scss'
],
}) })
export class DetailsUserComponent { export class DetailsUserComponent {
private id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined; private id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined;

View File

@@ -1,5 +1,5 @@
<header class="w-full flex justify-between items-center p-4 bg-white top-0 z-10 h-16 md:h-20"> <header class="w-full flex justify-between items-center p-4 bg-white top-0 z-10 h-16 md:h-20">
<img src="/assets/images/header-logo.png" alt="Logo" class="h-8 md:h-10 w-auto" /> <img src="/assets/images/header-logo.png" alt="BizMatch - Business & Property Marketplace" class="h-8 md:h-10 w-auto" width="120" height="40" />
<div class="hidden md:flex items-center space-x-4"> <div class="hidden md:flex items-center space-x-4">
@if(user){ @if(user){
<a routerLink="/account" class="text-primary-600 border border-primary-600 px-3 py-2 rounded">Account</a> <a routerLink="/account" class="text-primary-600 border border-primary-600 px-3 py-2 rounded">Account</a>
@@ -10,7 +10,12 @@
<!-- <a routerLink="/login" class="text-primary-500 hover:underline">Login/Register</a> --> <!-- <a routerLink="/login" class="text-primary-500 hover:underline">Login/Register</a> -->
} }
</div> </div>
<button (click)="toggleMenu()" class="md:hidden text-neutral-600"> <button
(click)="toggleMenu()"
class="md:hidden text-neutral-700 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center"
aria-label="Open navigation menu"
[attr.aria-expanded]="isMenuOpen"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16m-7 6h7"></path> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16m-7 6h7"></path>
</svg> </svg>
@@ -26,7 +31,11 @@
<a routerLink="/login" [queryParams]="{ mode: 'login' }" class="text-white text-xl py-2">Log In</a> <a routerLink="/login" [queryParams]="{ mode: 'login' }" class="text-white text-xl py-2">Log In</a>
<a routerLink="/login" [queryParams]="{ mode: 'register' }" class="text-white text-xl py-2">Sign Up</a> <a routerLink="/login" [queryParams]="{ mode: 'register' }" class="text-white text-xl py-2">Sign Up</a>
} }
<button (click)="toggleMenu()" class="text-white mt-4"> <button
(click)="toggleMenu()"
class="text-white mt-4"
aria-label="Close navigation menu"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path>
</svg> </svg>
@@ -38,88 +47,126 @@
<!-- 1. px-4 für <main> (vorher px-2 sm:px-4) --> <!-- 1. px-4 für <main> (vorher px-2 sm:px-4) -->
<main class="flex flex-col items-center justify-center px-4 w-full flex-grow"> <main class="flex flex-col items-center justify-center px-4 w-full flex-grow">
<div <div
class="bg-cover-custom pb-12 md:py-20 flex flex-col w-full rounded-xl lg:rounded-2xl md:drop-shadow-custom-md lg:drop-shadow-custom-lg min-h-[calc(100vh_-_20rem)] lg:min-h-[calc(100vh_-_10rem)] max-sm:bg-contain max-sm:bg-bottom max-sm:bg-no-repeat max-sm:min-h-[60vh] max-sm:bg-primary-600" class="relative overflow-hidden pb-12 md:py-20 flex flex-col w-full rounded-xl lg:rounded-2xl md:drop-shadow-custom-md lg:drop-shadow-custom-lg min-h-[calc(100vh_-_20rem)] lg:min-h-[calc(100vh_-_10rem)] max-sm:min-h-[60vh] max-sm:bg-primary-600"
> >
<div class="flex justify-center w-full"> <!-- Optimized Background Image -->
<picture class="absolute inset-0 w-full h-full z-0 pointer-events-none">
<source srcset="/assets/images/flags_bg.avif" type="image/avif">
<img
width="2500"
height="1285"
fetchpriority="high"
loading="eager"
src="/assets/images/flags_bg.jpg"
alt=""
class="w-full h-full object-cover"
>
</picture>
<!-- Gradient Overlay -->
<div class="absolute inset-0 bg-gradient-to-b from-black/35 via-black/15 to-transparent z-0 pointer-events-none"></div>
<div class="flex justify-center w-full relative z-10">
<!-- 3. Für Mobile: m-2 statt max-w-xs; ab sm: wieder max-width und kein Margin --> <!-- 3. Für Mobile: m-2 statt max-w-xs; ab sm: wieder max-width und kein Margin -->
<div class="w-full m-2 sm:m-0 sm:max-w-md md:max-w-xl lg:max-w-2xl xl:max-w-3xl"> <div class="w-full m-2 sm:m-0 sm:max-w-md md:max-w-xl lg:max-w-2xl xl:max-w-3xl">
<!-- Hero-Container --> <!-- Hero-Container -->
<section class="relative"> <section class="relative">
<!-- Dein Hintergrundbild liegt hier per CSS oder absolutem <img> --> <!-- Dein Hintergrundbild liegt hier per CSS oder absolutem <img> -->
<!-- 1) Overlay: sorgt für Kontrast auf hellem Himmel --> <!-- 1) Overlay: sorgt für Kontrast auf hellem Himmel (Previous overlay removed, using new global overlay) -->
<div aria-hidden="true" class="pointer-events-none absolute inset-0"></div> <!-- <div aria-hidden="true" class="pointer-events-none absolute inset-0"></div> -->
<!-- 2) Textblock --> <!-- 2) Textblock -->
<div class="relative z-10 mx-auto max-w-4xl px-6 sm:px-6 py-4 sm:py-16 text-center text-white"> <div class="relative z-10 mx-auto max-w-4xl px-6 sm:px-6 py-4 sm:py-16 text-center text-white">
<h1 class="text-[1.55rem] sm:text-4xl md:text-5xl lg:text-6xl font-extrabold tracking-tight leading-tight drop-shadow-[0_2px_6px_rgba(0,0,0,0.55)]">Buy & Sell Businesses and Commercial Properties</h1> <h1 class="text-[1.55rem] sm:text-4xl md:text-5xl lg:text-6xl font-extrabold tracking-tight leading-tight drop-shadow-[0_2px_6px_rgba(0,0,0,0.55)]">Buy & Sell Businesses and Commercial Properties</h1>
<p class="mt-3 sm:mt-4 text-base sm:text-lg md:text-xl lg:text-2xl font-medium text-white/90 drop-shadow-[0_1.5px_4px_rgba(0,0,0,0.6)]"> <p class="mt-3 sm:mt-4 text-base sm:text-lg md:text-xl lg:text-2xl font-medium text-white/90 drop-shadow-[0_1.5px_4px_rgba(0,0,0,0.6)]">
Find profitable businesses for sale, commercial real estate, and franchise opportunities across the United States Buy profitable businesses for sale or sell your business to qualified buyers. Browse commercial real estate and franchise opportunities across the United States.
</p> </p>
</div> </div>
</section> </section>
<!-- Restliche Anpassungen (Innenabstände, Button-Paddings etc.) bleiben wie im vorherigen Schritt --> <!-- Restliche Anpassungen (Innenabstände, Button-Paddings etc.) bleiben wie im vorherigen Schritt -->
<div class="search-form-container bg-white bg-opacity-80 pb-4 md:pb-6 pt-2 px-3 sm:px-4 md:px-6 rounded-lg shadow-lg w-full" [ngClass]="{ 'pt-6': aiSearch }"> <div class="search-form-container bg-white bg-opacity-80 pb-4 md:pb-6 pt-2 px-3 sm:px-4 md:px-6 rounded-lg shadow-lg w-full" [ngClass]="{ 'pt-6': aiSearch }">
@if(!aiSearch){ @if(!aiSearch){
<div class="text-sm lg:text-base mb-1 text-center text-neutral-500 border-neutral-200 dark:text-neutral-400 dark:border-neutral-700 flex justify-between"> <div class="text-sm lg:text-base mb-1 text-center text-neutral-700 border-neutral-200 dark:text-neutral-300 dark:border-neutral-700 flex justify-between">
<ul class="flex flex-wrap -mb-px w-full"> <ul class="flex flex-wrap -mb-px w-full" role="tablist" aria-label="Search categories">
<li class="w-[33%]"> <li class="w-[33%]" role="presentation">
<a <button
type="button"
role="tab"
[attr.aria-selected]="activeTabAction === 'business'"
(click)="changeTab('business')" (click)="changeTab('business')"
[ngClass]=" [ngClass]="
activeTabAction === 'business' activeTabAction === 'business'
? ['text-primary-600', 'border-primary-600', 'active', 'dark:text-primary-500', 'dark:border-primary-500'] ? ['text-primary-600', 'border-primary-600', 'active', 'dark:text-primary-500', 'dark:border-primary-500']
: ['border-transparent', 'hover:text-neutral-600', 'hover:border-neutral-300', 'dark:hover:text-neutral-300'] : ['border-transparent', 'text-neutral-700', 'hover:text-neutral-900', 'hover:border-neutral-400', 'dark:hover:text-neutral-300']
" "
class="tab-link hover:cursor-pointer inline-flex items-center justify-center px-1 py-2 md:p-4 border-b-2 rounded-t-lg" class="tab-link w-full hover:cursor-pointer inline-flex items-center justify-center px-3 py-3 md:p-4 border-b-2 rounded-t-lg bg-transparent min-h-[44px]"
[attr.aria-controls]="'tabpanel-search'"
id="tab-business"
> >
<img src="/assets/images/business_logo.png" alt="Search businesses for sale" class="tab-icon w-6 h-6 md:w-7 md:h-7 mr-1 md:mr-2 object-contain" width="28" height="28" /> <img src="/assets/images/business_logo.png" alt="" aria-hidden="true" class="tab-icon w-6 h-6 md:w-7 md:h-7 mr-1 md:mr-2 object-contain" width="28" height="28" />
<span>Businesses</span> <span>Businesses</span>
</a> </button>
</li> </li>
@if ((numberOfCommercial$ | async) > 0) { @if ((numberOfCommercial$ | async) > 0) {
<li class="w-[33%]"> <li class="w-[33%]" role="presentation">
<a <button
type="button"
role="tab"
[attr.aria-selected]="activeTabAction === 'commercialProperty'"
(click)="changeTab('commercialProperty')" (click)="changeTab('commercialProperty')"
[ngClass]=" [ngClass]="
activeTabAction === 'commercialProperty' activeTabAction === 'commercialProperty'
? ['text-primary-600', 'border-primary-600', 'active', 'dark:text-primary-500', 'dark:border-primary-500'] ? ['text-primary-600', 'border-primary-600', 'active', 'dark:text-primary-500', 'dark:border-primary-500']
: ['border-transparent', 'hover:text-neutral-600', 'hover:border-neutral-300', 'dark:hover:text-neutral-300'] : ['border-transparent', 'text-neutral-700', 'hover:text-neutral-900', 'hover:border-neutral-400', 'dark:hover:text-neutral-300']
" "
class="tab-link hover:cursor-pointer inline-flex items-center justify-center px-1 py-2 md:p-4 border-b-2 rounded-t-lg" class="tab-link w-full hover:cursor-pointer inline-flex items-center justify-center px-3 py-3 md:p-4 border-b-2 rounded-t-lg bg-transparent min-h-[44px]"
[attr.aria-controls]="'tabpanel-search'"
id="tab-properties"
> >
<img src="/assets/images/properties_logo.png" alt="Search commercial properties for sale" class="tab-icon w-6 h-6 md:w-7 md:h-7 mr-1 md:mr-2 object-contain" width="28" height="28" /> <img src="/assets/images/properties_logo.png" alt="" aria-hidden="true" class="tab-icon w-6 h-6 md:w-7 md:h-7 mr-1 md:mr-2 object-contain" width="28" height="28" />
<span>Properties</span> <span>Properties</span>
</a> </button>
</li> </li>
} }
<li class="w-[33%]"> <li class="w-[33%]" role="presentation">
<a <button
type="button"
role="tab"
[attr.aria-selected]="activeTabAction === 'broker'"
(click)="changeTab('broker')" (click)="changeTab('broker')"
[ngClass]=" [ngClass]="
activeTabAction === 'broker' activeTabAction === 'broker'
? ['text-primary-600', 'border-primary-600', 'active', 'dark:text-primary-500', 'dark:border-primary-500'] ? ['text-primary-600', 'border-primary-600', 'active', 'dark:text-primary-500', 'dark:border-primary-500']
: ['border-transparent', 'hover:text-neutral-600', 'hover:border-neutral-300', 'dark:hover:text-neutral-300'] : ['border-transparent', 'text-neutral-700', 'hover:text-neutral-900', 'hover:border-neutral-400', 'dark:hover:text-neutral-300']
" "
class="tab-link hover:cursor-pointer inline-flex items-center justify-center px-1 py-2 md:p-4 border-b-2 rounded-t-lg" class="tab-link w-full hover:cursor-pointer inline-flex items-center justify-center px-3 py-3 md:p-4 border-b-2 rounded-t-lg bg-transparent min-h-[44px]"
[attr.aria-controls]="'tabpanel-search'"
id="tab-professionals"
> >
<img <img
src="/assets/images/icon_professionals.png" src="/assets/images/icon_professionals.png"
alt="Search business professionals and brokers" alt=""
class="tab-icon w-6 h-6 md:w-7 md:h-7 mr-1 md:mr-2 object-contain bg-transparent" aria-hidden="true"
class="tab-icon w-6 h-6 md:w-7 md:h-7 mr-1 md:mr-2 object-contain"
style="mix-blend-mode: darken" style="mix-blend-mode: darken"
width="28" height="28"
/> />
<span>Professionals</span> <span>Professionals</span>
</a> </button>
</li> </li>
</ul> </ul>
</div> </div>
} @if(criteria && !aiSearch){ } @if(criteria && !aiSearch){
<div class="w-full max-w-3xl mx-auto bg-white rounded-lg flex flex-col md:flex-row md:border md:border-neutral-300"> <div id="tabpanel-search" role="tabpanel" aria-labelledby="tab-business" class="w-full max-w-3xl mx-auto bg-white rounded-lg flex flex-col md:flex-row md:border md:border-neutral-300">
<div class="md:flex-none md:w-48 flex-1 md:border-r border-neutral-300 overflow-hidden mb-2 md:mb-0"> <div class="md:flex-none md:w-48 flex-1 md:border-r border-neutral-300 overflow-hidden mb-2 md:mb-0">
<div class="relative max-sm:border border-neutral-300 rounded-md"> <div class="relative max-sm:border border-neutral-300 rounded-md">
<label for="type-filter" class="sr-only">Filter by type</label>
<select <select
id="type-filter"
aria-label="Filter by type"
class="appearance-none bg-transparent w-full py-4 px-4 pr-8 focus:outline-none md:border-none rounded-md md:rounded-none min-h-[52px]" class="appearance-none bg-transparent w-full py-4 px-4 pr-8 focus:outline-none md:border-none rounded-md md:rounded-none min-h-[52px]"
[ngModel]="criteria.types" [ngModel]="criteria.types"
(ngModelChange)="onTypesChange($event)" (ngModelChange)="onTypesChange($event)"
@@ -138,6 +185,7 @@
<div class="md:flex-auto md:w-36 flex-grow md:border-r border-neutral-300 mb-2 md:mb-0"> <div class="md:flex-auto md:w-36 flex-grow md:border-r border-neutral-300 mb-2 md:mb-0">
<div class="relative max-sm:border border-neutral-300 rounded-md"> <div class="relative max-sm:border border-neutral-300 rounded-md">
<label for="location-search" class="sr-only">Search by city or state</label>
<ng-select <ng-select
class="custom md:border-none rounded-md md:rounded-none" class="custom md:border-none rounded-md md:rounded-none"
[multiple]="false" [multiple]="false"
@@ -151,7 +199,13 @@
(ngModelChange)="setCityOrState($event)" (ngModelChange)="setCityOrState($event)"
placeholder="Enter City or State ..." placeholder="Enter City or State ..."
groupBy="type" groupBy="type"
labelForId="location-search"
aria-label="Search by city or state"
[inputAttrs]="{'aria-describedby': 'location-search-hint'}"
> >
<ng-template ng-footer-tmp>
<span id="location-search-hint" class="sr-only">Enter at least 2 characters to search for a city or state</span>
</ng-template>
@for (city of cities$ | async; track city.id) { @let state = city.type==='city'?city.content.state:''; @let separator = city.type==='city'?' - ':''; @for (city of cities$ | async; track city.id) { @let state = city.type==='city'?city.content.state:''; @let separator = city.type==='city'?' - ':'';
<ng-option [value]="city">{{ city.content.name }}{{ separator }}{{ state }}</ng-option> <ng-option [value]="city">{{ city.content.name }}{{ separator }}{{ state }}</ng-option>
} }
@@ -161,7 +215,10 @@
@if (criteria.radius && !aiSearch){ @if (criteria.radius && !aiSearch){
<div class="md:flex-none md:w-36 flex-1 md:border-r border-neutral-300 mb-2 md:mb-0"> <div class="md:flex-none md:w-36 flex-1 md:border-r border-neutral-300 mb-2 md:mb-0">
<div class="relative max-sm:border border-neutral-300 rounded-md"> <div class="relative max-sm:border border-neutral-300 rounded-md">
<label for="radius-filter" class="sr-only">Filter by radius</label>
<select <select
id="radius-filter"
aria-label="Filter by radius"
class="appearance-none bg-transparent w-full py-4 px-4 pr-8 focus:outline-none md:border-none rounded-md md:rounded-none min-h-[52px]" class="appearance-none bg-transparent w-full py-4 px-4 pr-8 focus:outline-none md:border-none rounded-md md:rounded-none min-h-[52px]"
(ngModelChange)="onRadiusChange($event)" (ngModelChange)="onRadiusChange($event)"
[ngModel]="criteria.radius" [ngModel]="criteria.radius"
@@ -180,13 +237,13 @@
} }
<div class="bg-primary-500 hover:bg-primary-600 max-sm:rounded-md search-button"> <div class="bg-primary-500 hover:bg-primary-600 max-sm:rounded-md search-button">
@if( numberOfResults$){ @if( numberOfResults$){
<button class="w-full h-full text-white font-bold py-2 px-4 md:py-3 md:px-6 focus:outline-none rounded-md md:rounded-none min-h-[52px] flex items-center justify-center gap-2" (click)="search()"> <button aria-label="Search listings" class="w-full h-full text-white font-bold py-2 px-4 md:py-3 md:px-6 focus:outline-none rounded-md md:rounded-none min-h-[52px] flex items-center justify-center gap-2" (click)="search()">
<i class="fas fa-search"></i> <i class="fas fa-search" aria-hidden="true"></i>
<span>Search {{ numberOfResults$ | async }}</span> <span>Search {{ numberOfResults$ | async }}</span>
</button> </button>
}@else { }@else {
<button class="w-full h-full text-white font-bold py-2 px-4 md:py-3 md:px-6 focus:outline-none rounded-md md:rounded-none min-h-[52px] flex items-center justify-center gap-2" (click)="search()"> <button aria-label="Search listings" class="w-full h-full text-white font-bold py-2 px-4 md:py-3 md:px-6 focus:outline-none rounded-md md:rounded-none min-h-[52px] flex items-center justify-center gap-2" (click)="search()">
<i class="fas fa-search"></i> <i class="fas fa-search" aria-hidden="true"></i>
<span>Search</span> <span>Search</span>
</button> </button>
} }

View File

@@ -1,39 +1,7 @@
.bg-cover-custom {
position: relative;
// Prioritize AVIF format (69KB) over JPG (26MB)
background-image: url('/assets/images/flags_bg.avif');
background-size: cover;
background-position: center;
border-radius: 20px;
// Fallback for browsers that don't support AVIF
@supports not (background-image: url('/assets/images/flags_bg.avif')) {
background-image: url('/assets/images/flags_bg.jpg');
}
// Add gradient overlay for better text contrast
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.35) 0%, rgba(0, 0, 0, 0.15) 40%, rgba(0, 0, 0, 0.05) 70%, rgba(0, 0, 0, 0) 100%);
border-radius: 20px;
pointer-events: none;
z-index: 1;
}
// Ensure content stays above overlay
> * {
position: relative;
z-index: 2;
}
}
select:not([size]) { select:not([size]) {
background-image: unset; background-image: unset;
} }
[type='text'], [type='text'],
[type='email'], [type='email'],
[type='url'], [type='url'],
@@ -51,39 +19,51 @@ textarea,
select { select {
border: unset; border: unset;
} }
.toggle-checkbox:checked { .toggle-checkbox:checked {
right: 0; right: 0;
border-color: rgb(125 211 252); border-color: rgb(125 211 252);
} }
.toggle-checkbox:checked + .toggle-label {
.toggle-checkbox:checked+.toggle-label {
background-color: rgb(125 211 252); background-color: rgb(125 211 252);
} }
:host ::ng-deep .ng-select.ng-select-single .ng-select-container { :host ::ng-deep .ng-select.ng-select-single .ng-select-container {
min-height: 52px; min-height: 52px;
border: none; border: none;
background-color: transparent; background-color: transparent;
.ng-value-container .ng-input { .ng-value-container .ng-input {
top: 12px; top: 12px;
} }
span.ng-arrow-wrapper { span.ng-arrow-wrapper {
display: none; display: none;
} }
} }
select { select {
color: #000; /* Standard-Textfarbe für das Dropdown */ color: #000;
/* Standard-Textfarbe für das Dropdown */
// background-color: #fff; /* Hintergrundfarbe für das Dropdown */ // background-color: #fff; /* Hintergrundfarbe für das Dropdown */
} }
select option { select option {
color: #000; /* Textfarbe für Dropdown-Optionen */ color: #000;
/* Textfarbe für Dropdown-Optionen */
} }
select.placeholder-selected { select.placeholder-selected {
color: #999; /* Farbe für den Platzhalter */ color: #6b7280;
/* gray-500 - besserer Kontrast für WCAG AA */
} }
input::placeholder { input::placeholder {
color: #555; /* Dunkleres Grau */ color: #555;
opacity: 1; /* Stellt sicher, dass die Deckkraft 100% ist */ /* Dunkleres Grau */
opacity: 1;
/* Stellt sicher, dass die Deckkraft 100% ist */
} }
/* Stellt sicher, dass die Optionen im Dropdown immer schwarz sind */ /* Stellt sicher, dass die Optionen im Dropdown immer schwarz sind */
@@ -91,10 +71,14 @@ select:focus option,
select:hover option { select:hover option {
color: #000 !important; color: #000 !important;
} }
input[type='text'][name='aiSearchText'] { input[type='text'][name='aiSearchText'] {
padding: 14px; /* Innerer Abstand */ padding: 14px;
font-size: 16px; /* Schriftgröße anpassen */ /* Innerer Abstand */
box-sizing: border-box; /* Padding und Border in die Höhe und Breite einrechnen */ font-size: 16px;
/* Schriftgröße anpassen */
box-sizing: border-box;
/* Padding und Border in die Höhe und Breite einrechnen */
height: 48px; height: 48px;
} }
@@ -177,6 +161,7 @@ select,
opacity: 0; opacity: 0;
transform: translateY(10px); transform: translateY(10px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
@@ -244,6 +229,7 @@ header {
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
&.text-blue-600.border.border-blue-600 { &.text-blue-600.border.border-blue-600 {
// Log In button // Log In button
&:hover { &:hover {
background-color: rgba(37, 99, 235, 0.05); background-color: rgba(37, 99, 235, 0.05);
@@ -256,6 +242,7 @@ header {
} }
&.bg-blue-600 { &.bg-blue-600 {
// Register button // Register button
&:hover { &:hover {
background-color: rgb(29, 78, 216); background-color: rgb(29, 78, 216);
@@ -269,3 +256,16 @@ header {
} }
} }
} }
// Screen reader only - visually hidden but accessible
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}

View File

@@ -1,12 +1,12 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, ElementRef, ViewChild } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { NgSelectModule } from '@ng-select/ng-select'; import { NgSelectModule } from '@ng-select/ng-select';
import { UntilDestroy } from '@ngneat/until-destroy'; import { UntilDestroy } from '@ngneat/until-destroy';
import { catchError, concat, distinctUntilChanged, Observable, of, Subject, switchMap, tap } from 'rxjs'; import { catchError, concat, distinctUntilChanged, Observable, of, Subject, switchMap, tap } from 'rxjs';
import { BusinessListingCriteria, CityAndStateResult, CommercialPropertyListingCriteria, GeoResult, KeycloakUser, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model'; import { BusinessListingCriteria, CityAndStateResult, CommercialPropertyListingCriteria, GeoResult, KeycloakUser, UserListingCriteria } from '../../../../../bizmatch-server/src/models/main.model';
import { FaqComponent, FAQItem } from '../../components/faq/faq.component'; import { FAQItem } from '../../components/faq/faq.component';
import { ModalService } from '../../components/search-modal/modal.service'; import { ModalService } from '../../components/search-modal/modal.service';
import { TooltipComponent } from '../../components/tooltip/tooltip.component'; import { TooltipComponent } from '../../components/tooltip/tooltip.component';
import { AiService } from '../../services/ai.service'; import { AiService } from '../../services/ai.service';
@@ -24,9 +24,10 @@ import { map2User } from '../../utils/utils';
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, RouterModule, NgSelectModule, FaqComponent], imports: [CommonModule, FormsModule, RouterModule, NgSelectModule],
templateUrl: './home.component.html', templateUrl: './home.component.html',
styleUrl: './home.component.scss', styleUrl: './home.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class HomeComponent { export class HomeComponent {
placeholders: string[] = ['Property close to Houston less than 10M', 'Franchise business in Austin price less than 500K']; placeholders: string[] = ['Property close to Houston less than 10M', 'Franchise business in Austin price less than 500K'];
@@ -125,57 +126,23 @@ export class HomeComponent {
// Set SEO meta tags for home page // Set SEO meta tags for home page
this.seoService.updateMetaTags({ this.seoService.updateMetaTags({
title: 'BizMatch - Buy & Sell Businesses and Commercial Properties', title: 'BizMatch - Buy & Sell Businesses and Commercial Properties',
description: 'Find profitable businesses for sale, commercial real estate, and franchise opportunities across the United States. Browse thousands of listings from verified sellers and brokers.', description: 'Buy and sell businesses, commercial properties, and franchises. Browse thousands of verified listings across the United States.',
keywords: 'business for sale, businesses for sale, buy business, sell business, commercial property, commercial real estate, franchise opportunities, business broker, business marketplace', keywords: 'business for sale, businesses for sale, buy business, sell business, commercial property, commercial real estate, franchise opportunities, business broker, business marketplace',
type: 'website' type: 'website'
}); });
// Add Organization schema for brand identity and FAQ schema for AEO // Add Organization schema for brand identity
// NOTE: FAQ schema removed because FAQ section is hidden (violates Google's visible content requirement)
// FAQ content is preserved in component for future use when FAQ section is made visible
const organizationSchema = this.seoService.generateOrganizationSchema(); const organizationSchema = this.seoService.generateOrganizationSchema();
const faqSchema = this.seoService.generateFAQPageSchema(
this.faqItems.map(item => ({
question: item.question,
answer: item.answer
}))
);
// Add HowTo schema for buying a business
const howToSchema = this.seoService.generateHowToSchema({
name: 'How to Buy a Business on BizMatch',
description: 'Step-by-step guide to finding and purchasing your ideal business through BizMatch marketplace',
totalTime: 'PT45M',
steps: [
{
name: 'Browse Business Listings',
text: 'Search through thousands of verified business listings using our advanced filters. Filter by industry, location, price range, revenue, and more to find businesses that match your criteria.'
},
{
name: 'Review Business Details',
text: 'Examine the business financials, including annual revenue, cash flow, asking price, and years established. Read the detailed business description and view photos of the operation.'
},
{
name: 'Contact the Seller',
text: 'Use our secure messaging system to contact the seller or business broker directly. Request additional information, financial documents, or schedule a site visit to see the business in person.'
},
{
name: 'Conduct Due Diligence',
text: 'Review all financial statements, tax returns, lease agreements, and legal documents. Verify the business information, inspect the physical location, and consult with legal and financial advisors.'
},
{
name: 'Make an Offer',
text: 'Submit a formal offer based on your valuation and due diligence findings. Negotiate terms including purchase price, payment structure, transition period, and any contingencies.'
},
{
name: 'Close the Transaction',
text: 'Work with attorneys and escrow services to finalize all legal documents, transfer ownership, and complete the purchase. The seller will transfer assets, train you on operations, and help with the transition.'
}
]
});
// Add SearchBox schema for Sitelinks Search // Add SearchBox schema for Sitelinks Search
const searchBoxSchema = this.seoService.generateSearchBoxSchema(); const searchBoxSchema = this.seoService.generateSearchBoxSchema();
this.seoService.injectMultipleSchemas([organizationSchema, faqSchema, howToSchema, searchBoxSchema]); // Inject schemas (FAQ schema excluded - content not visible to users)
this.seoService.injectMultipleSchemas([organizationSchema, searchBoxSchema]);
// Clear all filters and sort options on initial load // Clear all filters and sort options on initial load
this.filterStateService.resetCriteria('businessListings'); this.filterStateService.resetCriteria('businessListings');

View File

@@ -7,7 +7,7 @@
> >
<i class="fas fa-arrow-left text-lg"></i> <i class="fas fa-arrow-left text-lg"></i>
</button> </button>
<h1 class="text-3xl font-bold text-neutral-900 mb-6 pr-14">Privacy Statement</h1> <h1 class="text-3xl font-bold text-neutral-900 mb-6 pr-14">BizMatch Privacy Policy and Data Protection</h1>
<section id="content" role="main"> <section id="content" role="main">
<article class="post page"> <article class="post page">
@@ -21,7 +21,7 @@
Policy. Policy.
</p> </p>
<p class="mb-4"> <p class="mb-4">
We may update our Privacy Policy from time to time. Our Privacy Policy was last updated in Febuary 2018 and is effective upon acceptance for new users. By continuing to use our websites or otherwise We may update our Privacy Policy from time to time. Our Privacy Policy was last updated in February 2018 and is effective upon acceptance for new users. By continuing to use our websites or otherwise
continuing to deal with us, you accept this Privacy Policy. continuing to deal with us, you accept this Privacy Policy.
</p> </p>
<p class="font-bold mb-4 mt-6">Collection of personal information</p> <p class="font-bold mb-4 mt-6">Collection of personal information</p>

View File

@@ -7,7 +7,7 @@
> >
<i class="fas fa-arrow-left text-lg"></i> <i class="fas fa-arrow-left text-lg"></i>
</button> </button>
<h1 class="text-3xl font-bold text-neutral-900 mb-6 pr-14">Terms of Use</h1> <h1 class="text-3xl font-bold text-neutral-900 mb-6 pr-14">BizMatch Terms of Use and User Agreement</h1>
<section id="content" role="main"> <section id="content" role="main">
<article class="post page"> <article class="post page">

View File

@@ -13,11 +13,14 @@
</div> </div>
<!-- SEO-optimized heading --> <!-- SEO-optimized heading -->
<div class="mb-6"> <!-- <div class="mb-6">
<h1 class="text-3xl md:text-4xl font-bold text-neutral-900 mb-2">Professional Business Brokers & Advisors</h1> <h1 class="text-3xl md:text-4xl font-bold text-neutral-900 mb-2">Professional Business Brokers & Advisors</h1>
<p class="text-lg text-neutral-600">Connect with licensed business brokers, CPAs, attorneys, and other <p class="text-lg text-neutral-600">Connect with licensed business brokers, CPAs, attorneys, and other
professionals across the United States.</p> professionals across the United States.</p>
<div class="mt-4 text-base text-neutral-700 max-w-4xl">
<p>BizMatch connects business buyers and sellers with experienced professionals. Find qualified business brokers to help with your business sale or acquisition. Our platform features verified professionals including business brokers, M&A advisors, CPAs, and attorneys specializing in business transactions across the United States. Whether you're looking to buy or sell a business, our network of professionals can guide you through the process.</p>
</div> </div>
</div> -->
<!-- Mobile Filter Button --> <!-- Mobile Filter Button -->
<div class="md:hidden mb-4"> <div class="md:hidden mb-4">
@@ -41,14 +44,14 @@
@if(currentUser) { @if(currentUser) {
<button type="button" class="bg-white rounded-full p-2 shadow-lg transition-colors" <button type="button" class="bg-white rounded-full p-2 shadow-lg transition-colors"
[class.bg-red-50]="isFavorite(user)" [class.bg-red-50]="isFavorite(user)"
[title]="isFavorite(user) ? 'Remove from favorites' : 'Save to favorites'" [attr.aria-label]="isFavorite(user) ? 'Remove from favorites' : 'Save to favorites'"
(click)="toggleFavorite($event, user)"> (click)="toggleFavorite($event, user)">
<i <i
[class]="isFavorite(user) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i> [class]="isFavorite(user) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i>
</button> </button>
} }
<button type="button" class="bg-white rounded-full p-2 shadow-lg hover:bg-blue-50 transition-colors" <button type="button" class="bg-white rounded-full p-2 shadow-lg hover:bg-blue-50 transition-colors"
title="Share professional" (click)="shareProfessional($event, user)"> aria-label="Share professional" (click)="shareProfessional($event, user)">
<i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i> <i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i>
</button> </button>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { CommonModule, NgOptimizedImage } from '@angular/common'; import { CommonModule, NgOptimizedImage } from '@angular/common';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { UntilDestroy } from '@ngneat/until-destroy'; import { UntilDestroy } from '@ngneat/until-destroy';
@@ -29,6 +29,7 @@ import { assignProperties, resetUserListingCriteria, map2User } from '../../../u
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, CustomerSubTypeComponent, BreadcrumbsComponent, SearchModalBrokerComponent], imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, CustomerSubTypeComponent, BreadcrumbsComponent, SearchModalBrokerComponent],
templateUrl: './broker-listings.component.html', templateUrl: './broker-listings.component.html',
styleUrls: ['./broker-listings.component.scss', '../../pages.scss'], styleUrls: ['./broker-listings.component.scss', '../../pages.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class BrokerListingsComponent implements OnInit, OnDestroy { export class BrokerListingsComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();

View File

@@ -14,9 +14,12 @@
<!-- SEO-optimized heading --> <!-- SEO-optimized heading -->
<div class="mb-6"> <div class="mb-6">
<h1 class="text-3xl md:text-4xl font-bold text-neutral-900 mb-2">Businesses for Sale</h1> <h1 class="text-3xl md:text-4xl font-bold text-neutral-900 mb-2">Businesses for Sale - Find Your Next Business Opportunity</h1>
<p class="text-lg text-neutral-600">Discover profitable business opportunities across the United States. Browse <p class="text-lg text-neutral-600">Discover profitable business opportunities across the State of Texas. Browse
verified listings from business owners and brokers.</p> verified listings from business owners and brokers.</p>
<div class="mt-4 text-base text-neutral-700 max-w-4xl">
<p>BizMatch features a curated selection of businesses for sale across diverse industries and price ranges. Browse opportunities in sectors like restaurants, retail, franchises, services, e-commerce, and manufacturing. Each listing includes financial details, years established, location information, and seller contact details. Our marketplace connects business buyers with sellers and brokers nationwide, making it easy to find your next business opportunity.</p>
</div>
</div> </div>
<!-- Loading Skeleton --> <!-- Loading Skeleton -->
@@ -65,14 +68,14 @@
@if(user) { @if(user) {
<button class="bg-white rounded-full p-2 shadow-lg transition-colors" <button class="bg-white rounded-full p-2 shadow-lg transition-colors"
[class.bg-red-50]="isFavorite(listing)" [class.bg-red-50]="isFavorite(listing)"
[title]="isFavorite(listing) ? 'Remove from favorites' : 'Save to favorites'" [attr.aria-label]="isFavorite(listing) ? 'Remove from favorites' : 'Save to favorites'"
(click)="toggleFavorite($event, listing)"> (click)="toggleFavorite($event, listing)">
<i <i
[class]="isFavorite(listing) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i> [class]="isFavorite(listing) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i>
</button> </button>
} }
<button type="button" class="bg-white rounded-full p-2 shadow-lg hover:bg-blue-50 transition-colors" <button type="button" class="bg-white rounded-full p-2 shadow-lg hover:bg-blue-50 transition-colors"
title="Share listing" (click)="shareListing($event, listing)"> aria-label="Share listing" (click)="shareListing($event, listing)">
<i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i> <i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i>
</button> </button>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { UntilDestroy } from '@ngneat/until-destroy'; import { UntilDestroy } from '@ngneat/until-destroy';
@@ -31,6 +31,7 @@ import { map2User } from '../../../utils/utils';
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, SearchModalComponent, LazyLoadImageDirective, BreadcrumbsComponent], imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, SearchModalComponent, LazyLoadImageDirective, BreadcrumbsComponent],
templateUrl: './business-listings.component.html', templateUrl: './business-listings.component.html',
styleUrls: ['./business-listings.component.scss', '../../pages.scss'], styleUrls: ['./business-listings.component.scss', '../../pages.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class BusinessListingsComponent implements OnInit, OnDestroy { export class BusinessListingsComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
@@ -86,8 +87,8 @@ export class BusinessListingsComponent implements OnInit, OnDestroy {
// Set SEO meta tags for business listings page // Set SEO meta tags for business listings page
this.seoService.updateMetaTags({ this.seoService.updateMetaTags({
title: 'Businesses for Sale - Find Profitable Business Opportunities | BizMatch', title: 'Businesses for Sale | BizMatch',
description: 'Browse thousands of businesses for sale across the United States. Find restaurants, franchises, retail stores, and more. Verified listings from business owners and brokers.', description: 'Browse thousands of businesses for sale including restaurants, franchises, and retail stores. Verified listings nationwide.',
keywords: 'businesses for sale, buy a business, business opportunities, franchise for sale, restaurant for sale, retail business for sale, business broker listings', keywords: 'businesses for sale, buy a business, business opportunities, franchise for sale, restaurant for sale, retail business for sale, business broker listings',
type: 'website' type: 'website'
}); });

View File

@@ -15,7 +15,10 @@
<!-- SEO-optimized heading --> <!-- SEO-optimized heading -->
<div class="mb-6"> <div class="mb-6">
<h1 class="text-3xl md:text-4xl font-bold text-neutral-900 mb-2">Commercial Properties for Sale</h1> <h1 class="text-3xl md:text-4xl font-bold text-neutral-900 mb-2">Commercial Properties for Sale</h1>
<p class="text-lg text-neutral-600">Find office buildings, retail spaces, warehouses, and industrial properties across the United States. Investment opportunities from verified sellers and commercial real estate brokers.</p> <p class="text-lg text-neutral-600">Discover selected investment properties and commercial spaces. Connect with verified sellers and brokers to find your next asset.</p>
<div class="mt-4 text-base text-neutral-700 max-w-4xl">
<p>BizMatch presents commercial real estate opportunities for sale or lease. View investment properties with detailed information on square footage, zoning, pricing, and location. Our platform connects property buyers and investors directly with sellers and commercial real estate brokers, focusing on transparent and valuable transactions.</p>
</div>
</div> </div>
@if(listings?.length > 0) { @if(listings?.length > 0) {
@@ -30,13 +33,13 @@
class="bg-white rounded-full p-2 shadow-lg transition-colors" class="bg-white rounded-full p-2 shadow-lg transition-colors"
[class.bg-red-50]="isFavorite(listing)" [class.bg-red-50]="isFavorite(listing)"
[class.opacity-100]="isFavorite(listing)" [class.opacity-100]="isFavorite(listing)"
[title]="isFavorite(listing) ? 'Remove from favorites' : 'Save to favorites'" [attr.aria-label]="isFavorite(listing) ? 'Remove from favorites' : 'Save to favorites'"
(click)="toggleFavorite($event, listing)"> (click)="toggleFavorite($event, listing)">
<i [class]="isFavorite(listing) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i> <i [class]="isFavorite(listing) ? 'fas fa-heart text-red-500' : 'far fa-heart text-red-500 hover:scale-110 transition-transform'"></i>
</button> </button>
} }
<button type="button" class="bg-white rounded-full p-2 shadow-lg hover:bg-blue-50 transition-colors" <button type="button" class="bg-white rounded-full p-2 shadow-lg hover:bg-blue-50 transition-colors"
title="Share property" (click)="shareProperty($event, listing)"> aria-label="Share property" (click)="shareProperty($event, listing)">
<i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i> <i class="fas fa-share-alt text-blue-500 hover:scale-110 transition-transform"></i>
</button> </button>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { UntilDestroy } from '@ngneat/until-destroy'; import { UntilDestroy } from '@ngneat/until-destroy';
@@ -30,6 +30,7 @@ import { map2User } from '../../../utils/utils';
imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, SearchModalCommercialComponent, LazyLoadImageDirective, BreadcrumbsComponent], imports: [CommonModule, FormsModule, RouterModule, PaginatorComponent, SearchModalCommercialComponent, LazyLoadImageDirective, BreadcrumbsComponent],
templateUrl: './commercial-property-listings.component.html', templateUrl: './commercial-property-listings.component.html',
styleUrls: ['./commercial-property-listings.component.scss', '../../pages.scss'], styleUrls: ['./commercial-property-listings.component.scss', '../../pages.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class CommercialPropertyListingsComponent implements OnInit, OnDestroy { export class CommercialPropertyListingsComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>(); private destroy$ = new Subject<void>();
@@ -83,8 +84,8 @@ export class CommercialPropertyListingsComponent implements OnInit, OnDestroy {
// Set SEO meta tags for commercial property listings page // Set SEO meta tags for commercial property listings page
this.seoService.updateMetaTags({ this.seoService.updateMetaTags({
title: 'Commercial Properties for Sale - Office, Retail, Industrial Real Estate | BizMatch', title: 'Commercial Properties for Sale | 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.', description: 'Browse commercial real estate including offices, retail, warehouses, and industrial properties. Verified investment opportunities.',
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', 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' type: 'website'
}); });

View File

@@ -18,16 +18,16 @@
<div class="w-20 h-20 w-full rounded-md flex items-center justify-center relative"> <div class="w-20 h-20 w-full rounded-md flex items-center justify-center relative">
@if(user?.hasCompanyLogo){ @if(user?.hasCompanyLogo){
<img src="{{ companyLogoUrl }}" alt="Company logo" class="max-w-full max-h-full" /> <img src="{{ companyLogoUrl }}" alt="Company logo" class="max-w-full max-h-full" />
<div <button type="button" aria-label="Delete company logo"
class="absolute top-[-0.5rem] right-[0rem] bg-white rounded-full p-1 drop-shadow-custom-bg hover:cursor-pointer" class="absolute top-[-0.5rem] right-[0rem] bg-white rounded-full p-2 drop-shadow-custom-bg cursor-pointer min-w-[32px] min-h-[32px] flex items-center justify-center"
(click)="deleteConfirm('logo')"> (click)="deleteConfirm('logo')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
class="w-4 h-4 text-gray-600"> class="w-4 h-4 text-gray-600">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</div> </button>
} @else { } @else {
<img src="/assets/images/placeholder.png" class="max-w-full max-h-full" /> <img src="/assets/images/placeholder.png" class="max-w-full max-h-full" alt="" width="80" height="80" />
} }
</div> </div>
<button type="button" <button type="button"
@@ -41,16 +41,16 @@
<div class="w-20 h-20 w-full rounded-md flex items-center justify-center relative"> <div class="w-20 h-20 w-full rounded-md flex items-center justify-center relative">
@if(user?.hasProfile){ @if(user?.hasProfile){
<img src="{{ profileUrl }}" alt="Profile picture" class="max-w-full max-h-full" /> <img src="{{ profileUrl }}" alt="Profile picture" class="max-w-full max-h-full" />
<div <button type="button" aria-label="Delete profile picture"
class="absolute top-[-0.5rem] right-[0rem] bg-white rounded-full p-1 drop-shadow-custom-bg hover:cursor-pointer" class="absolute top-[-0.5rem] right-[0rem] bg-white rounded-full p-2 drop-shadow-custom-bg cursor-pointer min-w-[32px] min-h-[32px] flex items-center justify-center"
(click)="deleteConfirm('profile')"> (click)="deleteConfirm('profile')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
class="w-4 h-4 text-gray-600"> class="w-4 h-4 text-gray-600">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</div> </button>
} @else { } @else {
<img src="/assets/images/placeholder.png" class="max-w-full max-h-full" /> <img src="/assets/images/placeholder.png" class="max-w-full max-h-full" alt="" width="80" height="80" />
} }
</div> </div>
<button type="button" <button type="button"
@@ -129,23 +129,16 @@
mask="(000) 000-0000"></app-validated-input> mask="(000) 000-0000"></app-validated-input>
<app-validated-input label="Company Website" name="companyWebsite" <app-validated-input label="Company Website" name="companyWebsite"
[(ngModel)]="user.companyWebsite"></app-validated-input> [(ngModel)]="user.companyWebsite"></app-validated-input>
<!-- <app-validated-input label="Company Location" name="companyLocation" [(ngModel)]="user.companyLocation"></app-validated-input> -->
<!-- <app-validated-city label="Company Location" name="location" [(ngModel)]="user.location"></app-validated-city> -->
<app-validated-location label="Company Location" name="location" <app-validated-location label="Company Location" name="location"
[(ngModel)]="user.location"></app-validated-location> [(ngModel)]="user.location"></app-validated-location>
</div> </div>
<!-- <div>
<label for="companyOverview" class="block text-sm font-medium text-gray-700">Company Overview</label>
<quill-editor [(ngModel)]="user.companyOverview" name="companyOverview" [modules]="quillModules"></quill-editor>
</div> -->
<div> <div>
<app-validated-quill label="Company Overview" name="companyOverview" <app-validated-quill label="Company Overview" name="companyOverview"
[(ngModel)]="user.companyOverview"></app-validated-quill> [(ngModel)]="user.companyOverview"></app-validated-quill>
</div> </div>
<div> <div>
<!-- <label for="offeredServices" class="block text-sm font-medium text-gray-700">Services We Offer</label>
<quill-editor [(ngModel)]="user.offeredServices" name="offeredServices" [modules]="quillModules"></quill-editor> -->
<app-validated-quill label="Services We Offer" name="offeredServices" <app-validated-quill label="Services We Offer" name="offeredServices"
[(ngModel)]="user.offeredServices"></app-validated-quill> [(ngModel)]="user.offeredServices"></app-validated-quill>
</div> </div>

View File

@@ -1,7 +1,7 @@
import { DatePipe, TitleCasePipe } from '@angular/common'; import { DatePipe, TitleCasePipe } from '@angular/common';
import { ChangeDetectorRef, Component } from '@angular/core'; import { ChangeDetectorRef, Component } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { faTrash } from '@fortawesome/free-solid-svg-icons'; import { APP_ICONS } from '../../../utils/fontawesome-icons';
import { NgSelectModule } from '@ng-select/ng-select'; import { NgSelectModule } from '@ng-select/ng-select';
import { QuillModule } from 'ngx-quill'; import { QuillModule } from 'ngx-quill';
import { lastValueFrom } from 'rxjs'; import { lastValueFrom } from 'rxjs';
@@ -45,9 +45,15 @@ import { TOOLBAR_OPTIONS } from '../../utils/defaults';
ValidatedCountyComponent, ValidatedCountyComponent,
ValidatedLocationComponent, ValidatedLocationComponent,
], ],
providers: [TitleCasePipe, DatePipe], providers: [
TitleCasePipe,
DatePipe
],
templateUrl: './account.component.html', templateUrl: './account.component.html',
styleUrl: './account.component.scss', styleUrls: [
'./account.component.scss',
'../../../../../node_modules/quill/dist/quill.snow.css'
],
}) })
export class AccountComponent { export class AccountComponent {
id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined; id: string | undefined = this.activatedRoute.snapshot.params['id'] as string | undefined;
@@ -58,10 +64,7 @@ export class AccountComponent {
environment = environment; environment = environment;
editorModules = TOOLBAR_OPTIONS; editorModules = TOOLBAR_OPTIONS;
env = environment; env = environment;
faTrash = faTrash; faTrash = APP_ICONS.faTrash;
quillModules = {
toolbar: [['bold', 'italic', 'underline', 'strike'], [{ list: 'ordered' }, { list: 'bullet' }], [{ header: [1, 2, 3, 4, 5, 6, false] }], [{ color: [] }, { background: [] }], ['clean']],
};
uploadParams: UploadParams; uploadParams: UploadParams;
validationMessages: ValidationMessage[] = []; validationMessages: ValidationMessage[] = [];
customerTypeOptions: Array<{ value: string; label: string }> = []; customerTypeOptions: Array<{ value: string; label: string }> = [];

View File

@@ -6,7 +6,7 @@ import { SelectOptionsService } from '../../../services/select-options.service';
import { map2User, routeListingWithState } from '../../../utils/utils'; import { map2User, routeListingWithState } from '../../../utils/utils';
import { DragDropModule } from '@angular/cdk/drag-drop'; import { DragDropModule } from '@angular/cdk/drag-drop';
import { faTrash } from '@fortawesome/free-solid-svg-icons'; import { APP_ICONS } from '../../../utils/fontawesome-icons';
import { QuillModule } from 'ngx-quill'; import { QuillModule } from 'ngx-quill';
import { NgSelectModule } from '@ng-select/ng-select'; import { NgSelectModule } from '@ng-select/ng-select';
@@ -47,9 +47,11 @@ import { TOOLBAR_OPTIONS } from '../../utils/defaults';
ValidatedTextareaComponent, ValidatedTextareaComponent,
ValidatedLocationComponent, ValidatedLocationComponent,
], ],
providers: [],
templateUrl: './edit-business-listing.component.html', templateUrl: './edit-business-listing.component.html',
styleUrl: './edit-business-listing.component.scss', styleUrls: [
'./edit-business-listing.component.scss',
'../../../../../node_modules/quill/dist/quill.snow.css'
],
}) })
export class EditBusinessListingComponent { export class EditBusinessListingComponent {
listingsCategory = 'business'; listingsCategory = 'business';
@@ -64,7 +66,7 @@ export class EditBusinessListingComponent {
config = { aspectRatio: 16 / 9 }; config = { aspectRatio: 16 / 9 };
editorModules = TOOLBAR_OPTIONS; editorModules = TOOLBAR_OPTIONS;
draggedImage: ImageProperty; draggedImage: ImageProperty;
faTrash = faTrash; faTrash = APP_ICONS.faTrash;
data: CommercialPropertyListing; data: CommercialPropertyListing;
typesOfBusiness = []; typesOfBusiness = [];
quillModules = { quillModules = {

View File

@@ -58,7 +58,7 @@
(click)="uploadPropertyPicture()" (click)="uploadPropertyPicture()"
class="flex items-center justify-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 bg-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" class="flex items-center justify-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 bg-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
> >
<svg class="mr-2 h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg class="mr-2 h-5 w-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg> </svg>
Upload Upload

View File

@@ -7,7 +7,7 @@ import { map2User, routeListingWithState } from '../../../utils/utils';
import { DragDropModule } from '@angular/cdk/drag-drop'; import { DragDropModule } from '@angular/cdk/drag-drop';
import { ViewportRuler } from '@angular/cdk/scrolling'; import { ViewportRuler } from '@angular/cdk/scrolling';
import { faTrash } from '@fortawesome/free-solid-svg-icons'; import { APP_ICONS } from '../../../utils/fontawesome-icons';
import { NgSelectModule } from '@ng-select/ng-select'; import { NgSelectModule } from '@ng-select/ng-select';
import { NgxCurrencyDirective } from 'ngx-currency'; import { NgxCurrencyDirective } from 'ngx-currency';
import { ImageCropperComponent } from 'ngx-image-cropper'; import { ImageCropperComponent } from 'ngx-image-cropper';
@@ -53,9 +53,11 @@ import { TOOLBAR_OPTIONS } from '../../utils/defaults';
ValidatedLocationComponent, ValidatedLocationComponent,
ImageCropAndUploadComponent, ImageCropAndUploadComponent,
], ],
providers: [],
templateUrl: './edit-commercial-property-listing.component.html', templateUrl: './edit-commercial-property-listing.component.html',
styleUrl: './edit-commercial-property-listing.component.scss', styleUrls: [
'./edit-commercial-property-listing.component.scss',
'../../../../../node_modules/quill/dist/quill.snow.css'
],
}) })
export class EditCommercialPropertyListingComponent { export class EditCommercialPropertyListingComponent {
@ViewChild('fileInput') fileInput!: ElementRef<HTMLInputElement>; @ViewChild('fileInput') fileInput!: ElementRef<HTMLInputElement>;
@@ -91,7 +93,7 @@ export class EditCommercialPropertyListingComponent {
editorModules = TOOLBAR_OPTIONS; editorModules = TOOLBAR_OPTIONS;
draggedImage: ImageProperty; draggedImage: ImageProperty;
faTrash = faTrash; faTrash = APP_ICONS.faTrash;
suggestions: string[] | undefined; suggestions: string[] | undefined;
data: BusinessListing; data: BusinessListing;
userId: string; userId: string;

View File

@@ -1,5 +1,5 @@
import { Injectable, inject, PLATFORM_ID } from '@angular/core'; import { Injectable, inject, PLATFORM_ID, Renderer2, RendererFactory2 } from '@angular/core';
import { isPlatformBrowser } from '@angular/common'; import { isPlatformBrowser, DOCUMENT } from '@angular/common';
import { Meta, Title } from '@angular/platform-browser'; import { Meta, Title } from '@angular/platform-browser';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@@ -22,10 +22,16 @@ export class SeoService {
private router = inject(Router); private router = inject(Router);
private platformId = inject(PLATFORM_ID); private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId); private isBrowser = isPlatformBrowser(this.platformId);
private document = inject(DOCUMENT);
private renderer: Renderer2;
private readonly defaultImage = 'https://biz-match.com/assets/images/bizmatch-og-image.jpg'; private readonly defaultImage = 'https://www.bizmatch.net/assets/images/bizmatch-og-image.jpg';
private readonly siteName = 'BizMatch'; private readonly siteName = 'BizMatch';
private readonly baseUrl = 'https://biz-match.com'; private readonly baseUrl = 'https://www.bizmatch.net';
constructor(rendererFactory: RendererFactory2) {
this.renderer = rendererFactory.createRenderer(null, null);
}
/** /**
* Get the base URL for SEO purposes * Get the base URL for SEO purposes
@@ -109,20 +115,18 @@ export class SeoService {
} }
/** /**
* Update canonical URL * Update canonical URL (SSR-compatible using Renderer2)
*/ */
private updateCanonicalUrl(url: string): void { private updateCanonicalUrl(url: string): void {
if (!this.isBrowser) return; let link: HTMLLinkElement | null = this.document.querySelector('link[rel="canonical"]');
let link: HTMLLinkElement | null = document.querySelector('link[rel="canonical"]');
if (link) { if (link) {
link.setAttribute('href', url); this.renderer.setAttribute(link, 'href', url);
} else { } else {
link = document.createElement('link'); link = this.renderer.createElement('link');
link.setAttribute('rel', 'canonical'); this.renderer.setAttribute(link, 'rel', 'canonical');
link.setAttribute('href', url); this.renderer.setAttribute(link, 'href', url);
document.head.appendChild(link); this.renderer.appendChild(this.document.head, link);
} }
} }
@@ -269,32 +273,40 @@ export class SeoService {
} }
/** /**
* Inject JSON-LD structured data into page * Inject JSON-LD structured data into page (SSR-compatible using Renderer2)
*/ */
injectStructuredData(schema: object): void { injectStructuredData(schema: object): void {
if (!this.isBrowser) return; // Clear existing schema scripts with the same type
this.removeAllSchemas();
// Remove existing schema script // Create new script element using Renderer2 (works in both SSR and browser)
const existingScript = document.querySelector('script[type="application/ld+json"]'); const script = this.renderer.createElement('script');
if (existingScript) { this.renderer.setAttribute(script, 'type', 'application/ld+json');
existingScript.remove(); this.renderer.setAttribute(script, 'data-schema', 'true');
}
// Add new schema script // Create text node with schema JSON
const script = document.createElement('script'); const schemaText = this.renderer.createText(JSON.stringify(schema));
script.type = 'application/ld+json'; this.renderer.appendChild(script, schemaText);
script.text = JSON.stringify(schema);
document.head.appendChild(script); // Append to document head
this.renderer.appendChild(this.document.head, script);
} }
/** /**
* Clear all structured data * Remove all schema scripts (internal helper, SSR-compatible)
*/
private removeAllSchemas(): void {
const existingScripts = this.document.querySelectorAll('script[data-schema="true"]');
existingScripts.forEach(script => {
this.renderer.removeChild(this.document.head, script);
});
}
/**
* Clear all structured data (SSR-compatible)
*/ */
clearStructuredData(): void { clearStructuredData(): void {
if (!this.isBrowser) return; this.removeAllSchemas();
const scripts = document.querySelectorAll('script[type="application/ld+json"]');
scripts.forEach(script => script.remove());
} }
/** /**
@@ -418,6 +430,7 @@ export class SeoService {
/** /**
* Generate Organization schema for the company * Generate Organization schema for the company
* Enhanced for Knowledge Graph and entity verification
*/ */
generateOrganizationSchema(): object { generateOrganizationSchema(): object {
return { return {
@@ -427,18 +440,47 @@ export class SeoService {
'url': this.baseUrl, 'url': this.baseUrl,
'logo': `${this.baseUrl}/assets/images/bizmatch-logo.png`, '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.', 'description': 'BizMatch is the leading marketplace for buying and selling businesses and commercial properties across the United States.',
// Physical address for entity verification
'address': {
'@type': 'PostalAddress',
'streetAddress': '1001 Blucher Street',
'addressLocality': 'Corpus Christi',
'addressRegion': 'TX',
'postalCode': '78401',
'addressCountry': 'US'
},
// Contact information (E.164 format)
'telephone': '+1-800-840-6025',
'email': 'info@bizmatch.net',
// Social media and entity verification
'sameAs': [ 'sameAs': [
'https://www.facebook.com/bizmatch', 'https://www.facebook.com/bizmatch',
'https://www.linkedin.com/company/bizmatch', 'https://www.linkedin.com/company/bizmatch',
'https://twitter.com/bizmatch' 'https://twitter.com/bizmatch'
// Future: Add Wikidata, Crunchbase, Wikipedia when available
], ],
// Enhanced contact point
'contactPoint': { 'contactPoint': {
'@type': 'ContactPoint', '@type': 'ContactPoint',
'telephone': '+1-800-BIZ-MATCH', 'telephone': '+1-800-840-6025',
'contactType': 'Customer Service', 'contactType': 'Customer Service',
'areaServed': 'US', 'areaServed': 'US',
'availableLanguage': 'English' 'availableLanguage': 'English',
} 'email': 'info@bizmatch.net'
},
// Business details for Knowledge Graph
'foundingDate': '2020',
'knowsAbout': [
'Business Brokerage',
'Commercial Real Estate',
'Business Valuation',
'Franchise Opportunities'
]
}; };
} }
@@ -486,20 +528,21 @@ export class SeoService {
} }
/** /**
* Inject multiple structured data schemas * Inject multiple structured data schemas (SSR-compatible using Renderer2)
*/ */
injectMultipleSchemas(schemas: object[]): void { injectMultipleSchemas(schemas: object[]): void {
if (!this.isBrowser) return; // Clear existing schema scripts
this.removeAllSchemas();
// Remove existing schema scripts // Add new schema scripts using Renderer2
this.clearStructuredData();
// Add new schema scripts
schemas.forEach(schema => { schemas.forEach(schema => {
const script = document.createElement('script'); const script = this.renderer.createElement('script');
script.type = 'application/ld+json'; this.renderer.setAttribute(script, 'type', 'application/ld+json');
script.text = JSON.stringify(schema); this.renderer.setAttribute(script, 'data-schema', 'true');
document.head.appendChild(script);
const schemaText = this.renderer.createText(JSON.stringify(schema));
this.renderer.appendChild(script, schemaText);
this.renderer.appendChild(this.document.head, script);
}); });
} }

View File

@@ -11,7 +11,7 @@ export interface SitemapUrl {
providedIn: 'root' providedIn: 'root'
}) })
export class SitemapService { export class SitemapService {
private readonly baseUrl = 'https://biz-match.com'; private readonly baseUrl = 'https://www.bizmatch.net';
/** /**
* Generate XML sitemap content * Generate XML sitemap content

View File

@@ -0,0 +1,19 @@
import {
faArrowRight,
faEnvelope,
faLock,
faTimes,
faTrash,
faUserGear,
faUserPlus
} from '@fortawesome/free-solid-svg-icons';
export const APP_ICONS = {
faArrowRight,
faEnvelope,
faLock,
faTimes,
faTrash,
faUserGear,
faUserPlus,
};

View File

@@ -0,0 +1,25 @@
import { environment } from '../../environments/environment';
// Lightweight logger implementation for both dev and production
// Avoids dynamic require() which causes build issues
const createLoggerImpl = (name: string) => ({
info: (...args: any[]) => {
if (!environment.production) {
console.log(`[${name}]`, ...args);
}
},
warn: (...args: any[]) => console.warn(`[${name}]`, ...args),
error: (...args: any[]) => console.error(`[${name}]`, ...args),
debug: (...args: any[]) => {
if (!environment.production) {
console.debug(`[${name}]`, ...args);
}
},
trace: (...args: any[]) => {
if (!environment.production) {
console.trace(`[${name}]`, ...args);
}
}
});
export const createLogger = createLoggerImpl;

View File

@@ -1,5 +1,5 @@
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { ConsoleFormattedStream, INFO, createLogger as _createLogger, stdSerializers } from 'browser-bunyan'; import { createLogger as _createLogger } from './logger';
import { jwtDecode } from 'jwt-decode'; import { jwtDecode } from 'jwt-decode';
import onChange from 'on-change'; import onChange from 'on-change';
import { SortByOptions, User } from '../../../../bizmatch-server/src/models/db.model'; import { SortByOptions, User } from '../../../../bizmatch-server/src/models/db.model';
@@ -141,14 +141,8 @@ export function createMailInfo(user?: User): MailInfo {
listing: null, listing: null,
}; };
} }
export function createLogger(name: string, level: number = INFO, options: any = {}) { export function createLogger(name: string, level?: number, options?: any) {
return _createLogger({ return _createLogger(name);
name,
streams: [{ level, stream: new ConsoleFormattedStream() }],
serializers: stdSerializers,
src: true,
...options,
});
} }
export function formatPhoneNumber(phone: string): string { export function formatPhoneNumber(phone: string): string {
const cleaned = ('' + phone).replace(/\D/g, ''); const cleaned = ('' + phone).replace(/\D/g, '');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 MiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 667 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

View File

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

View File

@@ -1,8 +1,20 @@
// SSR-safe: check if window exists (it doesn't on server-side) // SSR-safe: check if window exists
const hostname = typeof window !== 'undefined' ? window.location.hostname : 'localhost'; // Im Browser nehmen wir den aktuellen Host (z.B. localhost).
// Auf dem Server (SSR in Docker) nehmen wir 'bizmatch-app' (der Name des Backend-Containers).
const isBrowser = typeof window !== 'undefined' && window.navigator.userAgent !== 'node';
const hostname = isBrowser ? window.location.hostname : 'bizmatch-app';
// Im Server-Modus nutzen wir den internen Docker-Namen
const internalUrl = 'http://bizmatch-app:3001';
// Im Browser-Modus die öffentliche URL
const publicUrl = 'https://api.bizmatch.net';
const calculatedApiBaseUrl = isBrowser ? publicUrl : internalUrl;
// WICHTIG: Port anpassen!
// Lokal läuft das Backend auf 3001. Im Docker Container auch auf 3001.
// Deine alte Config hatte hier :4200 stehen, das war falsch (das ist das Frontend).
export const environment_base = { export const environment_base = {
// apiBaseUrl: 'http://localhost:3000', // apiBaseUrl: 'http://localhost:3000',
apiBaseUrl: `http://${hostname}:4200`, // GETTER FUNCTION für die API URL (besser als statischer String für diesen Fall)
apiBaseUrl: calculatedApiBaseUrl,
imageBaseUrl: 'https://dev.bizmatch.net', imageBaseUrl: 'https://dev.bizmatch.net',
buildVersion: '<BUILD_VERSION>', buildVersion: '<BUILD_VERSION>',
mailinfoUrl: 'https://dev.bizmatch.net', mailinfoUrl: 'https://dev.bizmatch.net',

View File

@@ -1,10 +1,15 @@
import { environment_base } from './environment.base'; import { environment_base } from './environment.base';
export const environment = environment_base; export const environment = { ...environment_base }; // Kopie erstellen
environment.production = true; environment.production = true;
// WICHTIG: Diese Zeile auskommentieren, solange du lokal testest!
// Sonst greift er immer aufs Internet zu, statt auf deinen lokalen Docker-Container.
environment.apiBaseUrl = 'https://api.bizmatch.net'; environment.apiBaseUrl = 'https://api.bizmatch.net';
environment.mailinfoUrl = 'https://www.bizmatch.net'; environment.mailinfoUrl = 'https://www.bizmatch.net';
environment.imageBaseUrl = 'https://api.bizmatch.net'; environment.imageBaseUrl = 'https://www.bizmatch.net';// Ggf. auch auskommentieren, wenn Bilder lokal liegen
environment.POSTHOG_KEY = 'phc_eUIcIq0UPVzEDtZLy78klKhGudyagBz3goDlKx8SQFe'; environment.POSTHOG_KEY = 'phc_eUIcIq0UPVzEDtZLy78klKhGudyagBz3goDlKx8SQFe';
environment.POSTHOG_HOST = 'https://eu.i.posthog.com'; environment.POSTHOG_HOST = 'https://eu.i.posthog.com';

View File

@@ -35,6 +35,9 @@
<!-- Preload critical assets --> <!-- Preload critical assets -->
<link rel="preload" as="image" href="/assets/images/header-logo.png" type="image/png" /> <link rel="preload" as="image" href="/assets/images/header-logo.png" type="image/png" />
<!-- Hero background is LCP element - preload with high priority -->
<link rel="preload" as="image" href="/assets/images/flags_bg.avif" type="image/avif" fetchpriority="high" />
<link rel="preload" as="image" href="/assets/images/flags_bg.jpg" imagesrcset="/assets/images/flags_bg.jpg" type="image/jpeg" />
<!-- Prefetch common assets --> <!-- Prefetch common assets -->
<link rel="prefetch" as="image" href="/assets/images/business_logo.png" /> <link rel="prefetch" as="image" href="/assets/images/business_logo.png" />
@@ -58,6 +61,12 @@
<link rel="icon" href="/assets/cropped-Favicon-32x32.png" sizes="32x32" /> <link rel="icon" href="/assets/cropped-Favicon-32x32.png" sizes="32x32" />
<link rel="icon" href="/assets/cropped-Favicon-192x192.png" sizes="192x192" /> <link rel="icon" href="/assets/cropped-Favicon-192x192.png" sizes="192x192" />
<link rel="apple-touch-icon" href="/assets/cropped-Favicon-180x180.png" /> <link rel="apple-touch-icon" href="/assets/cropped-Favicon-180x180.png" />
<!-- Schema.org Structured Data -->
<!-- Note: Organization and WebSite schemas are now injected dynamically by SeoService -->
<!-- with more complete data (telephone, foundingDate, knowsAbout, dual search actions) -->
</head> </head>
<body class="flex flex-col min-h-screen"> <body class="flex flex-col min-h-screen">

View File

@@ -1,6 +1,6 @@
# robots.txt for BizMatch - Business Marketplace # robots.txt for BizMatch - Business Marketplace
# https://biz-match.com # https://www.bizmatch.net
# Last updated: 2026-01-02 # Last updated: 2026-02-03
# =========================================== # ===========================================
# Default rules for all crawlers # Default rules for all crawlers
@@ -37,6 +37,9 @@ Disallow: /emailUs
Disallow: /api/ Disallow: /api/
Disallow: /bizmatch/ Disallow: /bizmatch/
# Disallow Cloudflare internal paths (prevents 404 errors in crawl reports)
Disallow: /cdn-cgi/
# Disallow search result pages with parameters (to avoid duplicate content) # Disallow search result pages with parameters (to avoid duplicate content)
Disallow: /*?*sortBy= Disallow: /*?*sortBy=
Disallow: /*?*page= Disallow: /*?*page=
@@ -126,15 +129,10 @@ Disallow: /
# =========================================== # ===========================================
# Sitemap locations # Sitemap locations
# =========================================== # ===========================================
# Main sitemap index (dynamically generated, contains all sub-sitemaps) # Main sitemap index
Sitemap: https://biz-match.com/bizmatch/sitemap.xml Sitemap: https://www.bizmatch.net/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 directive (for Yandex)
# =========================================== # ===========================================
Host: https://biz-match.com Host: https://www.bizmatch.net

View File

@@ -5,7 +5,8 @@
@tailwind utilities; @tailwind utilities;
// External CSS imports - these URL imports don't trigger deprecation warnings // External CSS imports - these URL imports don't trigger deprecation warnings
@import url('https://fonts.googleapis.com/css?family=Open+Sans&display=swap'); // Using css2 API with specific weights for better performance
@import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;500;600;700&display=swap');
@import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css'); @import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css');
// Local CSS files loaded as CSS (not SCSS) to avoid @import deprecation // Local CSS files loaded as CSS (not SCSS) to avoid @import deprecation
@@ -121,7 +122,7 @@ p-menubarsub ul {
input::placeholder, input::placeholder,
textarea::placeholder { textarea::placeholder {
color: #999 !important; color: #757575 !important; /* 4.54:1 contrast - WCAG AA compliant */
} }
/* Fix für Marker-Icons in Leaflet */ /* Fix für Marker-Icons in Leaflet */

62
docker-compose.yml Normal file
View File

@@ -0,0 +1,62 @@
services:
# --- FRONTEND ---
bizmatch-ssr:
build:
context: . # Pfad zum Angular Ordner
dockerfile: bizmatch/Dockerfile
image: bizmatch-ssr
container_name: bizmatch-ssr
extra_hosts:
- "localhost:host-gateway"
restart: unless-stopped
ports:
- '4200:4000' # Extern 4200 -> Intern 4000 (SSR)
environment:
NODE_ENV: production
volumes:
- ./bizmatch-server/pictures:/app/pictures
# --- BACKEND ---
app:
build:
context: ./bizmatch-server # Pfad zum NestJS Ordner
dockerfile: Dockerfile
image: bizmatch-server:latest
container_name: bizmatch-app
restart: unless-stopped
ports:
- '3001:3001'
env_file:
- ./bizmatch-server/.env # Pfad zur .env Datei
depends_on:
- postgres
networks:
- bizmatch
# WICHTIG: Kein Volume Mapping für node_modules im Prod-Modus!
# Das Image bringt alles fertig mit.
# --- DATABASE ---
postgres:
container_name: bizmatchdb
image: postgres:17-alpine
restart: unless-stopped
volumes:
- bizmatch-db-data:/var/lib/postgresql/data
env_file:
- ./bizmatch-server/.env
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- '5434:5432'
networks:
- bizmatch
volumes:
bizmatch-db-data:
driver: local
networks:
bizmatch:
external: false # Oder true, falls du es manuell erstellt hast

21
update.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/bin/bash
echo "🚀 Starte Update Prozess..."
# 1. Neuesten Code holen
echo "📥 Git Pull..."
git pull
# 2. Docker Images neu bauen und Container neu starten
# --build: Zwingt Docker, die Images neu zu bauen (nutzt Multi-Stage)
# -d: Detached mode (im Hintergrund)
# --remove-orphans: Räumt alte Container auf, falls Services umbenannt wurden
echo "🐳 Baue und starte Container..."
docker compose up -d --build --remove-orphans
# 3. Aufräumen (Optional)
# Löscht alte Images ("dangling images"), die beim Build übrig geblieben sind, um Platz zu sparen
echo "🧹 Räume alte Images auf..."
docker image prune -f
echo "✅ Fertig! Die App läuft in der neuesten Version."