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-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",
|
||||
|
|
|
|||
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 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;
|
||||
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,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
Loading…
Reference in a new issue