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>
This commit is contained in:
Timo Knuth
2026-02-03 19:48:30 +01:00
parent 0bbfc3f4fb
commit bf735ed60f
22 changed files with 4001 additions and 3923 deletions

View File

@@ -1,362 +1,362 @@
import { Inject, Injectable } from '@nestjs/common';
import { eq, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as schema from '../drizzle/schema';
import { PG_CONNECTION } from '../drizzle/schema';
interface SitemapUrl {
loc: string;
lastmod?: string;
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
priority?: number;
}
interface SitemapIndexEntry {
loc: string;
lastmod?: string;
}
@Injectable()
export class SitemapService {
private readonly baseUrl = 'https://biz-match.com';
private readonly URLS_PER_SITEMAP = 10000; // Google best practice
constructor(@Inject(PG_CONNECTION) private readonly db: NodePgDatabase<typeof schema>) { }
/**
* Generate sitemap index (main sitemap.xml)
* Lists all sitemap files: static, business-1, business-2, commercial-1, etc.
*/
async generateSitemapIndex(): Promise<string> {
const sitemaps: SitemapIndexEntry[] = [];
// Add static pages sitemap
sitemaps.push({
loc: `${this.baseUrl}/bizmatch/sitemap/static.xml`,
lastmod: this.formatDate(new Date()),
});
// Count business listings
const businessCount = await this.getBusinessListingsCount();
const businessPages = Math.ceil(businessCount / this.URLS_PER_SITEMAP) || 1;
for (let page = 1; page <= businessPages; page++) {
sitemaps.push({
loc: `${this.baseUrl}/bizmatch/sitemap/business-${page}.xml`,
lastmod: this.formatDate(new Date()),
});
}
// Count commercial property listings
const commercialCount = await this.getCommercialPropertiesCount();
const commercialPages = Math.ceil(commercialCount / this.URLS_PER_SITEMAP) || 1;
for (let page = 1; page <= commercialPages; page++) {
sitemaps.push({
loc: `${this.baseUrl}/bizmatch/sitemap/commercial-${page}.xml`,
lastmod: this.formatDate(new Date()),
});
}
// Count broker profiles
const brokerCount = await this.getBrokerProfilesCount();
const brokerPages = Math.ceil(brokerCount / this.URLS_PER_SITEMAP) || 1;
for (let page = 1; page <= brokerPages; page++) {
sitemaps.push({
loc: `${this.baseUrl}/bizmatch/sitemap/brokers-${page}.xml`,
lastmod: this.formatDate(new Date()),
});
}
return this.buildXmlSitemapIndex(sitemaps);
}
/**
* Generate static pages sitemap
*/
async generateStaticSitemap(): Promise<string> {
const urls = this.getStaticPageUrls();
return this.buildXmlSitemap(urls);
}
/**
* Generate business listings sitemap (paginated)
*/
async generateBusinessSitemap(page: number): Promise<string> {
const offset = (page - 1) * this.URLS_PER_SITEMAP;
const urls = await this.getBusinessListingUrls(offset, this.URLS_PER_SITEMAP);
return this.buildXmlSitemap(urls);
}
/**
* Generate commercial property sitemap (paginated)
*/
async generateCommercialSitemap(page: number): Promise<string> {
const offset = (page - 1) * this.URLS_PER_SITEMAP;
const urls = await this.getCommercialPropertyUrls(offset, this.URLS_PER_SITEMAP);
return this.buildXmlSitemap(urls);
}
/**
* Build XML sitemap index
*/
private buildXmlSitemapIndex(sitemaps: SitemapIndexEntry[]): string {
const sitemapElements = sitemaps
.map(sitemap => {
let element = ` <sitemap>\n <loc>${sitemap.loc}</loc>`;
if (sitemap.lastmod) {
element += `\n <lastmod>${sitemap.lastmod}</lastmod>`;
}
element += '\n </sitemap>';
return element;
})
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${sitemapElements}
</sitemapindex>`;
}
/**
* Build XML sitemap string
*/
private buildXmlSitemap(urls: SitemapUrl[]): string {
const urlElements = urls.map(url => this.buildUrlElement(url)).join('\n ');
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urlElements}
</urlset>`;
}
/**
* Build single URL element
*/
private buildUrlElement(url: SitemapUrl): string {
let element = `<url>\n <loc>${url.loc}</loc>`;
if (url.lastmod) {
element += `\n <lastmod>${url.lastmod}</lastmod>`;
}
if (url.changefreq) {
element += `\n <changefreq>${url.changefreq}</changefreq>`;
}
if (url.priority !== undefined) {
element += `\n <priority>${url.priority.toFixed(1)}</priority>`;
}
element += '\n </url>';
return element;
}
/**
* Get static page URLs
*/
private getStaticPageUrls(): SitemapUrl[] {
return [
{
loc: `${this.baseUrl}/`,
changefreq: 'daily',
priority: 1.0,
},
{
loc: `${this.baseUrl}/home`,
changefreq: 'daily',
priority: 1.0,
},
{
loc: `${this.baseUrl}/businessListings`,
changefreq: 'daily',
priority: 0.9,
},
{
loc: `${this.baseUrl}/commercialPropertyListings`,
changefreq: 'daily',
priority: 0.9,
},
{
loc: `${this.baseUrl}/brokerListings`,
changefreq: 'daily',
priority: 0.8,
},
{
loc: `${this.baseUrl}/terms-of-use`,
changefreq: 'monthly',
priority: 0.5,
},
{
loc: `${this.baseUrl}/privacy-statement`,
changefreq: 'monthly',
priority: 0.5,
},
];
}
/**
* Count business listings (non-draft)
*/
private async getBusinessListingsCount(): Promise<number> {
try {
const result = await this.db
.select({ count: sql<number>`count(*)` })
.from(schema.businesses_json)
.where(sql`(${schema.businesses_json.data}->>'draft')::boolean IS NOT TRUE`);
return Number(result[0]?.count || 0);
} catch (error) {
console.error('Error counting business listings:', error);
return 0;
}
}
/**
* Count commercial properties (non-draft)
*/
private async getCommercialPropertiesCount(): Promise<number> {
try {
const result = await this.db
.select({ count: sql<number>`count(*)` })
.from(schema.commercials_json)
.where(sql`(${schema.commercials_json.data}->>'draft')::boolean IS NOT TRUE`);
return Number(result[0]?.count || 0);
} catch (error) {
console.error('Error counting commercial properties:', error);
return 0;
}
}
/**
* Get business listing URLs from database (paginated, slug-based)
*/
private async getBusinessListingUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
try {
const listings = await this.db
.select({
id: schema.businesses_json.id,
slug: sql<string>`${schema.businesses_json.data}->>'slug'`,
updated: sql<Date>`(${schema.businesses_json.data}->>'updated')::timestamptz`,
created: sql<Date>`(${schema.businesses_json.data}->>'created')::timestamptz`,
})
.from(schema.businesses_json)
.where(sql`(${schema.businesses_json.data}->>'draft')::boolean IS NOT TRUE`)
.limit(limit)
.offset(offset);
return listings.map(listing => {
const urlSlug = listing.slug || listing.id;
return {
loc: `${this.baseUrl}/business/${urlSlug}`,
lastmod: this.formatDate(listing.updated || listing.created),
changefreq: 'weekly' as const,
priority: 0.8,
};
});
} catch (error) {
console.error('Error fetching business listings for sitemap:', error);
return [];
}
}
/**
* Get commercial property URLs from database (paginated, slug-based)
*/
private async getCommercialPropertyUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
try {
const properties = await this.db
.select({
id: schema.commercials_json.id,
slug: sql<string>`${schema.commercials_json.data}->>'slug'`,
updated: sql<Date>`(${schema.commercials_json.data}->>'updated')::timestamptz`,
created: sql<Date>`(${schema.commercials_json.data}->>'created')::timestamptz`,
})
.from(schema.commercials_json)
.where(sql`(${schema.commercials_json.data}->>'draft')::boolean IS NOT TRUE`)
.limit(limit)
.offset(offset);
return properties.map(property => {
const urlSlug = property.slug || property.id;
return {
loc: `${this.baseUrl}/commercial-property/${urlSlug}`,
lastmod: this.formatDate(property.updated || property.created),
changefreq: 'weekly' as const,
priority: 0.8,
};
});
} catch (error) {
console.error('Error fetching commercial properties for sitemap:', error);
return [];
}
}
/**
* Format date to ISO 8601 format (YYYY-MM-DD)
*/
private formatDate(date: Date | string): string {
if (!date) return new Date().toISOString().split('T')[0];
const d = typeof date === 'string' ? new Date(date) : date;
return d.toISOString().split('T')[0];
}
/**
* Generate broker profiles sitemap (paginated)
*/
async generateBrokerSitemap(page: number): Promise<string> {
const offset = (page - 1) * this.URLS_PER_SITEMAP;
const urls = await this.getBrokerProfileUrls(offset, this.URLS_PER_SITEMAP);
return this.buildXmlSitemap(urls);
}
/**
* Count broker profiles (professionals with showInDirectory=true)
*/
private async getBrokerProfilesCount(): Promise<number> {
try {
const result = await this.db
.select({ count: sql<number>`count(*)` })
.from(schema.users_json)
.where(sql`
(${schema.users_json.data}->>'customerType') = 'professional'
AND (${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE
`);
return Number(result[0]?.count || 0);
} catch (error) {
console.error('Error counting broker profiles:', error);
return 0;
}
}
/**
* Get broker profile URLs from database (paginated)
*/
private async getBrokerProfileUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
try {
const brokers = await this.db
.select({
email: schema.users_json.email,
updated: sql<Date>`(${schema.users_json.data}->>'updated')::timestamptz`,
created: sql<Date>`(${schema.users_json.data}->>'created')::timestamptz`,
})
.from(schema.users_json)
.where(sql`
(${schema.users_json.data}->>'customerType') = 'professional'
AND (${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE
`)
.limit(limit)
.offset(offset);
return brokers.map(broker => ({
loc: `${this.baseUrl}/details-user/${encodeURIComponent(broker.email)}`,
lastmod: this.formatDate(broker.updated || broker.created),
changefreq: 'weekly' as const,
priority: 0.7,
}));
} catch (error) {
console.error('Error fetching broker profiles for sitemap:', error);
return [];
}
}
}
import { Inject, Injectable } from '@nestjs/common';
import { eq, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import * as schema from '../drizzle/schema';
import { PG_CONNECTION } from '../drizzle/schema';
interface SitemapUrl {
loc: string;
lastmod?: string;
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
priority?: number;
}
interface SitemapIndexEntry {
loc: string;
lastmod?: string;
}
@Injectable()
export class SitemapService {
private readonly baseUrl = 'https://www.bizmatch.net';
private readonly URLS_PER_SITEMAP = 10000; // Google best practice
constructor(@Inject(PG_CONNECTION) private readonly db: NodePgDatabase<typeof schema>) { }
/**
* Generate sitemap index (main sitemap.xml)
* Lists all sitemap files: static, business-1, business-2, commercial-1, etc.
*/
async generateSitemapIndex(): Promise<string> {
const sitemaps: SitemapIndexEntry[] = [];
// Add static pages sitemap
sitemaps.push({
loc: `${this.baseUrl}/bizmatch/sitemap/static.xml`,
lastmod: this.formatDate(new Date()),
});
// Count business listings
const businessCount = await this.getBusinessListingsCount();
const businessPages = Math.ceil(businessCount / this.URLS_PER_SITEMAP) || 1;
for (let page = 1; page <= businessPages; page++) {
sitemaps.push({
loc: `${this.baseUrl}/bizmatch/sitemap/business-${page}.xml`,
lastmod: this.formatDate(new Date()),
});
}
// Count commercial property listings
const commercialCount = await this.getCommercialPropertiesCount();
const commercialPages = Math.ceil(commercialCount / this.URLS_PER_SITEMAP) || 1;
for (let page = 1; page <= commercialPages; page++) {
sitemaps.push({
loc: `${this.baseUrl}/bizmatch/sitemap/commercial-${page}.xml`,
lastmod: this.formatDate(new Date()),
});
}
// Count broker profiles
const brokerCount = await this.getBrokerProfilesCount();
const brokerPages = Math.ceil(brokerCount / this.URLS_PER_SITEMAP) || 1;
for (let page = 1; page <= brokerPages; page++) {
sitemaps.push({
loc: `${this.baseUrl}/bizmatch/sitemap/brokers-${page}.xml`,
lastmod: this.formatDate(new Date()),
});
}
return this.buildXmlSitemapIndex(sitemaps);
}
/**
* Generate static pages sitemap
*/
async generateStaticSitemap(): Promise<string> {
const urls = this.getStaticPageUrls();
return this.buildXmlSitemap(urls);
}
/**
* Generate business listings sitemap (paginated)
*/
async generateBusinessSitemap(page: number): Promise<string> {
const offset = (page - 1) * this.URLS_PER_SITEMAP;
const urls = await this.getBusinessListingUrls(offset, this.URLS_PER_SITEMAP);
return this.buildXmlSitemap(urls);
}
/**
* Generate commercial property sitemap (paginated)
*/
async generateCommercialSitemap(page: number): Promise<string> {
const offset = (page - 1) * this.URLS_PER_SITEMAP;
const urls = await this.getCommercialPropertyUrls(offset, this.URLS_PER_SITEMAP);
return this.buildXmlSitemap(urls);
}
/**
* Build XML sitemap index
*/
private buildXmlSitemapIndex(sitemaps: SitemapIndexEntry[]): string {
const sitemapElements = sitemaps
.map(sitemap => {
let element = ` <sitemap>\n <loc>${sitemap.loc}</loc>`;
if (sitemap.lastmod) {
element += `\n <lastmod>${sitemap.lastmod}</lastmod>`;
}
element += '\n </sitemap>';
return element;
})
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${sitemapElements}
</sitemapindex>`;
}
/**
* Build XML sitemap string
*/
private buildXmlSitemap(urls: SitemapUrl[]): string {
const urlElements = urls.map(url => this.buildUrlElement(url)).join('\n ');
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urlElements}
</urlset>`;
}
/**
* Build single URL element
*/
private buildUrlElement(url: SitemapUrl): string {
let element = `<url>\n <loc>${url.loc}</loc>`;
if (url.lastmod) {
element += `\n <lastmod>${url.lastmod}</lastmod>`;
}
if (url.changefreq) {
element += `\n <changefreq>${url.changefreq}</changefreq>`;
}
if (url.priority !== undefined) {
element += `\n <priority>${url.priority.toFixed(1)}</priority>`;
}
element += '\n </url>';
return element;
}
/**
* Get static page URLs
*/
private getStaticPageUrls(): SitemapUrl[] {
return [
{
loc: `${this.baseUrl}/`,
changefreq: 'daily',
priority: 1.0,
},
{
loc: `${this.baseUrl}/home`,
changefreq: 'daily',
priority: 1.0,
},
{
loc: `${this.baseUrl}/businessListings`,
changefreq: 'daily',
priority: 0.9,
},
{
loc: `${this.baseUrl}/commercialPropertyListings`,
changefreq: 'daily',
priority: 0.9,
},
{
loc: `${this.baseUrl}/brokerListings`,
changefreq: 'daily',
priority: 0.8,
},
{
loc: `${this.baseUrl}/terms-of-use`,
changefreq: 'monthly',
priority: 0.5,
},
{
loc: `${this.baseUrl}/privacy-statement`,
changefreq: 'monthly',
priority: 0.5,
},
];
}
/**
* Count business listings (non-draft)
*/
private async getBusinessListingsCount(): Promise<number> {
try {
const result = await this.db
.select({ count: sql<number>`count(*)` })
.from(schema.businesses_json)
.where(sql`(${schema.businesses_json.data}->>'draft')::boolean IS NOT TRUE`);
return Number(result[0]?.count || 0);
} catch (error) {
console.error('Error counting business listings:', error);
return 0;
}
}
/**
* Count commercial properties (non-draft)
*/
private async getCommercialPropertiesCount(): Promise<number> {
try {
const result = await this.db
.select({ count: sql<number>`count(*)` })
.from(schema.commercials_json)
.where(sql`(${schema.commercials_json.data}->>'draft')::boolean IS NOT TRUE`);
return Number(result[0]?.count || 0);
} catch (error) {
console.error('Error counting commercial properties:', error);
return 0;
}
}
/**
* Get business listing URLs from database (paginated, slug-based)
*/
private async getBusinessListingUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
try {
const listings = await this.db
.select({
id: schema.businesses_json.id,
slug: sql<string>`${schema.businesses_json.data}->>'slug'`,
updated: sql<Date>`(${schema.businesses_json.data}->>'updated')::timestamptz`,
created: sql<Date>`(${schema.businesses_json.data}->>'created')::timestamptz`,
})
.from(schema.businesses_json)
.where(sql`(${schema.businesses_json.data}->>'draft')::boolean IS NOT TRUE`)
.limit(limit)
.offset(offset);
return listings.map(listing => {
const urlSlug = listing.slug || listing.id;
return {
loc: `${this.baseUrl}/business/${urlSlug}`,
lastmod: this.formatDate(listing.updated || listing.created),
changefreq: 'weekly' as const,
priority: 0.8,
};
});
} catch (error) {
console.error('Error fetching business listings for sitemap:', error);
return [];
}
}
/**
* Get commercial property URLs from database (paginated, slug-based)
*/
private async getCommercialPropertyUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
try {
const properties = await this.db
.select({
id: schema.commercials_json.id,
slug: sql<string>`${schema.commercials_json.data}->>'slug'`,
updated: sql<Date>`(${schema.commercials_json.data}->>'updated')::timestamptz`,
created: sql<Date>`(${schema.commercials_json.data}->>'created')::timestamptz`,
})
.from(schema.commercials_json)
.where(sql`(${schema.commercials_json.data}->>'draft')::boolean IS NOT TRUE`)
.limit(limit)
.offset(offset);
return properties.map(property => {
const urlSlug = property.slug || property.id;
return {
loc: `${this.baseUrl}/commercial-property/${urlSlug}`,
lastmod: this.formatDate(property.updated || property.created),
changefreq: 'weekly' as const,
priority: 0.8,
};
});
} catch (error) {
console.error('Error fetching commercial properties for sitemap:', error);
return [];
}
}
/**
* Format date to ISO 8601 format (YYYY-MM-DD)
*/
private formatDate(date: Date | string): string {
if (!date) return new Date().toISOString().split('T')[0];
const d = typeof date === 'string' ? new Date(date) : date;
return d.toISOString().split('T')[0];
}
/**
* Generate broker profiles sitemap (paginated)
*/
async generateBrokerSitemap(page: number): Promise<string> {
const offset = (page - 1) * this.URLS_PER_SITEMAP;
const urls = await this.getBrokerProfileUrls(offset, this.URLS_PER_SITEMAP);
return this.buildXmlSitemap(urls);
}
/**
* Count broker profiles (professionals with showInDirectory=true)
*/
private async getBrokerProfilesCount(): Promise<number> {
try {
const result = await this.db
.select({ count: sql<number>`count(*)` })
.from(schema.users_json)
.where(sql`
(${schema.users_json.data}->>'customerType') = 'professional'
AND (${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE
`);
return Number(result[0]?.count || 0);
} catch (error) {
console.error('Error counting broker profiles:', error);
return 0;
}
}
/**
* Get broker profile URLs from database (paginated)
*/
private async getBrokerProfileUrls(offset: number, limit: number): Promise<SitemapUrl[]> {
try {
const brokers = await this.db
.select({
email: schema.users_json.email,
updated: sql<Date>`(${schema.users_json.data}->>'updated')::timestamptz`,
created: sql<Date>`(${schema.users_json.data}->>'created')::timestamptz`,
})
.from(schema.users_json)
.where(sql`
(${schema.users_json.data}->>'customerType') = 'professional'
AND (${schema.users_json.data}->>'showInDirectory')::boolean IS TRUE
`)
.limit(limit)
.offset(offset);
return brokers.map(broker => ({
loc: `${this.baseUrl}/details-user/${encodeURIComponent(broker.email)}`,
lastmod: this.formatDate(broker.updated || broker.created),
changefreq: 'weekly' as const,
priority: 0.7,
}));
} catch (error) {
console.error('Error fetching broker profiles for sitemap:', error);
return [];
}
}
}