seo meta data on relavent pages
This commit is contained in:
parent
581d3ccfe7
commit
36c2dd98a2
13 changed files with 1603 additions and 4924 deletions
3982
frontend/package-lock.json
generated
3982
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -27,6 +27,7 @@
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-email-editor": "^1.7.11",
|
"react-email-editor": "^1.7.11",
|
||||||
|
"react-helmet": "^6.1.0",
|
||||||
"react-redux": "^9.0.2",
|
"react-redux": "^9.0.2",
|
||||||
"react-router-dom": "^6.20.1",
|
"react-router-dom": "^6.20.1",
|
||||||
"recharts": "^2.10.3",
|
"recharts": "^2.10.3",
|
||||||
|
|
|
||||||
103
frontend/src/components/SEOMetaTags.jsx
Normal file
103
frontend/src/components/SEOMetaTags.jsx
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Helmet } from 'react-helmet';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import useBrandingSettings from '@hooks/brandingHooks';
|
||||||
|
import imageUtils from '@utils/imageUtils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SEOMetaTags - Component for managing SEO metadata
|
||||||
|
*
|
||||||
|
* This component handles all SEO-related meta tags including Open Graph,
|
||||||
|
* Twitter Cards, and structured data for better search engine visibility
|
||||||
|
*/
|
||||||
|
const SEOMetaTags = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
keywords = [],
|
||||||
|
image,
|
||||||
|
canonical,
|
||||||
|
type = 'website',
|
||||||
|
structuredData = null,
|
||||||
|
noindex = false
|
||||||
|
}) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const { data: brandingSettings } = useBrandingSettings();
|
||||||
|
|
||||||
|
// Default site settings from branding
|
||||||
|
const siteName = brandingSettings?.site_name || 'Rocks, Bones & Sticks';
|
||||||
|
const defaultDescription = brandingSettings?.site_description ||
|
||||||
|
'Your premier source for natural curiosities and unique specimens';
|
||||||
|
|
||||||
|
// Format the title with the site name
|
||||||
|
const formattedTitle = title ? `${title} | ${siteName}` : siteName;
|
||||||
|
|
||||||
|
// Use provided description or fall back to default
|
||||||
|
const metaDescription = description || defaultDescription;
|
||||||
|
|
||||||
|
// Base URL construction - important for absolute URLs
|
||||||
|
const baseUrl = typeof window !== 'undefined' ?
|
||||||
|
`${window.location.protocol}//${window.location.host}` :
|
||||||
|
'';
|
||||||
|
|
||||||
|
// Construct the canonical URL correctly
|
||||||
|
const canonicalUrl = canonical ?
|
||||||
|
(canonical.startsWith('http') ? canonical : `${baseUrl}${canonical}`) :
|
||||||
|
`${baseUrl}${location.pathname}`;
|
||||||
|
|
||||||
|
// Default image from branding settings
|
||||||
|
const defaultImage = brandingSettings?.logo_url || '/logo.png';
|
||||||
|
const metaImage = image || defaultImage;
|
||||||
|
const imageUrl = imageUtils.getImageUrl(metaImage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Helmet>
|
||||||
|
{/* Basic meta tags */}
|
||||||
|
<title>{formattedTitle}</title>
|
||||||
|
<meta name="description" content={metaDescription} />
|
||||||
|
|
||||||
|
{/* Allow noindex pages */}
|
||||||
|
{noindex && <meta name="robots" content="noindex, nofollow" />}
|
||||||
|
|
||||||
|
{/* Keywords if provided */}
|
||||||
|
{keywords.length > 0 && <meta name="keywords" content={keywords.join(', ')} />}
|
||||||
|
|
||||||
|
{/* Canonical link */}
|
||||||
|
<link rel="canonical" href={canonicalUrl} />
|
||||||
|
|
||||||
|
{/* Open Graph tags for social sharing */}
|
||||||
|
<meta property="og:title" content={formattedTitle} />
|
||||||
|
<meta property="og:description" content={metaDescription} />
|
||||||
|
<meta property="og:image" content={imageUrl} />
|
||||||
|
<meta property="og:url" content={canonicalUrl} />
|
||||||
|
<meta property="og:type" content={type} />
|
||||||
|
<meta property="og:site_name" content={siteName} />
|
||||||
|
|
||||||
|
{/* Twitter Card tags */}
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content={formattedTitle} />
|
||||||
|
<meta name="twitter:description" content={metaDescription} />
|
||||||
|
<meta name="twitter:image" content={imageUrl} />
|
||||||
|
|
||||||
|
{/* Structured data for search engines */}
|
||||||
|
{structuredData && (
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{JSON.stringify(structuredData)}
|
||||||
|
</script>
|
||||||
|
)}
|
||||||
|
</Helmet>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
SEOMetaTags.propTypes = {
|
||||||
|
title: PropTypes.string,
|
||||||
|
description: PropTypes.string,
|
||||||
|
keywords: PropTypes.arrayOf(PropTypes.string),
|
||||||
|
image: PropTypes.string,
|
||||||
|
canonical: PropTypes.string,
|
||||||
|
type: PropTypes.string,
|
||||||
|
structuredData: PropTypes.object,
|
||||||
|
noindex: PropTypes.bool
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SEOMetaTags;
|
||||||
102
frontend/src/hooks/useProductSeo.js
Normal file
102
frontend/src/hooks/useProductSeo.js
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import useSeoMeta from './useSeoMeta';
|
||||||
|
import imageUtils from '@utils/imageUtils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for product SEO optimization
|
||||||
|
*
|
||||||
|
* @param {Object} product - Product data
|
||||||
|
* @returns {Object} SEO metadata for the product
|
||||||
|
*/
|
||||||
|
const useProductSeo = (product) => {
|
||||||
|
const seoMeta = useSeoMeta({
|
||||||
|
title: product?.name,
|
||||||
|
description: product?.description,
|
||||||
|
image: product?.images && product?.images.length > 0
|
||||||
|
? product.images.find(img => img.isPrimary)?.path || product.images[0].path
|
||||||
|
: null,
|
||||||
|
type: 'product',
|
||||||
|
productData: product
|
||||||
|
});
|
||||||
|
|
||||||
|
const keywords = useMemo(() => {
|
||||||
|
if (!product) return [];
|
||||||
|
|
||||||
|
const keywordsList = [];
|
||||||
|
|
||||||
|
if (product.name) keywordsList.push(product.name);
|
||||||
|
if (product.category_name) keywordsList.push(product.category_name);
|
||||||
|
|
||||||
|
if (product.tags && Array.isArray(product.tags)) {
|
||||||
|
keywordsList.push(...product.tags);
|
||||||
|
}
|
||||||
|
if (product.material_type) keywordsList.push(product.material_type);
|
||||||
|
if (product.origin) keywordsList.push(product.origin);
|
||||||
|
if (product.color) keywordsList.push(product.color);
|
||||||
|
|
||||||
|
return [...new Set(keywordsList)];
|
||||||
|
}, [product]);
|
||||||
|
|
||||||
|
// Generate product JSON
|
||||||
|
const richSnippet = useMemo(() => {
|
||||||
|
if (!product) return null;
|
||||||
|
|
||||||
|
let imageUrl = null;
|
||||||
|
if (product.images && product.images.length > 0) {
|
||||||
|
const primaryImage = product.images.find(img => img.isPrimary) || product.images[0];
|
||||||
|
imageUrl = imageUtils.getImageUrl(primaryImage.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dimensions = [];
|
||||||
|
if (product.length_cm) dimensions.push(`Length: ${product.length_cm}cm`);
|
||||||
|
if (product.width_cm) dimensions.push(`Width: ${product.width_cm}cm`);
|
||||||
|
if (product.height_cm) dimensions.push(`Height: ${product.height_cm}cm`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Product",
|
||||||
|
"name": product.name,
|
||||||
|
"image": imageUrl,
|
||||||
|
"description": product.description,
|
||||||
|
"sku": product.id,
|
||||||
|
"mpn": product.id,
|
||||||
|
"brand": {
|
||||||
|
"@type": "Brand",
|
||||||
|
"name": "Rocks, Bones & Sticks"
|
||||||
|
},
|
||||||
|
"offers": {
|
||||||
|
"@type": "Offer",
|
||||||
|
"url": window.location.href,
|
||||||
|
"priceCurrency": "CAD",
|
||||||
|
"price": parseFloat(product.price).toFixed(2),
|
||||||
|
"priceValidUntil": new Date(new Date().setFullYear(new Date().getFullYear() + 1)).toISOString().split('T')[0],
|
||||||
|
"itemCondition": "https://schema.org/NewCondition",
|
||||||
|
"availability": product.stock_quantity > 0
|
||||||
|
? "https://schema.org/InStock"
|
||||||
|
: "https://schema.org/OutOfStock"
|
||||||
|
},
|
||||||
|
...(product.average_rating && {
|
||||||
|
"aggregateRating": {
|
||||||
|
"@type": "AggregateRating",
|
||||||
|
"ratingValue": product.average_rating,
|
||||||
|
"reviewCount": product.review_count
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
...(dimensions.length > 0 && {
|
||||||
|
"additionalProperty": dimensions.map(dim => ({
|
||||||
|
"@type": "PropertyValue",
|
||||||
|
"name": dim.split(':')[0].trim(),
|
||||||
|
"value": dim.split(':')[1].trim()
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}, [product]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...seoMeta,
|
||||||
|
keywords,
|
||||||
|
richSnippet
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useProductSeo;
|
||||||
|
|
@ -1,198 +1,246 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import apiClient from '@services/api';
|
import useBrandingSettings from '@hooks/brandingHooks';
|
||||||
|
import imageUtils from '@utils/imageUtils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for managing SEO metadata
|
* Custom hook for generating SEO metadata and structured data
|
||||||
|
*
|
||||||
* @param {Object} options - Configuration options
|
* @param {Object} options - Configuration options
|
||||||
* @param {string} options.title - Page title
|
* @returns {Object} SEO metadata and structured data
|
||||||
* @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 useSeoMeta = (options = {}) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const { data: brandingSettings } = useBrandingSettings();
|
||||||
|
|
||||||
// Set default page metadata
|
|
||||||
useEffect(() => {
|
|
||||||
if (!options || isLoaded) return;
|
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
const {
|
const {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
image,
|
image,
|
||||||
type = 'website'
|
type = 'website',
|
||||||
|
productData = null,
|
||||||
|
articleData = null,
|
||||||
|
productsData = null
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
// Update document title
|
|
||||||
if (title) {
|
|
||||||
document.title = title;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update meta description
|
const siteName = brandingSettings?.site_name || 'Rocks, Bones & Sticks';
|
||||||
if (description) {
|
const siteDescription = brandingSettings?.site_description ||
|
||||||
let metaDescription = document.querySelector('meta[name="description"]');
|
'Your premier source for natural curiosities and unique specimens';
|
||||||
if (!metaDescription) {
|
|
||||||
metaDescription = document.createElement('meta');
|
|
||||||
metaDescription.name = 'description';
|
|
||||||
document.head.appendChild(metaDescription);
|
|
||||||
}
|
|
||||||
metaDescription.content = description;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set Open Graph meta tags
|
const baseUrl = typeof window !== 'undefined' ?
|
||||||
const updateMetaTag = (property, content) => {
|
`${window.location.protocol}//${window.location.host}` :
|
||||||
if (!content) return;
|
'';
|
||||||
|
|
||||||
let meta = document.querySelector(`meta[property="${property}"]`);
|
const canonical = `${baseUrl}${location.pathname}${location.search}`;
|
||||||
if (!meta) {
|
|
||||||
meta = document.createElement('meta');
|
let structuredData = null;
|
||||||
meta.setAttribute('property', property);
|
|
||||||
document.head.appendChild(meta);
|
// Default organization structured data
|
||||||
}
|
const organizationData = {
|
||||||
meta.content = content;
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": siteName,
|
||||||
|
"url": baseUrl,
|
||||||
|
...(brandingSettings?.logo_url && {
|
||||||
|
"logo": imageUtils.getImageUrl(brandingSettings.logo_url)
|
||||||
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set canonical URL
|
if (productData) {
|
||||||
const canonical = document.querySelector('link[rel="canonical"]');
|
structuredData = generateProductStructuredData(productData, baseUrl);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
else if (productsData) {
|
||||||
// Update Open Graph tags
|
structuredData = generateProductListStructuredData(productsData, baseUrl);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
else if (articleData) {
|
||||||
// Create the schema based on type
|
structuredData = generateArticleStructuredData(articleData, baseUrl);
|
||||||
let schema = {
|
}
|
||||||
'@context': 'https://schema.org',
|
else {
|
||||||
'@type': type,
|
structuredData = {
|
||||||
...data
|
"@context": "https://schema.org",
|
||||||
};
|
"@type": "WebSite",
|
||||||
|
"name": siteName,
|
||||||
// Add the schema to the page
|
"url": baseUrl,
|
||||||
const script = document.createElement('script');
|
"description": siteDescription,
|
||||||
script.type = 'application/ld+json';
|
"potentialAction": {
|
||||||
script.text = JSON.stringify(schema);
|
"@type": "SearchAction",
|
||||||
document.head.appendChild(script);
|
"target": {
|
||||||
};
|
"@type": "EntryPoint",
|
||||||
|
"urlTemplate": `${baseUrl}/products?search={search_term_string}`
|
||||||
/**
|
},
|
||||||
* Generate breadcrumb schema based on current path
|
"query-input": "required name=search_term_string"
|
||||||
*/
|
|
||||||
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<boolean>} - 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<boolean>} - 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 {
|
return {
|
||||||
setSchema,
|
title,
|
||||||
setBreadcrumbSchema,
|
description: description || siteDescription,
|
||||||
checkSitemapExists,
|
image: image ? imageUtils.getImageUrl(image) : null,
|
||||||
checkRobotsTxtExists
|
canonical,
|
||||||
|
type,
|
||||||
|
structuredData,
|
||||||
|
organizationData
|
||||||
};
|
};
|
||||||
|
}, [location, brandingSettings, options]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate structured data for a product
|
||||||
|
*/
|
||||||
|
function generateProductStructuredData(product, baseUrl) {
|
||||||
|
if (!product) return null;
|
||||||
|
|
||||||
|
// Get the primary image or the first image
|
||||||
|
let imageUrl = null;
|
||||||
|
if (product.images && product.images.length > 0) {
|
||||||
|
const primaryImage = product.images.find(img => img.isPrimary) || product.images[0];
|
||||||
|
imageUrl = imageUtils.getImageUrl(primaryImage.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const properties = [];
|
||||||
|
|
||||||
|
if (product.material_type) properties.push({
|
||||||
|
"@type": "PropertyValue",
|
||||||
|
"name": "Material",
|
||||||
|
"value": product.material_type
|
||||||
|
});
|
||||||
|
|
||||||
|
if (product.origin) properties.push({
|
||||||
|
"@type": "PropertyValue",
|
||||||
|
"name": "Origin",
|
||||||
|
"value": product.origin
|
||||||
|
});
|
||||||
|
|
||||||
|
if (product.age) properties.push({
|
||||||
|
"@type": "PropertyValue",
|
||||||
|
"name": "Age",
|
||||||
|
"value": product.age
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Product",
|
||||||
|
"name": product.name,
|
||||||
|
"description": product.description,
|
||||||
|
"sku": product.id,
|
||||||
|
"image": imageUrl,
|
||||||
|
"url": `${baseUrl}/products/${product.id}`,
|
||||||
|
"category": product.category_name,
|
||||||
|
"brand": {
|
||||||
|
"@type": "Brand",
|
||||||
|
"name": "Rocks, Bones & Sticks"
|
||||||
|
},
|
||||||
|
"offers": {
|
||||||
|
"@type": "Offer",
|
||||||
|
"url": `${baseUrl}/products/${product.id}`,
|
||||||
|
"priceCurrency": "CAD",
|
||||||
|
"price": parseFloat(product.price).toFixed(2),
|
||||||
|
"availability": product.stock_quantity > 0 ?
|
||||||
|
"https://schema.org/InStock" :
|
||||||
|
"https://schema.org/OutOfStock",
|
||||||
|
"priceValidUntil": new Date(new Date().setFullYear(new Date().getFullYear() + 1)).toISOString().split('T')[0]
|
||||||
|
},
|
||||||
|
...(product.average_rating && {
|
||||||
|
"aggregateRating": {
|
||||||
|
"@type": "AggregateRating",
|
||||||
|
"ratingValue": product.average_rating,
|
||||||
|
"reviewCount": product.review_count
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
...(properties.length > 0 && {
|
||||||
|
"additionalProperty": properties
|
||||||
|
}),
|
||||||
|
...(product.weight_grams && {
|
||||||
|
"weight": {
|
||||||
|
"@type": "QuantitativeValue",
|
||||||
|
"value": product.weight_grams,
|
||||||
|
"unitCode": "GRM"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate structured data for a product listing
|
||||||
|
*/
|
||||||
|
function generateProductListStructuredData(products, baseUrl) {
|
||||||
|
if (!products || products.length === 0) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "ItemList",
|
||||||
|
"itemListElement": products.map((product, index) => {
|
||||||
|
// Get product image
|
||||||
|
let imageUrl = null;
|
||||||
|
if (product.images && product.images.length > 0) {
|
||||||
|
const primaryImage = product.images.find(img => img.isPrimary) || product.images[0];
|
||||||
|
imageUrl = imageUtils.getImageUrl(primaryImage.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"@type": "ListItem",
|
||||||
|
"position": index + 1,
|
||||||
|
"item": {
|
||||||
|
"@type": "Product",
|
||||||
|
"name": product.name,
|
||||||
|
"url": `${baseUrl}/products/${product.id}`,
|
||||||
|
"image": imageUrl,
|
||||||
|
"description": product.description,
|
||||||
|
"offers": {
|
||||||
|
"@type": "Offer",
|
||||||
|
"price": parseFloat(product.price).toFixed(2),
|
||||||
|
"priceCurrency": "CAD",
|
||||||
|
"availability": product.stock_quantity > 0 ?
|
||||||
|
"https://schema.org/InStock" :
|
||||||
|
"https://schema.org/OutOfStock"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate structured data for a blog article
|
||||||
|
*/
|
||||||
|
function generateArticleStructuredData(article, baseUrl) {
|
||||||
|
if (!article) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BlogPosting",
|
||||||
|
"headline": article.title,
|
||||||
|
"description": article.excerpt || article.content.substring(0, 200),
|
||||||
|
"image": article.featured_image_path ?
|
||||||
|
imageUtils.getImageUrl(article.featured_image_path) : null,
|
||||||
|
"datePublished": article.published_at,
|
||||||
|
"dateModified": article.updated_at || article.published_at,
|
||||||
|
"author": {
|
||||||
|
"@type": "Person",
|
||||||
|
"name": `${article.author_first_name || ''} ${article.author_last_name || ''}`.trim()
|
||||||
|
},
|
||||||
|
"publisher": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "Rocks, Bones & Sticks",
|
||||||
|
"logo": {
|
||||||
|
"@type": "ImageObject",
|
||||||
|
"url": `${baseUrl}/logo.png`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mainEntityOfPage": {
|
||||||
|
"@type": "WebPage",
|
||||||
|
"@id": `${baseUrl}/blog/${article.slug}`
|
||||||
|
},
|
||||||
|
...(article.category_name && {
|
||||||
|
"articleSection": article.category_name
|
||||||
|
}),
|
||||||
|
...(article.tags && article.tags.length > 0 && {
|
||||||
|
"keywords": article.tags.join(', ')
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default useSeoMeta;
|
export default useSeoMeta;
|
||||||
141
frontend/src/hooks/useSeoUrl.js
Normal file
141
frontend/src/hooks/useSeoUrl.js
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for generating SEO-friendly URLs
|
||||||
|
*/
|
||||||
|
const useSeoUrl = () => {
|
||||||
|
return useMemo(() => ({
|
||||||
|
/**
|
||||||
|
* Generate a slug from a string
|
||||||
|
* @param {string} text - Text to convert to slug
|
||||||
|
* @returns {string} URL-friendly slug
|
||||||
|
*/
|
||||||
|
generateSlug: (text) => {
|
||||||
|
if (!text) return '';
|
||||||
|
|
||||||
|
return text
|
||||||
|
.toString()
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^\w\s-]/g, '') // Remove non-word chars
|
||||||
|
.replace(/[\s_-]+/g, '-') // Replace spaces and underscores with hyphens
|
||||||
|
.replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate SEO-friendly URL for a product
|
||||||
|
* @param {Object} product - Product object
|
||||||
|
* @returns {string} SEO-friendly URL
|
||||||
|
*/
|
||||||
|
getProductUrl: (product) => {
|
||||||
|
if (!product) return '/products';
|
||||||
|
|
||||||
|
const slug = product.name ? useSeoUrl().generateSlug(product.name) : '';
|
||||||
|
return `/products/${product.id}${slug ? `-${slug}` : ''}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate SEO-friendly URL for a category
|
||||||
|
* @param {Object|string} category - Category object or name
|
||||||
|
* @returns {string} SEO-friendly URL
|
||||||
|
*/
|
||||||
|
getCategoryUrl: (category) => {
|
||||||
|
if (!category) return '/products';
|
||||||
|
|
||||||
|
const categoryName = typeof category === 'string' ?
|
||||||
|
category :
|
||||||
|
(category.name || '');
|
||||||
|
|
||||||
|
const slug = useSeoUrl().generateSlug(categoryName);
|
||||||
|
return `/products?category=${encodeURIComponent(categoryName)}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate SEO-friendly URL for a blog post
|
||||||
|
* @param {Object} post - Blog post object
|
||||||
|
* @returns {string} SEO-friendly URL
|
||||||
|
*/
|
||||||
|
getBlogPostUrl: (post) => {
|
||||||
|
if (!post) return '/blog';
|
||||||
|
|
||||||
|
const slug = post.slug ||
|
||||||
|
(post.title ? useSeoUrl().generateSlug(post.title) : '');
|
||||||
|
|
||||||
|
return `/blog/${slug}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate SEO-friendly URL for a tag
|
||||||
|
* @param {string} tag - Tag name
|
||||||
|
* @param {string} [type='products'] - URL type ('products' or 'blog')
|
||||||
|
* @returns {string} SEO-friendly URL
|
||||||
|
*/
|
||||||
|
getTagUrl: (tag, type = 'products') => {
|
||||||
|
if (!tag) return `/${type}`;
|
||||||
|
|
||||||
|
return `/${type}?tag=${encodeURIComponent(tag)}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a URL to extract SEO components
|
||||||
|
* @param {string} url - URL to parse
|
||||||
|
* @returns {Object} Extracted components
|
||||||
|
*/
|
||||||
|
parseUrl: (url) => {
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
const cleanUrl = url.startsWith('/') ? url.substring(1) : url;
|
||||||
|
|
||||||
|
// Product detail page pattern: products/[id]-[slug]
|
||||||
|
const productPattern = /^products\/([a-f0-9-]+)(?:-(.+))?$/;
|
||||||
|
const productMatch = cleanUrl.match(productPattern);
|
||||||
|
|
||||||
|
if (productMatch) {
|
||||||
|
return {
|
||||||
|
type: 'product',
|
||||||
|
id: productMatch[1],
|
||||||
|
slug: productMatch[2] || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blog post pattern: blog/[slug]
|
||||||
|
const blogPattern = /^blog\/(.+)$/;
|
||||||
|
const blogMatch = cleanUrl.match(blogPattern);
|
||||||
|
|
||||||
|
if (blogMatch) {
|
||||||
|
return {
|
||||||
|
type: 'blogPost',
|
||||||
|
slug: blogMatch[1]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Product category pattern: products?category=[name]
|
||||||
|
if (cleanUrl.startsWith('products?category=')) {
|
||||||
|
const category = decodeURIComponent(
|
||||||
|
cleanUrl.replace('products?category=', '')
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'category',
|
||||||
|
name: category
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag pattern: [type]?tag=[name]
|
||||||
|
const tagPattern = /^(products|blog)\?tag=(.+)$/;
|
||||||
|
const tagMatch = cleanUrl.match(tagPattern);
|
||||||
|
|
||||||
|
if (tagMatch) {
|
||||||
|
return {
|
||||||
|
type: 'tag',
|
||||||
|
contentType: tagMatch[1],
|
||||||
|
name: decodeURIComponent(tagMatch[2])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}), []);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useSeoUrl;
|
||||||
|
|
@ -19,20 +19,28 @@ import {
|
||||||
FormControl,
|
FormControl,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
|
Breadcrumbs,
|
||||||
|
Link,
|
||||||
Alert,
|
Alert,
|
||||||
Container
|
Container
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useNavigate, useLocation, Link as RouterLink } from 'react-router-dom';
|
import { useNavigate, useLocation, Link as RouterLink } from 'react-router-dom';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import ClearIcon from '@mui/icons-material/Clear';
|
import ClearIcon from '@mui/icons-material/Clear';
|
||||||
|
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||||
import { useBlogPosts, useBlogCategories } from '@hooks/blogHooks';
|
import { useBlogPosts, useBlogCategories } from '@hooks/blogHooks';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import {Helmet} from 'react-helmet'
|
||||||
|
import useSeoMeta from '@hooks/useSeoMeta';
|
||||||
|
import useBrandingSettings from '@hooks/brandingHooks';
|
||||||
import imageUtils from '@utils/imageUtils';
|
import imageUtils from '@utils/imageUtils';
|
||||||
|
import SEOMetaTags from '@components/SEOMetaTags';
|
||||||
|
|
||||||
const BlogPage = () => {
|
const BlogPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const searchParams = new URLSearchParams(location.search);
|
const searchParams = new URLSearchParams(location.search);
|
||||||
|
const { data: brandingSettings } = useBrandingSettings();
|
||||||
|
|
||||||
// State for filters and search
|
// State for filters and search
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
|
|
@ -46,6 +54,31 @@ const BlogPage = () => {
|
||||||
const { data, isLoading, error } = useBlogPosts(filters);
|
const { data, isLoading, error } = useBlogPosts(filters);
|
||||||
const { data: categories } = useBlogCategories();
|
const { data: categories } = useBlogCategories();
|
||||||
|
|
||||||
|
|
||||||
|
const { structuredData } = useSeoMeta();
|
||||||
|
|
||||||
|
// Generate blog-specific structured data
|
||||||
|
const blogStructuredData = data?.posts && data.posts.length > 0 ? {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Blog",
|
||||||
|
"name": brandingSettings?.blog_title || "Our Blog",
|
||||||
|
"description": brandingSettings?.blog_desc || "Discover insights about our natural collections, sourcing adventures, and unique specimens",
|
||||||
|
"url": `${window.location.origin}/blog`,
|
||||||
|
"blogPost": data.posts.map(post => ({
|
||||||
|
"@type": "BlogPosting",
|
||||||
|
"headline": post.title,
|
||||||
|
"description": post.excerpt || (post.content && post.content.substring(0, 150) + '...'),
|
||||||
|
"url": `${window.location.origin}/blog/${post.slug}`,
|
||||||
|
"datePublished": post.published_at,
|
||||||
|
"image": post.featured_image_path ? imageUtils.getImageUrl(post.featured_image_path) : null,
|
||||||
|
"author": {
|
||||||
|
"@type": "Person",
|
||||||
|
"name": `${post.author_first_name || ''} ${post.author_last_name || ''}`.trim()
|
||||||
|
},
|
||||||
|
"keywords": post.tags ? post.tags.join(', ') : undefined
|
||||||
|
}))
|
||||||
|
} : null;
|
||||||
|
|
||||||
// Update URL when filters change
|
// Update URL when filters change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
@ -95,7 +128,23 @@ const BlogPage = () => {
|
||||||
if (!dateString) return '';
|
if (!dateString) return '';
|
||||||
return format(new Date(dateString), 'MMMM d, yyyy');
|
return format(new Date(dateString), 'MMMM d, yyyy');
|
||||||
};
|
};
|
||||||
|
// SEO title based on filters
|
||||||
|
let seoTitle = brandingSettings?.blog_title || "Our Blog";
|
||||||
|
if (filters.category) {
|
||||||
|
seoTitle = `${filters.category} Posts | ${seoTitle}`;
|
||||||
|
} else if (filters.tag) {
|
||||||
|
seoTitle = `Posts Tagged "${filters.tag}" | ${seoTitle}`;
|
||||||
|
} else if (filters.search) {
|
||||||
|
seoTitle = `Search Results for "${filters.search}" | ${seoTitle}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SEO description
|
||||||
|
let seoDescription = brandingSettings?.blog_desc ||
|
||||||
|
"Discover insights about our natural collections, sourcing adventures, and unique specimens";
|
||||||
|
|
||||||
|
if (filters.category || filters.tag || filters.search) {
|
||||||
|
seoDescription = `${seoTitle}. ${seoDescription}`;
|
||||||
|
}
|
||||||
// Loading state
|
// Loading state
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -120,6 +169,36 @@ const BlogPage = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="lg">
|
<Container maxWidth="lg">
|
||||||
|
|
||||||
|
{/* SEO Meta Tags */}
|
||||||
|
<SEOMetaTags
|
||||||
|
title={seoTitle}
|
||||||
|
description={seoDescription}
|
||||||
|
type="website"
|
||||||
|
structuredData={structuredData}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Blog-specific structured data */}
|
||||||
|
{blogStructuredData && (
|
||||||
|
<Helmet>
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{JSON.stringify(blogStructuredData)}
|
||||||
|
</script>
|
||||||
|
</Helmet>
|
||||||
|
)}
|
||||||
|
{/* Breadcrumbs */}
|
||||||
|
<Breadcrumbs
|
||||||
|
separator={<NavigateNextIcon fontSize="small" />}
|
||||||
|
aria-label="breadcrumb"
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
>
|
||||||
|
<Link component={RouterLink} to="/" color="inherit">
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
<Link component={RouterLink} to="/blog" color="inherit">
|
||||||
|
Blog
|
||||||
|
</Link>
|
||||||
|
</Breadcrumbs>
|
||||||
<Box sx={{ py: 4 }}>
|
<Box sx={{ py: 4 }}>
|
||||||
<Typography variant="h4" component="h1" gutterBottom>
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
Our Blog
|
Our Blog
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,9 @@ const CartPage = () => {
|
||||||
<Link component={RouterLink} to="/" color="inherit">
|
<Link component={RouterLink} to="/" color="inherit">
|
||||||
Home
|
Home
|
||||||
</Link>
|
</Link>
|
||||||
<Typography color="text.primary">Your Cart</Typography>
|
<Link component={RouterLink} to="/cart" color="inherit">
|
||||||
|
Cart
|
||||||
|
</Link>
|
||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
|
|
||||||
<Typography variant="h4" component="h1" gutterBottom>
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,69 @@ import { Link as RouterLink } from 'react-router-dom';
|
||||||
import { useProducts, useCategories } from '@hooks/apiHooks';
|
import { useProducts, useCategories } from '@hooks/apiHooks';
|
||||||
import imageUtils from '@utils/imageUtils';
|
import imageUtils from '@utils/imageUtils';
|
||||||
import useBrandingSettings from '@hooks/brandingHooks';
|
import useBrandingSettings from '@hooks/brandingHooks';
|
||||||
|
import SEOMetaTags from '@components/SEOMetaTags';
|
||||||
|
import useSeoMeta from '@hooks/useSeoMeta';
|
||||||
|
import { Helmet } from 'react-helmet';
|
||||||
|
|
||||||
const HomePage = () => {
|
const HomePage = () => {
|
||||||
const { data: products, isLoading: productsLoading } = useProducts({ limit: 6 });
|
const { data: products, isLoading: productsLoading } = useProducts({ limit: 6 });
|
||||||
const { data: categories, isLoading: categoriesLoading } = useCategories();
|
const { data: categories, isLoading: categoriesLoading } = useCategories();
|
||||||
const { data: brandingSettings } = useBrandingSettings();
|
const { data: brandingSettings } = useBrandingSettings();
|
||||||
|
|
||||||
|
// Get SEO metadata
|
||||||
|
const {
|
||||||
|
title: defaultTitle,
|
||||||
|
description: defaultDescription,
|
||||||
|
structuredData
|
||||||
|
} = useSeoMeta();
|
||||||
|
|
||||||
|
// Homepage-specific SEO metadata
|
||||||
|
const seoTitle = brandingSettings?.site_main_page_title || 'Discover Natural Wonders';
|
||||||
|
const seoDescription = brandingSettings?.site_main_page_subtitle ||
|
||||||
|
'Explore our collection of unique rocks, bones, and sticks from around the world';
|
||||||
|
|
||||||
|
// Generate structured data for featured products
|
||||||
|
const featuredProductsData = products && products.length > 0 ? {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "ItemList",
|
||||||
|
"itemListElement": products.slice(0, 6).map((product, index) => ({
|
||||||
|
"@type": "ListItem",
|
||||||
|
"position": index + 1,
|
||||||
|
"item": {
|
||||||
|
"@type": "Product",
|
||||||
|
"name": product.name,
|
||||||
|
"image": product.images && product.images.length > 0 ?
|
||||||
|
imageUtils.getImageUrl(product.images.find(img => img.isPrimary)?.path || product.images[0].path) : null,
|
||||||
|
"description": product.description,
|
||||||
|
"url": `${window.location.origin}/products/${product.id}`,
|
||||||
|
"offers": {
|
||||||
|
"@type": "Offer",
|
||||||
|
"price": parseFloat(product.price).toFixed(2),
|
||||||
|
"priceCurrency": "CAD"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
} : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{/* SEO Meta Tags */}
|
||||||
|
<SEOMetaTags
|
||||||
|
title={seoTitle}
|
||||||
|
description={seoDescription}
|
||||||
|
type="website"
|
||||||
|
structuredData={structuredData}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Structured data for featured products */}
|
||||||
|
{featuredProductsData && (
|
||||||
|
<Helmet>
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{JSON.stringify(featuredProductsData)}
|
||||||
|
</script>
|
||||||
|
</Helmet>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<Box
|
<Box
|
||||||
|
|
@ -30,7 +86,7 @@ const HomePage = () => {
|
||||||
{brandingSettings?.site_main_page_title || `Discover Natural Wonders`}
|
{brandingSettings?.site_main_page_title || `Discover Natural Wonders`}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h5" paragraph>
|
<Typography variant="h5" paragraph>
|
||||||
{brandingSettings?.site_main_page_subtitle || `Unique rocks, bones, and sticks from around my backyards`}
|
{brandingSettings?.site_main_page_subtitle || `Unique rocks, bones, and sticks from around the world`}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
|
|
@ -164,6 +220,7 @@ const HomePage = () => {
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ import { Link as RouterLink } from 'react-router-dom';
|
||||||
import { useProduct, useAddToCart } from '@hooks/apiHooks';
|
import { useProduct, useAddToCart } from '@hooks/apiHooks';
|
||||||
import { useAuth } from '@hooks/reduxHooks';
|
import { useAuth } from '@hooks/reduxHooks';
|
||||||
import imageUtils from '@utils/imageUtils';
|
import imageUtils from '@utils/imageUtils';
|
||||||
|
import SEOMetaTags from '@components/SEOMetaTags';
|
||||||
|
import useProductSeo from '@hooks/useProductSeo';
|
||||||
|
|
||||||
const ProductDetailPage = () => {
|
const ProductDetailPage = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
|
|
@ -45,6 +47,17 @@ const ProductDetailPage = () => {
|
||||||
const { data: products, isLoading, error } = useProduct(id);
|
const { data: products, isLoading, error } = useProduct(id);
|
||||||
const addToCart = useAddToCart();
|
const addToCart = useAddToCart();
|
||||||
let product = products?.length > 0 ? products[0] : null
|
let product = products?.length > 0 ? products[0] : null
|
||||||
|
|
||||||
|
// Get SEO metadata for the product
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
keywords,
|
||||||
|
image,
|
||||||
|
canonical,
|
||||||
|
structuredData
|
||||||
|
} = useProductSeo(product);
|
||||||
|
|
||||||
// Handle quantity changes
|
// Handle quantity changes
|
||||||
const increaseQuantity = () => {
|
const increaseQuantity = () => {
|
||||||
if (product && quantity < product.stock_quantity) {
|
if (product && quantity < product.stock_quantity) {
|
||||||
|
|
@ -154,6 +167,18 @@ const ProductDetailPage = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{/* SEO Meta Tags */}
|
||||||
|
<SEOMetaTags
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
keywords={keywords}
|
||||||
|
image={image}
|
||||||
|
canonical={canonical}
|
||||||
|
type="product"
|
||||||
|
structuredData={structuredData}
|
||||||
|
/>
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
{/* Breadcrumbs navigation */}
|
{/* Breadcrumbs navigation */}
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
|
|
@ -175,26 +200,6 @@ const ProductDetailPage = () => {
|
||||||
{product.category_name}
|
{product.category_name}
|
||||||
</Link>
|
</Link>
|
||||||
<Typography color="text.primary">{product.name}</Typography>
|
<Typography color="text.primary">{product.name}</Typography>
|
||||||
{product.average_rating && (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1, mb: 2 }}>
|
|
||||||
<Rating
|
|
||||||
value={product.average_rating}
|
|
||||||
readOnly
|
|
||||||
precision={0.5}
|
|
||||||
/>
|
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ ml: 1 }}>
|
|
||||||
{product.average_rating} ({product.review_count} {product.review_count === 1 ? 'review' : 'reviews'})
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!product.average_rating && (
|
|
||||||
<Box sx={{ mt: 1, mb: 2 }}>
|
|
||||||
<Typography variant="body2" color="text.secondary">
|
|
||||||
No reviews yet
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
|
|
||||||
<Grid container spacing={4}>
|
<Grid container spacing={4}>
|
||||||
|
|
@ -235,7 +240,7 @@ const ProductDetailPage = () => {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={image.path}
|
src={imageUtils.getImageUrl(image.path)}
|
||||||
alt={`${product.name} thumbnail ${index + 1}`}
|
alt={`${product.name} thumbnail ${index + 1}`}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -251,6 +256,27 @@ const ProductDetailPage = () => {
|
||||||
{product.name}
|
{product.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
{product.average_rating && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1, mb: 2 }}>
|
||||||
|
<Rating
|
||||||
|
value={product.average_rating}
|
||||||
|
readOnly
|
||||||
|
precision={0.5}
|
||||||
|
/>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ ml: 1 }}>
|
||||||
|
{product.average_rating} ({product.review_count} {product.review_count === 1 ? 'review' : 'reviews'})
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!product.average_rating && (
|
||||||
|
<Box sx={{ mt: 1, mb: 2 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
No reviews yet
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<Typography variant="h5" color="primary" gutterBottom>
|
<Typography variant="h5" color="primary" gutterBottom>
|
||||||
${parseFloat(product.price).toFixed(2)}
|
${parseFloat(product.price).toFixed(2)}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
@ -370,14 +396,17 @@ const ProductDetailPage = () => {
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Reviews Section */}
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<Divider sx={{ my: 4 }} />
|
<Divider sx={{ my: 4 }} />
|
||||||
<ProductReviews productId={id} />
|
<ProductReviews productId={id} />
|
||||||
</Grid>
|
</Grid>
|
||||||
</TableContainer>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Box>
|
</Box>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,12 @@ import {
|
||||||
Drawer,
|
Drawer,
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
|
Breadcrumbs,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
Divider,
|
Divider,
|
||||||
Chip,
|
Chip,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
Link,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
Select,
|
Select,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
|
|
@ -29,6 +31,7 @@ import SearchIcon from '@mui/icons-material/Search';
|
||||||
import FilterListIcon from '@mui/icons-material/FilterList';
|
import FilterListIcon from '@mui/icons-material/FilterList';
|
||||||
import SortIcon from '@mui/icons-material/Sort';
|
import SortIcon from '@mui/icons-material/Sort';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||||
import { Link as RouterLink, useNavigate, useLocation } from 'react-router-dom';
|
import { Link as RouterLink, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useProducts, useCategories, useTags, useAddToCart } from '@hooks/apiHooks';
|
import { useProducts, useCategories, useTags, useAddToCart } from '@hooks/apiHooks';
|
||||||
|
|
@ -36,6 +39,9 @@ import ProductRatingDisplay from '@components/ProductRatingDisplay';
|
||||||
import { useAuth } from '@hooks/reduxHooks';
|
import { useAuth } from '@hooks/reduxHooks';
|
||||||
import imageUtils from '@utils/imageUtils';
|
import imageUtils from '@utils/imageUtils';
|
||||||
import useBrandingSettings from '@hooks/brandingHooks';
|
import useBrandingSettings from '@hooks/brandingHooks';
|
||||||
|
import SEOMetaTags from '@components/SEOMetaTags';
|
||||||
|
import useSeoMeta from '@hooks/useSeoMeta';
|
||||||
|
import useSeoUrl from '@hooks/useSeoUrl';
|
||||||
|
|
||||||
const ProductsPage = () => {
|
const ProductsPage = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
@ -44,6 +50,7 @@ const ProductsPage = () => {
|
||||||
const { isAuthenticated, user } = useAuth();
|
const { isAuthenticated, user } = useAuth();
|
||||||
const { data: brandingSettings } = useBrandingSettings();
|
const { data: brandingSettings } = useBrandingSettings();
|
||||||
|
|
||||||
|
const seoUrl = useSeoUrl();
|
||||||
// Parse query params
|
// Parse query params
|
||||||
const queryParams = new URLSearchParams(location.search);
|
const queryParams = new URLSearchParams(location.search);
|
||||||
|
|
||||||
|
|
@ -138,10 +145,89 @@ const ProductsPage = () => {
|
||||||
products.slice((page - 1) * itemsPerPage, page * itemsPerPage) :
|
products.slice((page - 1) * itemsPerPage, page * itemsPerPage) :
|
||||||
[];
|
[];
|
||||||
|
|
||||||
|
const seoMeta = useSeoMeta({
|
||||||
|
title: getPageTitle(),
|
||||||
|
description: getPageDescription(),
|
||||||
|
productsData: paginatedProducts // Pass products data for structured data generation
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate page title based on filters
|
||||||
|
function getPageTitle() {
|
||||||
|
let title = brandingSettings?.product_title || 'Products';
|
||||||
|
|
||||||
|
// Add category name to title if filtered by category
|
||||||
|
if (filters.category) {
|
||||||
|
title = `${filters.category} ${title}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tag to title if filtered by tag
|
||||||
|
if (filters.tag) {
|
||||||
|
title = `${filters.tag} ${title}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add search term to title if searching
|
||||||
|
if (filters.search) {
|
||||||
|
title = `${title} - Search: ${filters.search}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate page description based on filters
|
||||||
|
function getPageDescription() {
|
||||||
|
let description = `Browse our collection of unique ${brandingSettings?.product_title || 'products'}`;
|
||||||
|
|
||||||
|
// Add category to description if filtered
|
||||||
|
if (filters.category) {
|
||||||
|
description = `${description} in the ${filters.category} category`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tag to description if filtered
|
||||||
|
if (filters.tag) {
|
||||||
|
description = `${description}${filters.category ? ' and' : ''} tagged with ${filters.tag}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add search term to description if searching
|
||||||
|
if (filters.search) {
|
||||||
|
description = `Search results for "${filters.search}" in our ${brandingSettings?.product_title || 'products'} collection`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Box>
|
<Box>
|
||||||
|
{/* Breadcrumbs navigation */}
|
||||||
|
<Breadcrumbs
|
||||||
|
separator={<NavigateNextIcon fontSize="small" />}
|
||||||
|
aria-label="breadcrumb"
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
>
|
||||||
|
<Link component={RouterLink} to="/" color="inherit">
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
<Link component={RouterLink} to="/products" onClick={clearFilters} color="inherit">
|
||||||
|
Products
|
||||||
|
</Link>
|
||||||
|
{filters.category && <Link
|
||||||
|
component={RouterLink}
|
||||||
|
to={`/products?category=${filters.category}`}
|
||||||
|
color="inherit"
|
||||||
|
>
|
||||||
|
{filters.category}
|
||||||
|
</Link>}
|
||||||
|
</Breadcrumbs>
|
||||||
|
|
||||||
|
<SEOMetaTags
|
||||||
|
title={getPageTitle()}
|
||||||
|
description={getPageDescription()}
|
||||||
|
canonical={`/products${location.search}`}
|
||||||
|
type="website"
|
||||||
|
structuredData={seoMeta.structuredData}
|
||||||
|
/>
|
||||||
<Typography variant="h4" component="h1" gutterBottom>
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
{brandingSettings?.product_title || `Products`}
|
{getPageTitle()}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* Search and filter bar */}
|
{/* Search and filter bar */}
|
||||||
|
|
@ -349,6 +435,7 @@ const ProductsPage = () => {
|
||||||
alt={product.name}
|
alt={product.name}
|
||||||
sx={{ objectFit: 'cover' }}
|
sx={{ objectFit: 'cover' }}
|
||||||
onClick={() => navigate(`/products/${product.id}`)}
|
onClick={() => navigate(`/products/${product.id}`)}
|
||||||
|
// onClick={() => navigate(seoUrl.getProductUrl(product))}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -357,6 +444,7 @@ const ProductsPage = () => {
|
||||||
variant="h6"
|
variant="h6"
|
||||||
component={RouterLink}
|
component={RouterLink}
|
||||||
to={`/products/${product.id}`}
|
to={`/products/${product.id}`}
|
||||||
|
// to={seoUrl.getProductUrl(product)}
|
||||||
sx={{
|
sx={{
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
color: 'inherit',
|
color: 'inherit',
|
||||||
|
|
@ -557,6 +645,7 @@ const ProductsPage = () => {
|
||||||
</Box>
|
</Box>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</Box>
|
</Box>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,14 +24,15 @@ import {
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
|
Breadcrumbs,
|
||||||
Divider,
|
Divider,
|
||||||
Link
|
Link
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
import { useNavigate, useLocation, Link as RouterLink } from 'react-router-dom';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useUserOrders, useUserOrder } from '@hooks/apiHooks';
|
||||||
import { useUserOrders, useUserOrder } from '../hooks/apiHooks';
|
import ProductImage from '@components/ProductImage';
|
||||||
import ProductImage from '../components/ProductImage';
|
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||||
|
|
||||||
import useBrandingSettings from '@hooks/brandingHooks';
|
import useBrandingSettings from '@hooks/brandingHooks';
|
||||||
|
|
||||||
const UserOrdersPage = () => {
|
const UserOrdersPage = () => {
|
||||||
|
|
@ -129,6 +130,20 @@ const UserOrdersPage = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
|
||||||
|
{/* Breadcrumbs */}
|
||||||
|
<Breadcrumbs
|
||||||
|
separator={<NavigateNextIcon fontSize="small" />}
|
||||||
|
aria-label="breadcrumb"
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
>
|
||||||
|
<Link component={RouterLink} to="/" color="inherit">
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
<Link component={RouterLink} to="/account/orders" color="inherit">
|
||||||
|
Orders
|
||||||
|
</Link>
|
||||||
|
</Breadcrumbs>
|
||||||
<Typography variant="h4" component="h1" gutterBottom>
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
{brandingSettings?.orders_title || `My Orders`}
|
{brandingSettings?.orders_title || `My Orders`}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,6 @@ export const store = configureStore({
|
||||||
devTools: process.env.NODE_ENV !== 'production',
|
devTools: process.env.NODE_ENV !== 'production',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export types for cleaner usage in components
|
|
||||||
export * from '../features/auth/authSlice';
|
export * from '../features/auth/authSlice';
|
||||||
export * from '../features/cart/cartSlice';
|
export * from '../features/cart/cartSlice';
|
||||||
export * from '../features/ui/uiSlice';
|
export * from '../features/ui/uiSlice';
|
||||||
|
|
||||||
// Infer the `RootState` and `AppDispatch` types from the store itself
|
|
||||||
// export type RootState = ReturnType<typeof store.getState>;
|
|
||||||
// export type AppDispatch = typeof store.dispatch;
|
|
||||||
Loading…
Reference in a new issue