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": "^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",

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 { 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}`; }
else if (productsData) {
if (!canonical) { structuredData = generateProductListStructuredData(productsData, baseUrl);
const link = document.createElement('link'); }
link.rel = 'canonical'; else if (articleData) {
link.href = url; structuredData = generateArticleStructuredData(articleData, baseUrl);
document.head.appendChild(link); }
} else { else {
canonical.href = url; 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"
}
};
} }
// Update Open Graph tags return {
if (title) updateMetaTag('og:title', title); title,
if (description) updateMetaTag('og:description', description); description: description || siteDescription,
if (image) updateMetaTag('og:image', image); image: image ? imageUtils.getImageUrl(image) : null,
updateMetaTag('og:url', url); canonical,
updateMetaTag('og:type', type); type,
structuredData,
// Update Twitter Card tags organizationData
if (title) updateMetaTag('twitter:title', title);
if (description) updateMetaTag('twitter:description', description);
if (image) updateMetaTag('twitter:image', image);
updateMetaTag('twitter:card', image ? 'summary_large_image' : 'summary');
setIsLoaded(true);
}, [options, location.pathname, isLoaded]);
/**
* Function to fetch and insert structured data schema
* @param {string} type - Schema type (Product, Article, etc.)
* @param {Object} data - Schema data
*/
const setSchema = (type, data) => {
// Remove any existing schema
const existingSchema = document.querySelector('script[type="application/ld+json"]');
if (existingSchema) {
existingSchema.remove();
}
// Create the schema based on type
let schema = {
'@context': 'https://schema.org',
'@type': type,
...data
}; };
}, [location, brandingSettings, options]);
// 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;
}
};
/**
* 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
};
}; };
/**
* 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;

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, 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

View file

@ -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>

View file

@ -4,166 +4,223 @@ 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 (
<Box> <>
{/* Hero Section */} {/* SEO Meta Tags */}
<Box <SEOMetaTags
sx={{ title={seoTitle}
bgcolor: 'primary.main', description={seoDescription}
color: 'primary.contrastText', type="website"
py: 8, structuredData={structuredData}
mb: 6, />
borderRadius: 2,
backgroundImage: 'linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5))', {/* Structured data for featured products */}
backgroundSize: 'cover', {featuredProductsData && (
backgroundPosition: 'center', <Helmet>
}} <script type="application/ld+json">
> {JSON.stringify(featuredProductsData)}
<Container maxWidth="md"> </script>
<Typography variant="h2" component="h1" gutterBottom> </Helmet>
{brandingSettings?.site_main_page_title || `Discover Natural Wonders`} )}
</Typography>
<Typography variant="h5" paragraph> <Box>
{brandingSettings?.site_main_page_subtitle || `Unique rocks, bones, and sticks from around my backyards`} {/* Hero Section */}
<Box
sx={{
bgcolor: 'primary.main',
color: 'primary.contrastText',
py: 8,
mb: 6,
borderRadius: 2,
backgroundImage: 'linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5))',
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
<Container maxWidth="md">
<Typography variant="h2" component="h1" gutterBottom>
{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 the world`}
</Typography>
<Button
variant="contained"
color="secondary"
size="large"
component={RouterLink}
to="/products"
sx={{ mt: 2 }}
>
Shop Now
</Button>
</Container>
</Box>
{/* Categories Section */}
<Typography variant="h4" component="h2" gutterBottom>
Shop by Category
</Typography>
{!categoriesLoading && categories && (
<Grid container spacing={3} mb={6}>
{categories.map((category) => (
<Grid item xs={12} sm={4} key={category.id}>
<Card
component={RouterLink}
to={`/products?category=${category.name}`}
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
textDecoration: 'none',
transition: '0.3s',
'&:hover': {
transform: 'scale(1.03)',
boxShadow: (theme) => theme.shadows[8],
},
}}
>
<CardMedia
component="img"
height="200"
image={category.image_path ? imageUtils.getImageUrl(category.image_path) : "https://placehold.co/600x400/000000/FFFF"}
alt={category.name}
/>
<CardContent>
<Typography gutterBottom variant="h5" component="div">
{category.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{category.description}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
{/* Featured Products Section */}
<Typography variant="h4" component="h2" gutterBottom>
Featured {brandingSettings?.product_title || `Products`}
</Typography>
{!productsLoading && products && (
<Grid container spacing={3}>
{products.slice(0, 6).map((product) => (
<Grid item xs={12} sm={6} md={4} key={product.id}>
<Card
component={RouterLink}
to={`/products/${product.id}`}
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
textDecoration: 'none',
transition: '0.3s',
'&:hover': {
transform: 'scale(1.03)',
boxShadow: (theme) => theme.shadows[8],
},
}}
>
<CardMedia
component="img"
height="200"
image={(product.images && product.images.length > 0)
? imageUtils.getImageUrl(product.images.find(img => img.isPrimary)?.path || product.images[0].path
) : "https://placehold.co/600x400/000000/FFFF"}
alt={product.name}
/>
<CardContent sx={{ flexGrow: 1 }}>
<Typography gutterBottom variant="h6" component="div">
{product.name}
</Typography>
<Typography variant="body2" color="text.secondary" mb={2}>
{product.description.length > 100
? `${product.description.substring(0, 100)}...`
: product.description}
</Typography>
<Typography variant="h6" color="primary">
${parseFloat(product.price).toFixed(2)}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
{/* Call to Action */}
<Box
sx={{
mt: 8,
py: 6,
textAlign: 'center',
bgcolor: 'background.paper',
borderRadius: 2,
}}
>
<Typography variant="h4" component="h2" gutterBottom>
{brandingSettings?.site_main_bottom_sting || `Ready to explore more?`}
</Typography> </Typography>
<Button <Button
variant="contained" variant="contained"
color="secondary" color="primary"
size="large" size="large"
component={RouterLink} component={RouterLink}
to="/products" to="/products"
sx={{ mt: 2 }} sx={{ mt: 2 }}
> >
Shop Now View All {brandingSettings?.product_title || `Products`}
</Button> </Button>
</Container> </Box>
</Box> </Box>
</>
{/* Categories Section */}
<Typography variant="h4" component="h2" gutterBottom>
Shop by Category
</Typography>
{!categoriesLoading && categories && (
<Grid container spacing={3} mb={6}>
{categories.map((category) => (
<Grid item xs={12} sm={4} key={category.id}>
<Card
component={RouterLink}
to={`/products?category=${category.name}`}
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
textDecoration: 'none',
transition: '0.3s',
'&:hover': {
transform: 'scale(1.03)',
boxShadow: (theme) => theme.shadows[8],
},
}}
>
<CardMedia
component="img"
height="200"
image={category.image_path ? imageUtils.getImageUrl(category.image_path) : "https://placehold.co/600x400/000000/FFFF"}
alt={category.name}
/>
<CardContent>
<Typography gutterBottom variant="h5" component="div">
{category.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{category.description}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
{/* Featured Products Section */}
<Typography variant="h4" component="h2" gutterBottom>
Featured {brandingSettings?.product_title || `Products`}
</Typography>
{!productsLoading && products && (
<Grid container spacing={3}>
{products.slice(0, 6).map((product) => (
<Grid item xs={12} sm={6} md={4} key={product.id}>
<Card
component={RouterLink}
to={`/products/${product.id}`}
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
textDecoration: 'none',
transition: '0.3s',
'&:hover': {
transform: 'scale(1.03)',
boxShadow: (theme) => theme.shadows[8],
},
}}
>
<CardMedia
component="img"
height="200"
image={(product.images && product.images.length > 0)
? imageUtils.getImageUrl(product.images.find(img => img.isPrimary)?.path || product.images[0].path
) : "https://placehold.co/600x400/000000/FFFF"}
alt={product.name}
/>
<CardContent sx={{ flexGrow: 1 }}>
<Typography gutterBottom variant="h6" component="div">
{product.name}
</Typography>
<Typography variant="body2" color="text.secondary" mb={2}>
{product.description.length > 100
? `${product.description.substring(0, 100)}...`
: product.description}
</Typography>
<Typography variant="h6" color="primary">
${parseFloat(product.price).toFixed(2)}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
{/* Call to Action */}
<Box
sx={{
mt: 8,
py: 6,
textAlign: 'center',
bgcolor: 'background.paper',
borderRadius: 2,
}}
>
<Typography variant="h4" component="h2" gutterBottom>
{brandingSettings?.site_main_bottom_sting || `Ready to explore more?`}
</Typography>
<Button
variant="contained"
color="primary"
size="large"
component={RouterLink}
to="/products"
sx={{ mt: 2 }}
>
View All {brandingSettings?.product_title || `Products`}
</Button>
</Box>
</Box>
); );
}; };

View file

@ -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,230 +167,246 @@ const ProductDetailPage = () => {
} }
return ( return (
<Box> <>
{/* Breadcrumbs navigation */} {/* SEO Meta Tags */}
<Breadcrumbs <SEOMetaTags
separator={<NavigateNextIcon fontSize="small" />} title={title}
aria-label="breadcrumb" description={description}
sx={{ mb: 3 }} keywords={keywords}
> image={image}
<Link component={RouterLink} to="/" color="inherit"> canonical={canonical}
Home type="product"
</Link> structuredData={structuredData}
<Link component={RouterLink} to="/products" color="inherit"> />
Products
</Link> <Box>
<Link {/* Breadcrumbs navigation */}
component={RouterLink} <Breadcrumbs
to={`/products?category=${product.category_name}`} separator={<NavigateNextIcon fontSize="small" />}
color="inherit" aria-label="breadcrumb"
sx={{ mb: 3 }}
> >
{product.category_name} <Link component={RouterLink} to="/" color="inherit">
</Link> Home
<Typography color="text.primary">{product.name}</Typography> </Link>
{product.average_rating && ( <Link component={RouterLink} to="/products" color="inherit">
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1, mb: 2 }}> Products
<Rating </Link>
value={product.average_rating} <Link
readOnly component={RouterLink}
precision={0.5} to={`/products?category=${product.category_name}`}
/> color="inherit"
<Typography variant="body1" color="text.secondary" sx={{ ml: 1 }}> >
{product.average_rating} ({product.review_count} {product.review_count === 1 ? 'review' : 'reviews'}) {product.category_name}
</Typography> </Link>
</Box> <Typography color="text.primary">{product.name}</Typography>
)} </Breadcrumbs>
{!product.average_rating && ( <Grid container spacing={4}>
<Box sx={{ mt: 1, mb: 2 }}> {/* Product Images */}
<Typography variant="body2" color="text.secondary"> <Grid item xs={12} md={6}>
No reviews yet <Box sx={{ mb: 2 }}>
</Typography> <Card>
</Box> <CardMedia
)} component="img"
</Breadcrumbs> image={product.images && product.images.length > 0
?
<Grid container spacing={4}> imageUtils.getImageUrl(product.images[selectedImage]?.path) : "https://placehold.co/600x400/000000/FFFF"
{/* Product Images */} }
<Grid item xs={12} md={6}> alt={product.name}
<Box sx={{ mb: 2 }}>
<Card>
<CardMedia
component="img"
image={product.images && product.images.length > 0
?
imageUtils.getImageUrl(product.images[selectedImage]?.path) : "https://placehold.co/600x400/000000/FFFF"
}
alt={product.name}
sx={{
height: 400,
objectFit: 'contain',
bgcolor: 'background.paper'
}}
/>
</Card>
</Box>
{/* Thumbnail images */}
{product.images && product.images.length > 1 && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{product.images.map((image, index) => (
<Box
key={image.id}
onClick={() => setSelectedImage(index)}
sx={{ sx={{
width: 80, height: 400,
height: 80, objectFit: 'contain',
cursor: 'pointer', bgcolor: 'background.paper'
border: index === selectedImage ? `2px solid ${theme.palette.primary.main}` : '2px solid transparent',
borderRadius: 1,
overflow: 'hidden',
}} }}
> />
<img </Card>
src={image.path} </Box>
alt={`${product.name} thumbnail ${index + 1}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }} {/* Thumbnail images */}
/> {product.images && product.images.length > 1 && (
</Box> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{product.images.map((image, index) => (
<Box
key={image.id}
onClick={() => setSelectedImage(index)}
sx={{
width: 80,
height: 80,
cursor: 'pointer',
border: index === selectedImage ? `2px solid ${theme.palette.primary.main}` : '2px solid transparent',
borderRadius: 1,
overflow: 'hidden',
}}
>
<img
src={imageUtils.getImageUrl(image.path)}
alt={`${product.name} thumbnail ${index + 1}`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
))}
</Box>
)}
</Grid>
{/* Product Details */}
<Grid item xs={12} md={6}>
<Typography variant="h4" component="h1" gutterBottom>
{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>
{/* Tags */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, my: 2 }}>
{product.tags && product.tags.map((tag, index) => (
<Chip
key={index}
label={tag}
component={RouterLink}
to={`/products?tag=${tag}`}
clickable
/>
))} ))}
</Box> </Box>
)}
</Grid>
{/* Product Details */} <Typography variant="body1" paragraph sx={{ mt: 2 }}>
<Grid item xs={12} md={6}> {product.description}
<Typography variant="h4" component="h1" gutterBottom>
{product.name}
</Typography>
<Typography variant="h5" color="primary" gutterBottom>
${parseFloat(product.price).toFixed(2)}
</Typography>
{/* Tags */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, my: 2 }}>
{product.tags && product.tags.map((tag, index) => (
<Chip
key={index}
label={tag}
component={RouterLink}
to={`/products?tag=${tag}`}
clickable
/>
))}
</Box>
<Typography variant="body1" paragraph sx={{ mt: 2 }}>
{product.description}
</Typography>
<Divider sx={{ my: 3 }} />
{/* Stock information */}
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" gutterBottom>
Availability:
<Chip
label={product.stock_quantity > 0 ? 'In Stock' : 'Out of Stock'}
color={product.stock_quantity > 0 ? 'success' : 'error'}
size="small"
sx={{ ml: 1 }}
/>
</Typography> </Typography>
{product.stock_quantity > 0 && ( <Divider sx={{ my: 3 }} />
<Typography variant="body2" color="text.secondary">
{product.stock_quantity} items available
</Typography>
)}
</Box>
{/* Add to cart section */} {/* Stock information */}
{product.stock_quantity > 0 ? ( <Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}> <Typography variant="subtitle1" gutterBottom>
<Box sx={{ display: 'flex', alignItems: 'center', mr: 2 }}> Availability:
<IconButton <Chip
onClick={decreaseQuantity} label={product.stock_quantity > 0 ? 'In Stock' : 'Out of Stock'}
disabled={quantity <= 1} color={product.stock_quantity > 0 ? 'success' : 'error'}
size="small"
>
<RemoveIcon />
</IconButton>
<TextField
value={quantity}
onChange={handleQuantityChange}
inputProps={{ min: 1, max: product.stock_quantity }}
sx={{ width: 60, mx: 1 }}
size="small" size="small"
sx={{ ml: 1 }}
/> />
</Typography>
<IconButton {product.stock_quantity > 0 && (
onClick={increaseQuantity} <Typography variant="body2" color="text.secondary">
disabled={quantity >= product.stock_quantity} {product.stock_quantity} items available
size="small" </Typography>
> )}
<AddIcon />
</IconButton>
</Box>
<Button
variant="contained"
startIcon={<ShoppingCartIcon />}
onClick={handleAddToCart}
disabled={addToCart.isLoading}
sx={{ flexGrow: 1 }}
>
{addToCart.isLoading ? 'Adding...' : 'Add to Cart'}
</Button>
</Box> </Box>
) : (
<Button
variant="outlined"
color="error"
disabled
fullWidth
sx={{ mb: 3 }}
>
Out of Stock
</Button>
)}
{/* Product properties table */} {/* Add to cart section */}
<Typography variant="h6" gutterBottom> {product.stock_quantity > 0 ? (
Product Details <Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
</Typography> <Box sx={{ display: 'flex', alignItems: 'center', mr: 2 }}>
<IconButton
onClick={decreaseQuantity}
disabled={quantity <= 1}
size="small"
>
<RemoveIcon />
</IconButton>
<TableContainer component={Paper} variant="outlined" sx={{ mb: 3 }}> <TextField
<Table aria-label="product specifications"> value={quantity}
<TableBody> onChange={handleQuantityChange}
{getProductProperties().map((prop) => ( inputProps={{ min: 1, max: product.stock_quantity }}
<TableRow key={prop.name}> sx={{ width: 60, mx: 1 }}
<TableCell size="small"
component="th" />
scope="row"
sx={{ <IconButton
width: '40%', onClick={increaseQuantity}
bgcolor: 'background.paper', disabled={quantity >= product.stock_quantity}
fontWeight: 'medium' size="small"
}} >
> <AddIcon />
{prop.name} </IconButton>
</TableCell> </Box>
<TableCell>{prop.value}</TableCell>
</TableRow> <Button
))} variant="contained"
</TableBody> startIcon={<ShoppingCartIcon />}
</Table> onClick={handleAddToCart}
<Grid item xs={12}> disabled={addToCart.isLoading}
<Divider sx={{ my: 4 }} /> sx={{ flexGrow: 1 }}
<ProductReviews productId={id} /> >
</Grid> {addToCart.isLoading ? 'Adding...' : 'Add to Cart'}
</TableContainer> </Button>
</Box>
) : (
<Button
variant="outlined"
color="error"
disabled
fullWidth
sx={{ mb: 3 }}
>
Out of Stock
</Button>
)}
{/* Product properties table */}
<Typography variant="h6" gutterBottom>
Product Details
</Typography>
<TableContainer component={Paper} variant="outlined" sx={{ mb: 3 }}>
<Table aria-label="product specifications">
<TableBody>
{getProductProperties().map((prop) => (
<TableRow key={prop.name}>
<TableCell
component="th"
scope="row"
sx={{
width: '40%',
bgcolor: 'background.paper',
fontWeight: 'medium'
}}
>
{prop.name}
</TableCell>
<TableCell>{prop.value}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Grid>
{/* Reviews Section */}
<Grid item xs={12}>
<Divider sx={{ my: 4 }} />
<ProductReviews productId={id} />
</Grid>
</Grid> </Grid>
</Grid> </Box>
</Box> </>
); );
}; };

View file

@ -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,425 +145,507 @@ 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> <>
<Typography variant="h4" component="h1" gutterBottom> <Box>
{brandingSettings?.product_title || `Products`} {/* Breadcrumbs navigation */}
</Typography> <Breadcrumbs
separator={<NavigateNextIcon fontSize="small" />}
{/* Search and filter bar */} aria-label="breadcrumb"
<Box sx={{ sx={{ mb: 3 }}
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
mb: 3,
gap: 2
}}>
{/* Search */}
<Box component="form" onSubmit={handleSearchSubmit} sx={{ flexGrow: 1, maxWidth: 500 }}>
<TextField
fullWidth
size="small"
placeholder={`Search ${(brandingSettings?.product_title || `Products`).toLowerCase()}...`}
value={filters.search}
onChange={handleSearchChange}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
)
}}
/>
</Box>
{/* Filter button (mobile) */}
<Box sx={{ display: { xs: 'block', md: 'none' } }}>
<Button
startIcon={<FilterListIcon />}
variant="outlined"
onClick={() => setDrawerOpen(true)}
>
Filters
</Button>
</Box>
{/* Sort options (desktop) */}
<Box sx={{ display: { xs: 'none', md: 'flex' }, alignItems: 'center', gap: 2 }}>
<SortIcon color="action" />
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel id="sort-label">Sort by</InputLabel>
<Select
labelId="sort-label"
id="sort"
name="sort"
value={filters.sort}
label="Sort by"
onChange={handleSortChange}
>
<MenuItem value="name">Name</MenuItem>
<MenuItem value="price">Price</MenuItem>
<MenuItem value="created_at">Newest</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 100 }}>
<InputLabel id="order-label">Order</InputLabel>
<Select
labelId="order-label"
id="order"
name="order"
value={filters.order}
label="Order"
onChange={handleSortChange}
>
<MenuItem value="asc">Ascending</MenuItem>
<MenuItem value="desc">Descending</MenuItem>
</Select>
</FormControl>
</Box>
</Box>
{/* Active filters display */}
{(filters.category || filters.tag || filters.search) && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 3 }}>
{filters.category && (
<Chip
label={`Category: ${filters.category}`}
onDelete={() => setFilters({ ...filters, category: '' })}
/>
)}
{filters.tag && (
<Chip
label={`Tag: ${filters.tag}`}
onDelete={() => setFilters({ ...filters, tag: '' })}
/>
)}
{filters.search && (
<Chip
label={`Search: ${filters.search}`}
onDelete={() => setFilters({ ...filters, search: '' })}
/>
)}
<Button
size="small"
onClick={clearFilters}
sx={{ ml: 1 }}
>
Clear All
</Button>
</Box>
)}
{/* Main content area */}
<Box sx={{ display: 'flex' }}>
{/* Filter sidebar (desktop) */}
<Box
sx={{
width: 240,
flexShrink: 0,
mr: 3,
display: { xs: 'none', md: 'block' }
}}
> >
<Typography variant="h6" gutterBottom> <Link component={RouterLink} to="/" color="inherit">
Categories Home
</Typography> </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>
<List disablePadding> <SEOMetaTags
<ListItem title={getPageTitle()}
button description={getPageDescription()}
selected={filters.category === ''} canonical={`/products${location.search}`}
onClick={() => setFilters({ ...filters, category: '' })} type="website"
structuredData={seoMeta.structuredData}
/>
<Typography variant="h4" component="h1" gutterBottom>
{getPageTitle()}
</Typography>
{/* Search and filter bar */}
<Box sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
mb: 3,
gap: 2
}}>
{/* Search */}
<Box component="form" onSubmit={handleSearchSubmit} sx={{ flexGrow: 1, maxWidth: 500 }}>
<TextField
fullWidth
size="small"
placeholder={`Search ${(brandingSettings?.product_title || `Products`).toLowerCase()}...`}
value={filters.search}
onChange={handleSearchChange}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
)
}}
/>
</Box>
{/* Filter button (mobile) */}
<Box sx={{ display: { xs: 'block', md: 'none' } }}>
<Button
startIcon={<FilterListIcon />}
variant="outlined"
onClick={() => setDrawerOpen(true)}
> >
<ListItemText primary="All Categories" /> Filters
</ListItem> </Button>
</Box>
{categories?.map((category) => ( {/* Sort options (desktop) */}
<Box sx={{ display: { xs: 'none', md: 'flex' }, alignItems: 'center', gap: 2 }}>
<SortIcon color="action" />
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel id="sort-label">Sort by</InputLabel>
<Select
labelId="sort-label"
id="sort"
name="sort"
value={filters.sort}
label="Sort by"
onChange={handleSortChange}
>
<MenuItem value="name">Name</MenuItem>
<MenuItem value="price">Price</MenuItem>
<MenuItem value="created_at">Newest</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 100 }}>
<InputLabel id="order-label">Order</InputLabel>
<Select
labelId="order-label"
id="order"
name="order"
value={filters.order}
label="Order"
onChange={handleSortChange}
>
<MenuItem value="asc">Ascending</MenuItem>
<MenuItem value="desc">Descending</MenuItem>
</Select>
</FormControl>
</Box>
</Box>
{/* Active filters display */}
{(filters.category || filters.tag || filters.search) && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 3 }}>
{filters.category && (
<Chip
label={`Category: ${filters.category}`}
onDelete={() => setFilters({ ...filters, category: '' })}
/>
)}
{filters.tag && (
<Chip
label={`Tag: ${filters.tag}`}
onDelete={() => setFilters({ ...filters, tag: '' })}
/>
)}
{filters.search && (
<Chip
label={`Search: ${filters.search}`}
onDelete={() => setFilters({ ...filters, search: '' })}
/>
)}
<Button
size="small"
onClick={clearFilters}
sx={{ ml: 1 }}
>
Clear All
</Button>
</Box>
)}
{/* Main content area */}
<Box sx={{ display: 'flex' }}>
{/* Filter sidebar (desktop) */}
<Box
sx={{
width: 240,
flexShrink: 0,
mr: 3,
display: { xs: 'none', md: 'block' }
}}
>
<Typography variant="h6" gutterBottom>
Categories
</Typography>
<List disablePadding>
<ListItem <ListItem
button button
key={category.id} selected={filters.category === ''}
selected={filters.category === category.name} onClick={() => setFilters({ ...filters, category: '' })}
onClick={() => setFilters({ ...filters, category: category.name })}
> >
<ListItemText primary={category.name} /> <ListItemText primary="All Categories" />
</ListItem> </ListItem>
))}
</List>
<Divider sx={{ my: 2 }} />
<Typography variant="h6" gutterBottom>
Popular Tags
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{tags?.map((tag) => (
<Chip
key={tag.id}
label={tag.name}
onClick={() => setFilters({ ...filters, tag: tag.name })}
color={filters.tag === tag.name ? 'primary' : 'default'}
clickable
size="small"
sx={{ mb: 1 }}
/>
))}
</Box>
</Box>
{/* Product grid */}
<Box sx={{ flexGrow: 1 }}>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 4 }}>
<CircularProgress />
</Box>
) : error ? (
<Typography color="error" sx={{ my: 4 }}>
Error loading products. Please try again.
</Typography>
) : paginatedProducts?.length === 0 ? (
<Typography sx={{ my: 4 }}>
No products found with the selected filters.
</Typography>
) : (
<>
<Grid container spacing={3}>
{paginatedProducts.map((product) => (
<Grid item xs={12} sm={6} md={4} key={product.id}>
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
transition: '0.3s',
'&:hover': {
transform: 'translateY(-5px)',
boxShadow: (theme) => theme.shadows[8],
},
}}
>
<CardMedia
component="img"
height="200"
image={(product.images && product.images.length > 0)
? imageUtils.getImageUrl( product.images.find(img => img.isPrimary)?.path || product.images[0].path
) : "https://placehold.co/600x400/000000/FFFF"}
alt={product.name}
sx={{ objectFit: 'cover' }}
onClick={() => navigate(`/products/${product.id}`)}
style={{ cursor: 'pointer' }}
/>
<CardContent sx={{ flexGrow: 1 }}>
<Typography
variant="h6"
component={RouterLink}
to={`/products/${product.id}`}
sx={{
textDecoration: 'none',
color: 'inherit',
'&:hover': { color: 'primary.main' }
}}
>
{product.name}
</Typography>
<ProductRatingDisplay
rating={product.average_rating}
reviewCount={product.review_count}
/>
<Typography
variant="body2"
color="text.secondary"
sx={{
mt: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
}}
>
{product.description}
</Typography>
<Box sx={{ display: 'flex', mt: 2, mb: 1 }}>
{product.tags && product.tags.slice(0, 3).map((tag, index) => (
<Chip
key={index}
label={tag}
size="small"
sx={{ mr: 0.5, mb: 0.5 }}
onClick={(e) => {
e.preventDefault();
setFilters({ ...filters, tag });
}}
/>
))}
</Box>
<Typography
variant="h6"
color="primary"
sx={{ mt: 1 }}
>
${parseFloat(product.price).toFixed(2)}
</Typography>
</CardContent>
<CardActions sx={{ p: 2, pt: 0 }}>
<Button
variant="contained"
size="small"
startIcon={<ShoppingCartIcon />}
onClick={() => handleAddToCart(product.id)}
disabled={addToCart.isLoading}
>
Add to Cart
</Button>
<Button
variant="outlined"
size="small"
component={RouterLink}
to={`/products/${product.id}`}
sx={{ ml: 'auto' }}
>
Details
</Button>
</CardActions>
</Card>
</Grid>
))}
</Grid>
{/* Pagination */}
{products && products.length > itemsPerPage && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
<Pagination
count={Math.ceil(products.length / itemsPerPage)}
page={page}
onChange={(e, value) => setPage(value)}
color="primary"
/>
</Box>
)}
</>
)}
</Box>
</Box>
{/* Filter drawer (mobile) */}
<Drawer
anchor="right"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
>
<Box sx={{ width: 280, p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Filters</Typography>
<IconButton onClick={() => setDrawerOpen(false)}>
<CloseIcon />
</IconButton>
</Box>
<Divider sx={{ mb: 2 }} />
<Typography variant="subtitle1" gutterBottom>
Sort by
</Typography>
<FormControl fullWidth size="small" sx={{ mb: 2 }}>
<InputLabel id="mobile-sort-label">Sort</InputLabel>
<Select
labelId="mobile-sort-label"
id="mobile-sort"
name="sort"
value={filters.sort}
label="Sort"
onChange={handleSortChange}
>
<MenuItem value="name">Name</MenuItem>
<MenuItem value="price">Price</MenuItem>
<MenuItem value="created_at">Newest</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth size="small" sx={{ mb: 3 }}>
<InputLabel id="mobile-order-label">Order</InputLabel>
<Select
labelId="mobile-order-label"
id="mobile-order"
name="order"
value={filters.order}
label="Order"
onChange={handleSortChange}
>
<MenuItem value="asc">Ascending</MenuItem>
<MenuItem value="desc">Descending</MenuItem>
</Select>
</FormControl>
<Typography variant="subtitle1" gutterBottom>
Categories
</Typography>
<FormControl fullWidth size="small" sx={{ mb: 3 }}>
<InputLabel id="category-label">Category</InputLabel>
<Select
labelId="category-label"
id="category"
name="category"
value={filters.category}
label="Category"
onChange={handleFilterChange}
>
<MenuItem value="">All Categories</MenuItem>
{categories?.map((category) => ( {categories?.map((category) => (
<MenuItem key={category.id} value={category.name}> <ListItem
{category.name} button
</MenuItem> key={category.id}
selected={filters.category === category.name}
onClick={() => setFilters({ ...filters, category: category.name })}
>
<ListItemText primary={category.name} />
</ListItem>
))} ))}
</Select> </List>
</FormControl>
<Typography variant="subtitle1" gutterBottom> <Divider sx={{ my: 2 }} />
Popular Tags
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 3 }}> <Typography variant="h6" gutterBottom>
{tags?.slice(0, 10).map((tag) => ( Popular Tags
<Chip </Typography>
key={tag.id}
label={tag.name} <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
onClick={() => { {tags?.map((tag) => (
setFilters({ ...filters, tag: tag.name }); <Chip
setDrawerOpen(false); key={tag.id}
}} label={tag.name}
color={filters.tag === tag.name ? 'primary' : 'default'} onClick={() => setFilters({ ...filters, tag: tag.name })}
clickable color={filters.tag === tag.name ? 'primary' : 'default'}
size="small" clickable
/> size="small"
))} sx={{ mb: 1 }}
/>
))}
</Box>
</Box> </Box>
<Button {/* Product grid */}
variant="outlined" <Box sx={{ flexGrow: 1 }}>
fullWidth {isLoading ? (
onClick={() => { <Box sx={{ display: 'flex', justifyContent: 'center', my: 4 }}>
clearFilters(); <CircularProgress />
setDrawerOpen(false); </Box>
}} ) : error ? (
> <Typography color="error" sx={{ my: 4 }}>
Clear All Filters Error loading products. Please try again.
</Button> </Typography>
) : paginatedProducts?.length === 0 ? (
<Typography sx={{ my: 4 }}>
No products found with the selected filters.
</Typography>
) : (
<>
<Grid container spacing={3}>
{paginatedProducts.map((product) => (
<Grid item xs={12} sm={6} md={4} key={product.id}>
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
transition: '0.3s',
'&:hover': {
transform: 'translateY(-5px)',
boxShadow: (theme) => theme.shadows[8],
},
}}
>
<CardMedia
component="img"
height="200"
image={(product.images && product.images.length > 0)
? imageUtils.getImageUrl( product.images.find(img => img.isPrimary)?.path || product.images[0].path
) : "https://placehold.co/600x400/000000/FFFF"}
alt={product.name}
sx={{ objectFit: 'cover' }}
onClick={() => navigate(`/products/${product.id}`)}
// onClick={() => navigate(seoUrl.getProductUrl(product))}
style={{ cursor: 'pointer' }}
/>
<CardContent sx={{ flexGrow: 1 }}>
<Typography
variant="h6"
component={RouterLink}
to={`/products/${product.id}`}
// to={seoUrl.getProductUrl(product)}
sx={{
textDecoration: 'none',
color: 'inherit',
'&:hover': { color: 'primary.main' }
}}
>
{product.name}
</Typography>
<ProductRatingDisplay
rating={product.average_rating}
reviewCount={product.review_count}
/>
<Typography
variant="body2"
color="text.secondary"
sx={{
mt: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
}}
>
{product.description}
</Typography>
<Box sx={{ display: 'flex', mt: 2, mb: 1 }}>
{product.tags && product.tags.slice(0, 3).map((tag, index) => (
<Chip
key={index}
label={tag}
size="small"
sx={{ mr: 0.5, mb: 0.5 }}
onClick={(e) => {
e.preventDefault();
setFilters({ ...filters, tag });
}}
/>
))}
</Box>
<Typography
variant="h6"
color="primary"
sx={{ mt: 1 }}
>
${parseFloat(product.price).toFixed(2)}
</Typography>
</CardContent>
<CardActions sx={{ p: 2, pt: 0 }}>
<Button
variant="contained"
size="small"
startIcon={<ShoppingCartIcon />}
onClick={() => handleAddToCart(product.id)}
disabled={addToCart.isLoading}
>
Add to Cart
</Button>
<Button
variant="outlined"
size="small"
component={RouterLink}
to={`/products/${product.id}`}
sx={{ ml: 'auto' }}
>
Details
</Button>
</CardActions>
</Card>
</Grid>
))}
</Grid>
{/* Pagination */}
{products && products.length > itemsPerPage && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
<Pagination
count={Math.ceil(products.length / itemsPerPage)}
page={page}
onChange={(e, value) => setPage(value)}
color="primary"
/>
</Box>
)}
</>
)}
</Box>
</Box> </Box>
</Drawer>
</Box> {/* Filter drawer (mobile) */}
<Drawer
anchor="right"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
>
<Box sx={{ width: 280, p: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Filters</Typography>
<IconButton onClick={() => setDrawerOpen(false)}>
<CloseIcon />
</IconButton>
</Box>
<Divider sx={{ mb: 2 }} />
<Typography variant="subtitle1" gutterBottom>
Sort by
</Typography>
<FormControl fullWidth size="small" sx={{ mb: 2 }}>
<InputLabel id="mobile-sort-label">Sort</InputLabel>
<Select
labelId="mobile-sort-label"
id="mobile-sort"
name="sort"
value={filters.sort}
label="Sort"
onChange={handleSortChange}
>
<MenuItem value="name">Name</MenuItem>
<MenuItem value="price">Price</MenuItem>
<MenuItem value="created_at">Newest</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth size="small" sx={{ mb: 3 }}>
<InputLabel id="mobile-order-label">Order</InputLabel>
<Select
labelId="mobile-order-label"
id="mobile-order"
name="order"
value={filters.order}
label="Order"
onChange={handleSortChange}
>
<MenuItem value="asc">Ascending</MenuItem>
<MenuItem value="desc">Descending</MenuItem>
</Select>
</FormControl>
<Typography variant="subtitle1" gutterBottom>
Categories
</Typography>
<FormControl fullWidth size="small" sx={{ mb: 3 }}>
<InputLabel id="category-label">Category</InputLabel>
<Select
labelId="category-label"
id="category"
name="category"
value={filters.category}
label="Category"
onChange={handleFilterChange}
>
<MenuItem value="">All Categories</MenuItem>
{categories?.map((category) => (
<MenuItem key={category.id} value={category.name}>
{category.name}
</MenuItem>
))}
</Select>
</FormControl>
<Typography variant="subtitle1" gutterBottom>
Popular Tags
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 3 }}>
{tags?.slice(0, 10).map((tag) => (
<Chip
key={tag.id}
label={tag.name}
onClick={() => {
setFilters({ ...filters, tag: tag.name });
setDrawerOpen(false);
}}
color={filters.tag === tag.name ? 'primary' : 'default'}
clickable
size="small"
/>
))}
</Box>
<Button
variant="outlined"
fullWidth
onClick={() => {
clearFilters();
setDrawerOpen(false);
}}
>
Clear All Filters
</Button>
</Box>
</Drawer>
</Box>
</>
); );
}; };

View file

@ -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>

View file

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