diff --git a/backend/src/index.js b/backend/src/index.js
index 014962c..90f28d7 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -8,6 +8,7 @@ const { query, pool } = require('./db')
const authMiddleware = require('./middleware/auth');
const adminAuthMiddleware = require('./middleware/adminAuth');
const settingsAdminRoutes = require('./routes/settingsAdmin');
+const seoMiddleware = require('./middleware/seoMiddleware');
const SystemSettings = require('./models/SystemSettings');
const fs = require('fs');
// services
@@ -162,7 +163,7 @@ pool.connect()
} catch (error) {
console.error('Error processing low stock notifications:', error);
}
- }, timeInterval);
+ }, siteGeneratorInterval);
}
// Handle SSL proxy headers
@@ -174,6 +175,7 @@ app.use((req, res, next) => {
next();
});
app.set('trust proxy', true);
+app.use(seoMiddleware);
// Middleware
app.use(cors({
origin: '*',
diff --git a/backend/src/middleware/seoMiddleware.js b/backend/src/middleware/seoMiddleware.js
new file mode 100644
index 0000000..327338a
--- /dev/null
+++ b/backend/src/middleware/seoMiddleware.js
@@ -0,0 +1,19 @@
+/**
+ * Middleware to handle serving SEO files with correct content types
+ */
+const seoMiddleware = (req, res, next) => {
+ if (req.path === '/sitemap.xml') {
+ res.setHeader('Content-Type', 'application/xml');
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD');
+ }
+ else if (req.path === '/robots.txt') {
+ res.setHeader('Content-Type', 'text/plain');
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD');
+ }
+
+ next();
+ };
+
+ module.exports = seoMiddleware;
\ No newline at end of file
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index b3b42c8..3b9ec35 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -8,6 +8,7 @@ import useBrandingSettings from '@hooks/brandingHooks';
import imageUtils from '@utils/imageUtils';
import Clarity from '@microsoft/clarity';
import CookieConsentPopup from '@components/CookieConsentPopup';
+import SeoProxyRoutes from '@components/SeoProxyRoutes'; // Import SeoProxyRoutes
// Import layouts
import MainLayout from './layouts/MainLayout';
@@ -131,6 +132,10 @@ function App() {
}>
+
+ {/* SEO Routes for sitemap.xml and robots.txt */}
+
+
{/* Main routes with MainLayout */}
}>
diff --git a/frontend/src/components/SeoProxyRoutes.jsx b/frontend/src/components/SeoProxyRoutes.jsx
new file mode 100644
index 0000000..0baffcd
--- /dev/null
+++ b/frontend/src/components/SeoProxyRoutes.jsx
@@ -0,0 +1,106 @@
+import React, { useEffect, useState } from 'react';
+import { Routes, Route } from 'react-router-dom';
+import axiosClient from '@services/seoapi';
+
+/**
+ * Component to serve SEO files (sitemap.xml, robots.txt) directly from API
+ */
+const SeoFile = ({ filePath }) => {
+ const [content, setContent] = useState('');
+ const [contentType, setContentType] = useState('');
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ // Determine the content type based on the file extension
+ const fileExtension = filePath.split('.').pop();
+ const type = fileExtension === 'xml' ? 'application/xml' : 'text/plain';
+ setContentType(type);
+
+ // Fetch the file from the API
+ axiosClient.get(filePath, {
+ responseType: 'text',
+ headers: {
+ 'Accept': type
+ }
+ })
+ .then(response => {
+ setContent(response.data);
+ })
+ .catch(err => {
+ console.error(`Error fetching ${filePath}:`, err);
+ setError(`Error loading ${filePath}. ${err.message}`);
+ });
+ }, [filePath]);
+
+ // Set the content type and return the raw content
+ useEffect(() => {
+ if (content && contentType) {
+ // For XML content, we need to handle it differently than document.write
+ if (contentType.includes('xml')) {
+ // Clear the existing document content
+ document.body.innerHTML = '';
+ document.head.innerHTML = '';
+
+ // Set the XML MIME type
+ const meta = document.createElement('meta');
+ meta.httpEquiv = 'Content-Type';
+ meta.content = `${contentType}; charset=utf-8`;
+ document.head.appendChild(meta);
+
+ // Create a pre element to display the XML with proper formatting
+ const pre = document.createElement('pre');
+ pre.textContent = content;
+ document.body.appendChild(pre);
+
+ // For XML styling - optional but makes it look nicer
+ const style = document.createElement('style');
+ style.textContent = `
+ body {
+ font-family: monospace;
+ background: #282c34;
+ color: #abb2bf;
+ padding: 20px;
+ }
+ pre {
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ }
+ `;
+ document.head.appendChild(style);
+ } else {
+ // For text content like robots.txt, use the standard approach
+ document.open();
+ document.write(content);
+ document.close();
+
+ // Set the correct content type
+ const meta = document.createElement('meta');
+ meta.httpEquiv = 'Content-Type';
+ meta.content = `${contentType}; charset=utf-8`;
+ document.head.appendChild(meta);
+ }
+ }
+ }, [content, contentType]);
+
+ // If there was an error, show a simple error message
+ if (error) {
+ return {error}
;
+ }
+
+ // During loading, return nothing (blank page)
+ return null;
+};
+
+/**
+ * Routes component that handles SEO file requests
+ */
+const SeoProxyRoutes = () => {
+ return (
+
+ } />
+ } />
+
+ );
+};
+
+export default SeoProxyRoutes;
\ No newline at end of file
diff --git a/frontend/src/hooks/useSeoMeta.js b/frontend/src/hooks/useSeoMeta.js
new file mode 100644
index 0000000..2860335
--- /dev/null
+++ b/frontend/src/hooks/useSeoMeta.js
@@ -0,0 +1,198 @@
+import { useEffect, useState } from 'react';
+import { useLocation } from 'react-router-dom';
+import apiClient from '@services/api';
+
+/**
+ * Custom hook for managing SEO metadata
+ * @param {Object} options - Configuration options
+ * @param {string} options.title - Page title
+ * @param {string} options.description - Page description
+ * @param {string} options.image - Social media image URL
+ * @param {string} options.type - Open Graph type (article, website, etc.)
+ * @returns {Object} SEO utilities
+ */
+const useSeoMeta = (options = {}) => {
+ const location = useLocation();
+ const [isLoaded, setIsLoaded] = useState(false);
+
+ // Set default page metadata
+ useEffect(() => {
+ if (!options || isLoaded) return;
+
+ const {
+ title,
+ description,
+ image,
+ type = 'website'
+ } = options;
+
+ // Update document title
+ if (title) {
+ document.title = title;
+ }
+
+ // Update meta description
+ if (description) {
+ let metaDescription = document.querySelector('meta[name="description"]');
+ if (!metaDescription) {
+ metaDescription = document.createElement('meta');
+ metaDescription.name = 'description';
+ document.head.appendChild(metaDescription);
+ }
+ metaDescription.content = description;
+ }
+
+ // Set Open Graph meta tags
+ const updateMetaTag = (property, content) => {
+ if (!content) return;
+
+ let meta = document.querySelector(`meta[property="${property}"]`);
+ if (!meta) {
+ meta = document.createElement('meta');
+ meta.setAttribute('property', property);
+ document.head.appendChild(meta);
+ }
+ meta.content = content;
+ };
+
+ // Set canonical URL
+ const canonical = document.querySelector('link[rel="canonical"]');
+ const url = `${window.location.origin}${location.pathname}`;
+
+ if (!canonical) {
+ const link = document.createElement('link');
+ link.rel = 'canonical';
+ link.href = url;
+ document.head.appendChild(link);
+ } else {
+ canonical.href = url;
+ }
+
+ // Update Open Graph tags
+ if (title) updateMetaTag('og:title', title);
+ if (description) updateMetaTag('og:description', description);
+ if (image) updateMetaTag('og:image', image);
+ updateMetaTag('og:url', url);
+ updateMetaTag('og:type', type);
+
+ // Update Twitter Card tags
+ if (title) updateMetaTag('twitter:title', title);
+ if (description) updateMetaTag('twitter:description', description);
+ if (image) updateMetaTag('twitter:image', image);
+ updateMetaTag('twitter:card', image ? 'summary_large_image' : 'summary');
+
+ setIsLoaded(true);
+ }, [options, location.pathname, isLoaded]);
+
+ /**
+ * Function to fetch and insert structured data schema
+ * @param {string} type - Schema type (Product, Article, etc.)
+ * @param {Object} data - Schema data
+ */
+ const setSchema = (type, data) => {
+ // Remove any existing schema
+ const existingSchema = document.querySelector('script[type="application/ld+json"]');
+ if (existingSchema) {
+ existingSchema.remove();
+ }
+
+ // Create the schema based on type
+ let schema = {
+ '@context': 'https://schema.org',
+ '@type': type,
+ ...data
+ };
+
+ // Add the schema to the page
+ const script = document.createElement('script');
+ script.type = 'application/ld+json';
+ script.text = JSON.stringify(schema);
+ document.head.appendChild(script);
+ };
+
+ /**
+ * Generate breadcrumb schema based on current path
+ */
+ const setBreadcrumbSchema = () => {
+ const paths = location.pathname.split('/').filter(Boolean);
+
+ if (paths.length === 0) return; // Don't set breadcrumbs for homepage
+
+ const itemListElements = [];
+ let currentPath = '';
+
+ // Always add Home as the first item
+ itemListElements.push({
+ '@type': 'ListItem',
+ 'position': 1,
+ 'name': 'Home',
+ 'item': `${window.location.origin}/`
+ });
+
+ // Add each path segment as a breadcrumb item
+ paths.forEach((path, index) => {
+ currentPath += `/${path}`;
+
+ // Format the name (capitalize, replace hyphens with spaces)
+ const name = path
+ .replace(/-/g, ' ')
+ .replace(/\b\w/g, char => char.toUpperCase());
+
+ itemListElements.push({
+ '@type': 'ListItem',
+ 'position': index + 2, // +2 because Home is position 1
+ 'name': name,
+ 'item': `${window.location.origin}${currentPath}`
+ });
+ });
+
+ const breadcrumbSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'BreadcrumbList',
+ 'itemListElement': itemListElements
+ };
+
+ // Add breadcrumb schema to the page
+ const script = document.createElement('script');
+ script.type = 'application/ld+json';
+ script.text = JSON.stringify(breadcrumbSchema);
+ document.head.appendChild(script);
+ };
+
+ /**
+ * Checks if sitemap.xml exists and is accessible
+ * @returns {Promise} - Whether sitemap exists
+ */
+ const checkSitemapExists = async () => {
+ try {
+ const response = await apiClient.head('/sitemap.xml');
+ return response.status === 200;
+ } catch (error) {
+ console.error('Error checking sitemap:', error);
+ return false;
+ }
+ };
+
+ /**
+ * Checks if robots.txt exists and is accessible
+ * @returns {Promise} - Whether robots.txt exists
+ */
+ const checkRobotsTxtExists = async () => {
+ try {
+ const response = await apiClient.head('/robots.txt');
+ return response.status === 200;
+ } catch (error) {
+ console.error('Error checking robots.txt:', error);
+ return false;
+ }
+ };
+
+ return {
+ setSchema,
+ setBreadcrumbSchema,
+ checkSitemapExists,
+ checkRobotsTxtExists
+ };
+};
+
+export default useSeoMeta;
\ No newline at end of file
diff --git a/frontend/src/services/seoapi.js b/frontend/src/services/seoapi.js
new file mode 100644
index 0000000..b869ad0
--- /dev/null
+++ b/frontend/src/services/seoapi.js
@@ -0,0 +1,42 @@
+import axios from 'axios';
+import { store } from '../store';
+
+// Create the base axios instance
+const axiosClient = axios.create({
+ baseURL: import.meta.env.VITE_API_URL.split('/api')[0],
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+});
+
+// Add request interceptor to include API key in headers if available
+axiosClient.interceptors.request.use(
+ (config) => {
+ const state = store.getState();
+ const apiKey = state.auth.apiKey;
+
+ if (apiKey) {
+ config.headers['X-API-Key'] = apiKey;
+ }
+
+ return config;
+ },
+ (error) => {
+ return Promise.reject(error);
+ }
+);
+
+// Add response interceptor to handle common errors
+axiosClient.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ // Handle 401 unauthorized errors
+ if (error.response && error.response.status === 401) {
+ console.log("Missing Seo Files")
+ }
+
+ return Promise.reject(error);
+ }
+);
+
+export default axiosClient;
\ No newline at end of file