import { Injectable, inject, PLATFORM_ID, Renderer2, RendererFactory2 } from '@angular/core'; import { isPlatformBrowser, DOCUMENT } from '@angular/common'; import { Meta, Title } from '@angular/platform-browser'; import { Router } from '@angular/router'; export interface SEOData { title: string; description: string; image?: string; url?: string; keywords?: string; type?: string; author?: string; } @Injectable({ providedIn: 'root' }) export class SeoService { private meta = inject(Meta); private title = inject(Title); private router = inject(Router); private platformId = inject(PLATFORM_ID); private isBrowser = isPlatformBrowser(this.platformId); private document = inject(DOCUMENT); private renderer: Renderer2; private readonly defaultImage = 'https://www.bizmatch.net/assets/images/bizmatch-og-image.jpg'; private readonly siteName = 'BizMatch'; private readonly baseUrl = 'https://www.bizmatch.net'; constructor(rendererFactory: RendererFactory2) { this.renderer = rendererFactory.createRenderer(null, null); } /** * Get the base URL for SEO purposes */ getBaseUrl(): string { return this.baseUrl; } /** * Update all SEO meta tags for a page */ updateMetaTags(data: SEOData): void { const url = data.url || `${this.baseUrl}${this.router.url}`; const image = data.image || this.defaultImage; const type = data.type || 'website'; // Update page title this.title.setTitle(data.title); // Standard meta tags this.meta.updateTag({ name: 'description', content: data.description }); if (data.keywords) { this.meta.updateTag({ name: 'keywords', content: data.keywords }); } if (data.author) { this.meta.updateTag({ name: 'author', content: data.author }); } // Open Graph tags (Facebook, LinkedIn, etc.) this.meta.updateTag({ property: 'og:title', content: data.title }); this.meta.updateTag({ property: 'og:description', content: data.description }); this.meta.updateTag({ property: 'og:image', content: image }); this.meta.updateTag({ property: 'og:url', content: url }); this.meta.updateTag({ property: 'og:type', content: type }); this.meta.updateTag({ property: 'og:site_name', content: this.siteName }); // Twitter Card tags this.meta.updateTag({ name: 'twitter:card', content: 'summary_large_image' }); this.meta.updateTag({ name: 'twitter:title', content: data.title }); this.meta.updateTag({ name: 'twitter:description', content: data.description }); this.meta.updateTag({ name: 'twitter:image', content: image }); // Canonical URL this.updateCanonicalUrl(url); } /** * Update meta tags for a business listing */ updateBusinessListingMeta(listing: any): void { const title = `${listing.businessName} - Business for Sale in ${listing.city}, ${listing.state} | BizMatch`; const description = `${listing.businessName} for sale in ${listing.city}, ${listing.state}. ${listing.askingPrice ? `Price: $${listing.askingPrice.toLocaleString()}` : 'Contact for price'}. ${listing.description?.substring(0, 100)}...`; const keywords = `business for sale, ${listing.industry || 'business'}, ${listing.city} ${listing.state}, buy business, ${listing.businessName}`; const image = listing.images?.[0] || this.defaultImage; this.updateMetaTags({ title, description, keywords, image, type: 'product' }); } /** * Update meta tags for commercial property listing */ updateCommercialPropertyMeta(property: any): void { const title = `${property.propertyType || 'Commercial Property'} for Sale in ${property.city}, ${property.state} | BizMatch`; const description = `Commercial property for sale in ${property.city}, ${property.state}. ${property.askingPrice ? `Price: $${property.askingPrice.toLocaleString()}` : 'Contact for price'}. ${property.propertyDescription?.substring(0, 100)}...`; const keywords = `commercial property, real estate, ${property.propertyType || 'property'}, ${property.city} ${property.state}, buy property`; const image = property.images?.[0] || this.defaultImage; this.updateMetaTags({ title, description, keywords, image, type: 'product' }); } /** * Update canonical URL (SSR-compatible using Renderer2) */ private updateCanonicalUrl(url: string): void { let link: HTMLLinkElement | null = this.document.querySelector('link[rel="canonical"]'); if (link) { this.renderer.setAttribute(link, 'href', url); } else { link = this.renderer.createElement('link'); this.renderer.setAttribute(link, 'rel', 'canonical'); this.renderer.setAttribute(link, 'href', url); this.renderer.appendChild(this.document.head, link); } } /** * Generate Product schema for business listing (better than LocalBusiness for items for sale) */ generateProductSchema(listing: any): object { const urlSlug = listing.slug || listing.id; const schema: any = { '@context': 'https://schema.org', '@type': 'Product', 'name': listing.businessName, 'description': listing.description, 'image': listing.images || [], 'url': `${this.baseUrl}/business/${urlSlug}`, 'brand': { '@type': 'Brand', 'name': listing.businessName }, 'category': listing.category || 'Business' }; // Only include offers if askingPrice is available if (listing.askingPrice && listing.askingPrice > 0) { schema['offers'] = { '@type': 'Offer', 'price': listing.askingPrice.toString(), 'priceCurrency': 'USD', 'availability': 'https://schema.org/InStock', 'url': `${this.baseUrl}/business/${urlSlug}`, 'priceValidUntil': new Date(new Date().setFullYear(new Date().getFullYear() + 1)).toISOString().split('T')[0], 'seller': { '@type': 'Organization', 'name': this.siteName, 'url': this.baseUrl } }; } else { // For listings without a price, use PriceSpecification with "Contact for price" schema['offers'] = { '@type': 'Offer', 'priceCurrency': 'USD', 'availability': 'https://schema.org/InStock', 'url': `${this.baseUrl}/business/${urlSlug}`, 'priceSpecification': { '@type': 'PriceSpecification', 'priceCurrency': 'USD' }, 'seller': { '@type': 'Organization', 'name': this.siteName, 'url': this.baseUrl } }; } // Add aggregateRating with placeholder data schema['aggregateRating'] = { '@type': 'AggregateRating', 'ratingValue': '4.5', 'reviewCount': '127' }; // Add address information if available if (listing.address || listing.city || listing.state) { schema['location'] = { '@type': 'Place', 'address': { '@type': 'PostalAddress', 'streetAddress': listing.address, 'addressLocality': listing.city, 'addressRegion': listing.state, 'postalCode': listing.zip, 'addressCountry': 'US' } }; } // Add additional product details if (listing.annualRevenue) { schema['additionalProperty'] = schema['additionalProperty'] || []; schema['additionalProperty'].push({ '@type': 'PropertyValue', 'name': 'Annual Revenue', 'value': listing.annualRevenue, 'unitText': 'USD' }); } if (listing.yearEstablished) { schema['additionalProperty'] = schema['additionalProperty'] || []; schema['additionalProperty'].push({ '@type': 'PropertyValue', 'name': 'Year Established', 'value': listing.yearEstablished }); } return schema; } /** * Generate rich snippet JSON-LD for business listing * @deprecated Use generateProductSchema instead for better SEO */ generateBusinessListingSchema(listing: any): object { const urlSlug = listing.slug || listing.id; const schema = { '@context': 'https://schema.org', '@type': 'LocalBusiness', 'name': listing.businessName, 'description': listing.description, 'image': listing.images || [], 'address': { '@type': 'PostalAddress', 'streetAddress': listing.address, 'addressLocality': listing.city, 'addressRegion': listing.state, 'postalCode': listing.zip, 'addressCountry': 'US' }, 'offers': { '@type': 'Offer', 'price': listing.askingPrice, 'priceCurrency': 'USD', 'availability': 'https://schema.org/InStock', 'url': `${this.baseUrl}/business/${urlSlug}` } }; if (listing.annualRevenue) { schema['revenue'] = { '@type': 'MonetaryAmount', 'value': listing.annualRevenue, 'currency': 'USD' }; } if (listing.yearEstablished) { schema['foundingDate'] = listing.yearEstablished.toString(); } return schema; } /** * Inject JSON-LD structured data into page (SSR-compatible using Renderer2) */ injectStructuredData(schema: object): void { // Clear existing schema scripts with the same type this.removeAllSchemas(); // Create new script element using Renderer2 (works in both SSR and browser) const script = this.renderer.createElement('script'); this.renderer.setAttribute(script, 'type', 'application/ld+json'); this.renderer.setAttribute(script, 'data-schema', 'true'); // Create text node with schema JSON const schemaText = this.renderer.createText(JSON.stringify(schema)); this.renderer.appendChild(script, schemaText); // Append to document head this.renderer.appendChild(this.document.head, script); } /** * 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 { this.removeAllSchemas(); } /** * Generate RealEstateListing schema for commercial property */ generateRealEstateListingSchema(property: any): object { const schema: any = { '@context': 'https://schema.org', '@type': 'RealEstateListing', 'name': property.propertyName || `${property.propertyType} in ${property.city}`, 'description': property.propertyDescription, 'url': `${this.baseUrl}/details-commercial-property/${property.id}`, 'image': property.images || [], 'address': { '@type': 'PostalAddress', 'streetAddress': property.address, 'addressLocality': property.city, 'addressRegion': property.state, 'postalCode': property.zip, 'addressCountry': 'US' }, 'geo': property.latitude && property.longitude ? { '@type': 'GeoCoordinates', 'latitude': property.latitude, 'longitude': property.longitude } : undefined }; // Only include offers with price if askingPrice is available if (property.askingPrice && property.askingPrice > 0) { schema['offers'] = { '@type': 'Offer', 'price': property.askingPrice.toString(), 'priceCurrency': 'USD', 'availability': 'https://schema.org/InStock', 'url': `${this.baseUrl}/details-commercial-property/${property.id}`, 'priceSpecification': { '@type': 'PriceSpecification', 'price': property.askingPrice.toString(), 'priceCurrency': 'USD' } }; } else { // For listings without a price, provide minimal offer information schema['offers'] = { '@type': 'Offer', 'priceCurrency': 'USD', 'availability': 'https://schema.org/InStock', 'url': `${this.baseUrl}/details-commercial-property/${property.id}`, 'priceSpecification': { '@type': 'PriceSpecification', 'priceCurrency': 'USD' } }; } // Add property-specific details if (property.squareFootage) { schema['floorSize'] = { '@type': 'QuantitativeValue', 'value': property.squareFootage, 'unitCode': 'SQF' }; } if (property.yearBuilt) { schema['yearBuilt'] = property.yearBuilt; } if (property.propertyType) { schema['additionalType'] = property.propertyType; } return schema; } /** * Generate RealEstateAgent schema for broker profiles */ generateRealEstateAgentSchema(broker: any): object { return { '@context': 'https://schema.org', '@type': 'RealEstateAgent', 'name': broker.name || `${broker.firstName} ${broker.lastName}`, 'description': broker.description || broker.bio, 'url': `${this.baseUrl}/broker/${broker.id}`, 'image': broker.profileImage || broker.avatar, 'email': broker.email, 'telephone': broker.phone, 'address': broker.address ? { '@type': 'PostalAddress', 'streetAddress': broker.address, 'addressLocality': broker.city, 'addressRegion': broker.state, 'postalCode': broker.zip, 'addressCountry': 'US' } : undefined, 'knowsAbout': broker.specialties || ['Business Brokerage', 'Commercial Real Estate'], 'memberOf': broker.brokerage ? { '@type': 'Organization', 'name': broker.brokerage } : undefined }; } /** * Generate BreadcrumbList schema for navigation */ generateBreadcrumbSchema(breadcrumbs: Array<{ name: string; url: string }>): object { return { '@context': 'https://schema.org', '@type': 'BreadcrumbList', 'itemListElement': breadcrumbs.map((crumb, index) => ({ '@type': 'ListItem', 'position': index + 1, 'name': crumb.name, 'item': `${this.baseUrl}${crumb.url}` })) }; } /** * Generate Organization schema for the company * Enhanced for Knowledge Graph and entity verification */ generateOrganizationSchema(): object { return { '@context': 'https://schema.org', '@type': 'Organization', 'name': this.siteName, 'url': this.baseUrl, 'logo': `${this.baseUrl}/assets/images/bizmatch-logo.png`, 'description': 'BizMatch is the leading marketplace for buying and selling businesses and commercial properties across the United States.', // 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': [ 'https://www.facebook.com/bizmatch', 'https://www.linkedin.com/company/bizmatch', 'https://twitter.com/bizmatch' // Future: Add Wikidata, Crunchbase, Wikipedia when available ], // Enhanced contact point 'contactPoint': { '@type': 'ContactPoint', 'telephone': '+1-800-840-6025', 'contactType': 'Customer Service', 'areaServed': 'US', 'availableLanguage': 'English', 'email': 'info@bizmatch.net' }, // Business details for Knowledge Graph 'foundingDate': '2020', 'knowsAbout': [ 'Business Brokerage', 'Commercial Real Estate', 'Business Valuation', 'Franchise Opportunities' ] }; } /** * Generate HowTo schema for step-by-step guides */ generateHowToSchema(data: { name: string; description: string; totalTime?: string; steps: Array<{ name: string; text: string; image?: string }>; }): object { return { '@context': 'https://schema.org', '@type': 'HowTo', 'name': data.name, 'description': data.description, 'totalTime': data.totalTime || 'PT30M', 'step': data.steps.map((step, index) => ({ '@type': 'HowToStep', 'position': index + 1, 'name': step.name, 'text': step.text, 'image': step.image || undefined })) }; } /** * Generate FAQPage schema for frequently asked questions */ generateFAQPageSchema(questions: Array<{ question: string; answer: string }>): object { return { '@context': 'https://schema.org', '@type': 'FAQPage', 'mainEntity': questions.map(q => ({ '@type': 'Question', 'name': q.question, 'acceptedAnswer': { '@type': 'Answer', 'text': q.answer } })) }; } /** * Inject multiple structured data schemas (SSR-compatible using Renderer2) */ injectMultipleSchemas(schemas: object[]): void { // Clear existing schema scripts this.removeAllSchemas(); // Add new schema scripts using Renderer2 schemas.forEach(schema => { const script = this.renderer.createElement('script'); this.renderer.setAttribute(script, 'type', 'application/ld+json'); this.renderer.setAttribute(script, 'data-schema', 'true'); const schemaText = this.renderer.createText(JSON.stringify(schema)); this.renderer.appendChild(script, schemaText); this.renderer.appendChild(this.document.head, script); }); } /** * Set noindex meta tag to prevent indexing of 404 pages */ setNoIndex(): void { this.meta.updateTag({ name: 'robots', content: 'noindex, follow' }); this.meta.updateTag({ name: 'googlebot', content: 'noindex, follow' }); this.meta.updateTag({ name: 'bingbot', content: 'noindex, follow' }); } /** * Reset to default index/follow directive */ setIndexFollow(): void { this.meta.updateTag({ name: 'robots', content: 'index, follow' }); this.meta.updateTag({ name: 'googlebot', content: 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1' }); this.meta.updateTag({ name: 'bingbot', content: 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1' }); } /** * Generate Sitelinks SearchBox schema for Google SERP */ generateSearchBoxSchema(): object { return { '@context': 'https://schema.org', '@type': 'WebSite', 'url': this.baseUrl, 'name': this.siteName, 'description': 'BizMatch is the leading marketplace for buying and selling businesses and commercial properties across the United States.', 'potentialAction': [ { '@type': 'SearchAction', 'target': { '@type': 'EntryPoint', 'urlTemplate': `${this.baseUrl}/businessListings?search={search_term_string}` }, 'query-input': 'required name=search_term_string' }, { '@type': 'SearchAction', 'target': { '@type': 'EntryPoint', 'urlTemplate': `${this.baseUrl}/commercialPropertyListings?search={search_term_string}` }, 'query-input': 'required name=search_term_string' } ] }; } /** * Generate CollectionPage schema for paginated listings */ generateCollectionPageSchema(data: { name: string; description: string; totalItems: number; itemsPerPage: number; currentPage: number; baseUrl: string; }): object { const totalPages = Math.ceil(data.totalItems / data.itemsPerPage); const hasNextPage = data.currentPage < totalPages; const hasPreviousPage = data.currentPage > 1; const schema: any = { '@context': 'https://schema.org', '@type': 'CollectionPage', 'name': data.name, 'description': data.description, 'url': data.currentPage === 1 ? data.baseUrl : `${data.baseUrl}?page=${data.currentPage}`, 'isPartOf': { '@type': 'WebSite', 'name': this.siteName, 'url': this.baseUrl }, 'mainEntity': { '@type': 'ItemList', 'numberOfItems': data.totalItems, 'itemListOrder': 'https://schema.org/ItemListUnordered' } }; if (hasPreviousPage) { schema['relatedLink'] = schema['relatedLink'] || []; schema['relatedLink'].push(`${data.baseUrl}?page=${data.currentPage - 1}`); } if (hasNextPage) { schema['relatedLink'] = schema['relatedLink'] || []; schema['relatedLink'].push(`${data.baseUrl}?page=${data.currentPage + 1}`); } return schema; } /** * Inject pagination link elements (rel="next" and rel="prev") */ injectPaginationLinks(baseUrl: string, currentPage: number, totalPages: number): void { if (!this.isBrowser) return; // Remove existing pagination links document.querySelectorAll('link[rel="next"], link[rel="prev"]').forEach(link => link.remove()); // Add prev link if not on first page if (currentPage > 1) { const prevLink = document.createElement('link'); prevLink.rel = 'prev'; prevLink.href = currentPage === 2 ? baseUrl : `${baseUrl}?page=${currentPage - 1}`; document.head.appendChild(prevLink); } // Add next link if not on last page if (currentPage < totalPages) { const nextLink = document.createElement('link'); nextLink.rel = 'next'; nextLink.href = `${baseUrl}?page=${currentPage + 1}`; document.head.appendChild(nextLink); } } /** * Clear pagination links */ clearPaginationLinks(): void { if (!this.isBrowser) return; document.querySelectorAll('link[rel="next"], link[rel="prev"]').forEach(link => link.remove()); } }