seo meta data on relavent pages

This commit is contained in:
2ManyProjects 2025-05-08 13:17:35 -05:00
parent 581d3ccfe7
commit 36c2dd98a2
13 changed files with 1603 additions and 4924 deletions

File diff suppressed because it is too large Load diff

View file

@ -27,6 +27,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-email-editor": "^1.7.11",
"react-helmet": "^6.1.0",
"react-redux": "^9.0.2",
"react-router-dom": "^6.20.1",
"recharts": "^2.10.3",

View 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;

View 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;

View file

@ -1,198 +1,246 @@
import { useEffect, useState } from 'react';
import { useMemo } from 'react';
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 {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
* @returns {Object} SEO metadata and structured data
*/
const useSeoMeta = (options = {}) => {
const location = useLocation();
const [isLoaded, setIsLoaded] = useState(false);
// Set default page metadata
useEffect(() => {
if (!options || isLoaded) return;
const { data: brandingSettings } = useBrandingSettings();
return useMemo(() => {
const {
title,
description,
image,
type = 'website'
type = 'website',
productData = null,
articleData = null,
productsData = null
} = 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;
}
const siteName = brandingSettings?.site_name || 'Rocks, Bones & Sticks';
const siteDescription = brandingSettings?.site_description ||
'Your premier source for natural curiosities and unique specimens';
// Set Open Graph meta tags
const updateMetaTag = (property, content) => {
if (!content) return;
const baseUrl = typeof window !== 'undefined' ?
`${window.location.protocol}//${window.location.host}` :
'';
let meta = document.querySelector(`meta[property="${property}"]`);
if (!meta) {
meta = document.createElement('meta');
meta.setAttribute('property', property);
document.head.appendChild(meta);
}
meta.content = content;
const canonical = `${baseUrl}${location.pathname}${location.search}`;
let structuredData = null;
// Default organization structured data
const organizationData = {
"@context": "https://schema.org",
"@type": "Organization",
"name": siteName,
"url": baseUrl,
...(brandingSettings?.logo_url && {
"logo": imageUtils.getImageUrl(brandingSettings.logo_url)
})
};
// 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;
if (productData) {
structuredData = generateProductStructuredData(productData, baseUrl);
}
// 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();
else if (productsData) {
structuredData = generateProductListStructuredData(productsData, baseUrl);
}
// 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<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;
else if (articleData) {
structuredData = generateArticleStructuredData(articleData, baseUrl);
}
else {
structuredData = {
"@context": "https://schema.org",
"@type": "WebSite",
"name": siteName,
"url": baseUrl,
"description": siteDescription,
"potentialAction": {
"@type": "SearchAction",
"target": {
"@type": "EntryPoint",
"urlTemplate": `${baseUrl}/products?search={search_term_string}`
},
"query-input": "required name=search_term_string"
}
};
/**
* 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 {
setSchema,
setBreadcrumbSchema,
checkSitemapExists,
checkRobotsTxtExists
title,
description: description || siteDescription,
image: image ? imageUtils.getImageUrl(image) : null,
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;

View 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;

View file

@ -19,20 +19,28 @@ import {
FormControl,
InputLabel,
CircularProgress,
Breadcrumbs,
Link,
Alert,
Container
} from '@mui/material';
import { useNavigate, useLocation, Link as RouterLink } from 'react-router-dom';
import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import { useBlogPosts, useBlogCategories } from '@hooks/blogHooks';
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 SEOMetaTags from '@components/SEOMetaTags';
const BlogPage = () => {
const navigate = useNavigate();
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
const { data: brandingSettings } = useBrandingSettings();
// State for filters and search
const [filters, setFilters] = useState({
@ -46,6 +54,31 @@ const BlogPage = () => {
const { data, isLoading, error } = useBlogPosts(filters);
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
useEffect(() => {
const params = new URLSearchParams();
@ -95,7 +128,23 @@ const BlogPage = () => {
if (!dateString) return '';
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
if (isLoading) {
return (
@ -120,6 +169,36 @@ const BlogPage = () => {
return (
<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 }}>
<Typography variant="h4" component="h1" gutterBottom>
Our Blog

View file

@ -126,7 +126,9 @@ const CartPage = () => {
<Link component={RouterLink} to="/" color="inherit">
Home
</Link>
<Typography color="text.primary">Your Cart</Typography>
<Link component={RouterLink} to="/cart" color="inherit">
Cart
</Link>
</Breadcrumbs>
<Typography variant="h4" component="h1" gutterBottom>

View file

@ -4,13 +4,69 @@ import { Link as RouterLink } from 'react-router-dom';
import { useProducts, useCategories } from '@hooks/apiHooks';
import imageUtils from '@utils/imageUtils';
import useBrandingSettings from '@hooks/brandingHooks';
import SEOMetaTags from '@components/SEOMetaTags';
import useSeoMeta from '@hooks/useSeoMeta';
import { Helmet } from 'react-helmet';
const HomePage = () => {
const { data: products, isLoading: productsLoading } = useProducts({ limit: 6 });
const { data: categories, isLoading: categoriesLoading } = useCategories();
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 (
<>
{/* 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>
{/* Hero Section */}
<Box
@ -30,7 +86,7 @@ const HomePage = () => {
{brandingSettings?.site_main_page_title || `Discover Natural Wonders`}
</Typography>
<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>
<Button
variant="contained"
@ -164,6 +220,7 @@ const HomePage = () => {
</Button>
</Box>
</Box>
</>
);
};

View file

@ -32,6 +32,8 @@ import { Link as RouterLink } from 'react-router-dom';
import { useProduct, useAddToCart } from '@hooks/apiHooks';
import { useAuth } from '@hooks/reduxHooks';
import imageUtils from '@utils/imageUtils';
import SEOMetaTags from '@components/SEOMetaTags';
import useProductSeo from '@hooks/useProductSeo';
const ProductDetailPage = () => {
const { id } = useParams();
@ -45,6 +47,17 @@ const ProductDetailPage = () => {
const { data: products, isLoading, error } = useProduct(id);
const addToCart = useAddToCart();
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
const increaseQuantity = () => {
if (product && quantity < product.stock_quantity) {
@ -154,6 +167,18 @@ const ProductDetailPage = () => {
}
return (
<>
{/* SEO Meta Tags */}
<SEOMetaTags
title={title}
description={description}
keywords={keywords}
image={image}
canonical={canonical}
type="product"
structuredData={structuredData}
/>
<Box>
{/* Breadcrumbs navigation */}
<Breadcrumbs
@ -175,26 +200,6 @@ const ProductDetailPage = () => {
{product.category_name}
</Link>
<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>
<Grid container spacing={4}>
@ -235,7 +240,7 @@ const ProductDetailPage = () => {
}}
>
<img
src={image.path}
src={imageUtils.getImageUrl(image.path)}
alt={`${product.name} thumbnail ${index + 1}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
@ -251,6 +256,27 @@ const ProductDetailPage = () => {
{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>
)}
<Typography variant="h5" color="primary" gutterBottom>
${parseFloat(product.price).toFixed(2)}
</Typography>
@ -370,14 +396,17 @@ const ProductDetailPage = () => {
))}
</TableBody>
</Table>
</TableContainer>
</Grid>
{/* Reviews Section */}
<Grid item xs={12}>
<Divider sx={{ my: 4 }} />
<ProductReviews productId={id} />
</Grid>
</TableContainer>
</Grid>
</Grid>
</Box>
</>
);
};

View file

@ -14,10 +14,12 @@ import {
Drawer,
List,
ListItem,
Breadcrumbs,
ListItemText,
Divider,
Chip,
FormControl,
Link,
InputLabel,
Select,
MenuItem,
@ -29,6 +31,7 @@ import SearchIcon from '@mui/icons-material/Search';
import FilterListIcon from '@mui/icons-material/FilterList';
import SortIcon from '@mui/icons-material/Sort';
import CloseIcon from '@mui/icons-material/Close';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import { Link as RouterLink, useNavigate, useLocation } from 'react-router-dom';
import { useProducts, useCategories, useTags, useAddToCart } from '@hooks/apiHooks';
@ -36,6 +39,9 @@ import ProductRatingDisplay from '@components/ProductRatingDisplay';
import { useAuth } from '@hooks/reduxHooks';
import imageUtils from '@utils/imageUtils';
import useBrandingSettings from '@hooks/brandingHooks';
import SEOMetaTags from '@components/SEOMetaTags';
import useSeoMeta from '@hooks/useSeoMeta';
import useSeoUrl from '@hooks/useSeoUrl';
const ProductsPage = () => {
const theme = useTheme();
@ -44,6 +50,7 @@ const ProductsPage = () => {
const { isAuthenticated, user } = useAuth();
const { data: brandingSettings } = useBrandingSettings();
const seoUrl = useSeoUrl();
// Parse query params
const queryParams = new URLSearchParams(location.search);
@ -138,10 +145,89 @@ const ProductsPage = () => {
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 (
<>
<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>
{brandingSettings?.product_title || `Products`}
{getPageTitle()}
</Typography>
{/* Search and filter bar */}
@ -349,6 +435,7 @@ const ProductsPage = () => {
alt={product.name}
sx={{ objectFit: 'cover' }}
onClick={() => navigate(`/products/${product.id}`)}
// onClick={() => navigate(seoUrl.getProductUrl(product))}
style={{ cursor: 'pointer' }}
/>
@ -357,6 +444,7 @@ const ProductsPage = () => {
variant="h6"
component={RouterLink}
to={`/products/${product.id}`}
// to={seoUrl.getProductUrl(product)}
sx={{
textDecoration: 'none',
color: 'inherit',
@ -557,6 +645,7 @@ const ProductsPage = () => {
</Box>
</Drawer>
</Box>
</>
);
};

View file

@ -24,14 +24,15 @@ import {
List,
ListItem,
ListItemText,
Breadcrumbs,
Divider,
Link
} from '@mui/material';
import { useNavigate, useLocation, Link as RouterLink } from 'react-router-dom';
import { format } from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { useUserOrders, useUserOrder } from '../hooks/apiHooks';
import ProductImage from '../components/ProductImage';
import { useUserOrders, useUserOrder } from '@hooks/apiHooks';
import ProductImage from '@components/ProductImage';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import useBrandingSettings from '@hooks/brandingHooks';
const UserOrdersPage = () => {
@ -129,6 +130,20 @@ const UserOrdersPage = () => {
return (
<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>
{brandingSettings?.orders_title || `My Orders`}
</Typography>

View file

@ -16,11 +16,6 @@ export const store = configureStore({
devTools: process.env.NODE_ENV !== 'production',
});
// Export types for cleaner usage in components
export * from '../features/auth/authSlice';
export * from '../features/cart/cartSlice';
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;