seo meta data on relavent pages
This commit is contained in:
parent
581d3ccfe7
commit
36c2dd98a2
13 changed files with 1603 additions and 4924 deletions
3982
frontend/package-lock.json
generated
3982
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -27,6 +27,7 @@
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-email-editor": "^1.7.11",
|
"react-email-editor": "^1.7.11",
|
||||||
|
"react-helmet": "^6.1.0",
|
||||||
"react-redux": "^9.0.2",
|
"react-redux": "^9.0.2",
|
||||||
"react-router-dom": "^6.20.1",
|
"react-router-dom": "^6.20.1",
|
||||||
"recharts": "^2.10.3",
|
"recharts": "^2.10.3",
|
||||||
|
|
|
||||||
103
frontend/src/components/SEOMetaTags.jsx
Normal file
103
frontend/src/components/SEOMetaTags.jsx
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Helmet } from 'react-helmet';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import useBrandingSettings from '@hooks/brandingHooks';
|
||||||
|
import imageUtils from '@utils/imageUtils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SEOMetaTags - Component for managing SEO metadata
|
||||||
|
*
|
||||||
|
* This component handles all SEO-related meta tags including Open Graph,
|
||||||
|
* Twitter Cards, and structured data for better search engine visibility
|
||||||
|
*/
|
||||||
|
const SEOMetaTags = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
keywords = [],
|
||||||
|
image,
|
||||||
|
canonical,
|
||||||
|
type = 'website',
|
||||||
|
structuredData = null,
|
||||||
|
noindex = false
|
||||||
|
}) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const { data: brandingSettings } = useBrandingSettings();
|
||||||
|
|
||||||
|
// Default site settings from branding
|
||||||
|
const siteName = brandingSettings?.site_name || 'Rocks, Bones & Sticks';
|
||||||
|
const defaultDescription = brandingSettings?.site_description ||
|
||||||
|
'Your premier source for natural curiosities and unique specimens';
|
||||||
|
|
||||||
|
// Format the title with the site name
|
||||||
|
const formattedTitle = title ? `${title} | ${siteName}` : siteName;
|
||||||
|
|
||||||
|
// Use provided description or fall back to default
|
||||||
|
const metaDescription = description || defaultDescription;
|
||||||
|
|
||||||
|
// Base URL construction - important for absolute URLs
|
||||||
|
const baseUrl = typeof window !== 'undefined' ?
|
||||||
|
`${window.location.protocol}//${window.location.host}` :
|
||||||
|
'';
|
||||||
|
|
||||||
|
// Construct the canonical URL correctly
|
||||||
|
const canonicalUrl = canonical ?
|
||||||
|
(canonical.startsWith('http') ? canonical : `${baseUrl}${canonical}`) :
|
||||||
|
`${baseUrl}${location.pathname}`;
|
||||||
|
|
||||||
|
// Default image from branding settings
|
||||||
|
const defaultImage = brandingSettings?.logo_url || '/logo.png';
|
||||||
|
const metaImage = image || defaultImage;
|
||||||
|
const imageUrl = imageUtils.getImageUrl(metaImage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Helmet>
|
||||||
|
{/* Basic meta tags */}
|
||||||
|
<title>{formattedTitle}</title>
|
||||||
|
<meta name="description" content={metaDescription} />
|
||||||
|
|
||||||
|
{/* Allow noindex pages */}
|
||||||
|
{noindex && <meta name="robots" content="noindex, nofollow" />}
|
||||||
|
|
||||||
|
{/* Keywords if provided */}
|
||||||
|
{keywords.length > 0 && <meta name="keywords" content={keywords.join(', ')} />}
|
||||||
|
|
||||||
|
{/* Canonical link */}
|
||||||
|
<link rel="canonical" href={canonicalUrl} />
|
||||||
|
|
||||||
|
{/* Open Graph tags for social sharing */}
|
||||||
|
<meta property="og:title" content={formattedTitle} />
|
||||||
|
<meta property="og:description" content={metaDescription} />
|
||||||
|
<meta property="og:image" content={imageUrl} />
|
||||||
|
<meta property="og:url" content={canonicalUrl} />
|
||||||
|
<meta property="og:type" content={type} />
|
||||||
|
<meta property="og:site_name" content={siteName} />
|
||||||
|
|
||||||
|
{/* Twitter Card tags */}
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content={formattedTitle} />
|
||||||
|
<meta name="twitter:description" content={metaDescription} />
|
||||||
|
<meta name="twitter:image" content={imageUrl} />
|
||||||
|
|
||||||
|
{/* Structured data for search engines */}
|
||||||
|
{structuredData && (
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{JSON.stringify(structuredData)}
|
||||||
|
</script>
|
||||||
|
)}
|
||||||
|
</Helmet>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
SEOMetaTags.propTypes = {
|
||||||
|
title: PropTypes.string,
|
||||||
|
description: PropTypes.string,
|
||||||
|
keywords: PropTypes.arrayOf(PropTypes.string),
|
||||||
|
image: PropTypes.string,
|
||||||
|
canonical: PropTypes.string,
|
||||||
|
type: PropTypes.string,
|
||||||
|
structuredData: PropTypes.object,
|
||||||
|
noindex: PropTypes.bool
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SEOMetaTags;
|
||||||
102
frontend/src/hooks/useProductSeo.js
Normal file
102
frontend/src/hooks/useProductSeo.js
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import useSeoMeta from './useSeoMeta';
|
||||||
|
import imageUtils from '@utils/imageUtils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for product SEO optimization
|
||||||
|
*
|
||||||
|
* @param {Object} product - Product data
|
||||||
|
* @returns {Object} SEO metadata for the product
|
||||||
|
*/
|
||||||
|
const useProductSeo = (product) => {
|
||||||
|
const seoMeta = useSeoMeta({
|
||||||
|
title: product?.name,
|
||||||
|
description: product?.description,
|
||||||
|
image: product?.images && product?.images.length > 0
|
||||||
|
? product.images.find(img => img.isPrimary)?.path || product.images[0].path
|
||||||
|
: null,
|
||||||
|
type: 'product',
|
||||||
|
productData: product
|
||||||
|
});
|
||||||
|
|
||||||
|
const keywords = useMemo(() => {
|
||||||
|
if (!product) return [];
|
||||||
|
|
||||||
|
const keywordsList = [];
|
||||||
|
|
||||||
|
if (product.name) keywordsList.push(product.name);
|
||||||
|
if (product.category_name) keywordsList.push(product.category_name);
|
||||||
|
|
||||||
|
if (product.tags && Array.isArray(product.tags)) {
|
||||||
|
keywordsList.push(...product.tags);
|
||||||
|
}
|
||||||
|
if (product.material_type) keywordsList.push(product.material_type);
|
||||||
|
if (product.origin) keywordsList.push(product.origin);
|
||||||
|
if (product.color) keywordsList.push(product.color);
|
||||||
|
|
||||||
|
return [...new Set(keywordsList)];
|
||||||
|
}, [product]);
|
||||||
|
|
||||||
|
// Generate product JSON
|
||||||
|
const richSnippet = useMemo(() => {
|
||||||
|
if (!product) return null;
|
||||||
|
|
||||||
|
let imageUrl = null;
|
||||||
|
if (product.images && product.images.length > 0) {
|
||||||
|
const primaryImage = product.images.find(img => img.isPrimary) || product.images[0];
|
||||||
|
imageUrl = imageUtils.getImageUrl(primaryImage.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dimensions = [];
|
||||||
|
if (product.length_cm) dimensions.push(`Length: ${product.length_cm}cm`);
|
||||||
|
if (product.width_cm) dimensions.push(`Width: ${product.width_cm}cm`);
|
||||||
|
if (product.height_cm) dimensions.push(`Height: ${product.height_cm}cm`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Product",
|
||||||
|
"name": product.name,
|
||||||
|
"image": imageUrl,
|
||||||
|
"description": product.description,
|
||||||
|
"sku": product.id,
|
||||||
|
"mpn": product.id,
|
||||||
|
"brand": {
|
||||||
|
"@type": "Brand",
|
||||||
|
"name": "Rocks, Bones & Sticks"
|
||||||
|
},
|
||||||
|
"offers": {
|
||||||
|
"@type": "Offer",
|
||||||
|
"url": window.location.href,
|
||||||
|
"priceCurrency": "CAD",
|
||||||
|
"price": parseFloat(product.price).toFixed(2),
|
||||||
|
"priceValidUntil": new Date(new Date().setFullYear(new Date().getFullYear() + 1)).toISOString().split('T')[0],
|
||||||
|
"itemCondition": "https://schema.org/NewCondition",
|
||||||
|
"availability": product.stock_quantity > 0
|
||||||
|
? "https://schema.org/InStock"
|
||||||
|
: "https://schema.org/OutOfStock"
|
||||||
|
},
|
||||||
|
...(product.average_rating && {
|
||||||
|
"aggregateRating": {
|
||||||
|
"@type": "AggregateRating",
|
||||||
|
"ratingValue": product.average_rating,
|
||||||
|
"reviewCount": product.review_count
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
...(dimensions.length > 0 && {
|
||||||
|
"additionalProperty": dimensions.map(dim => ({
|
||||||
|
"@type": "PropertyValue",
|
||||||
|
"name": dim.split(':')[0].trim(),
|
||||||
|
"value": dim.split(':')[1].trim()
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}, [product]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...seoMeta,
|
||||||
|
keywords,
|
||||||
|
richSnippet
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useProductSeo;
|
||||||
|
|
@ -1,198 +1,246 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import apiClient from '@services/api';
|
import useBrandingSettings from '@hooks/brandingHooks';
|
||||||
|
import imageUtils from '@utils/imageUtils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for managing SEO metadata
|
* Custom hook for generating SEO metadata and structured data
|
||||||
|
*
|
||||||
* @param {Object} options - Configuration options
|
* @param {Object} options - Configuration options
|
||||||
* @param {string} options.title - Page title
|
* @returns {Object} SEO metadata and structured data
|
||||||
* @param {string} options.description - Page description
|
|
||||||
* @param {string} options.image - Social media image URL
|
|
||||||
* @param {string} options.type - Open Graph type (article, website, etc.)
|
|
||||||
* @returns {Object} SEO utilities
|
|
||||||
*/
|
*/
|
||||||
const useSeoMeta = (options = {}) => {
|
const useSeoMeta = (options = {}) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const { data: brandingSettings } = useBrandingSettings();
|
||||||
|
|
||||||
// Set default page metadata
|
return useMemo(() => {
|
||||||
useEffect(() => {
|
|
||||||
if (!options || isLoaded) return;
|
|
||||||
|
|
||||||
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;
|
||||||
141
frontend/src/hooks/useSeoUrl.js
Normal file
141
frontend/src/hooks/useSeoUrl.js
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for generating SEO-friendly URLs
|
||||||
|
*/
|
||||||
|
const useSeoUrl = () => {
|
||||||
|
return useMemo(() => ({
|
||||||
|
/**
|
||||||
|
* Generate a slug from a string
|
||||||
|
* @param {string} text - Text to convert to slug
|
||||||
|
* @returns {string} URL-friendly slug
|
||||||
|
*/
|
||||||
|
generateSlug: (text) => {
|
||||||
|
if (!text) return '';
|
||||||
|
|
||||||
|
return text
|
||||||
|
.toString()
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^\w\s-]/g, '') // Remove non-word chars
|
||||||
|
.replace(/[\s_-]+/g, '-') // Replace spaces and underscores with hyphens
|
||||||
|
.replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate SEO-friendly URL for a product
|
||||||
|
* @param {Object} product - Product object
|
||||||
|
* @returns {string} SEO-friendly URL
|
||||||
|
*/
|
||||||
|
getProductUrl: (product) => {
|
||||||
|
if (!product) return '/products';
|
||||||
|
|
||||||
|
const slug = product.name ? useSeoUrl().generateSlug(product.name) : '';
|
||||||
|
return `/products/${product.id}${slug ? `-${slug}` : ''}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate SEO-friendly URL for a category
|
||||||
|
* @param {Object|string} category - Category object or name
|
||||||
|
* @returns {string} SEO-friendly URL
|
||||||
|
*/
|
||||||
|
getCategoryUrl: (category) => {
|
||||||
|
if (!category) return '/products';
|
||||||
|
|
||||||
|
const categoryName = typeof category === 'string' ?
|
||||||
|
category :
|
||||||
|
(category.name || '');
|
||||||
|
|
||||||
|
const slug = useSeoUrl().generateSlug(categoryName);
|
||||||
|
return `/products?category=${encodeURIComponent(categoryName)}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate SEO-friendly URL for a blog post
|
||||||
|
* @param {Object} post - Blog post object
|
||||||
|
* @returns {string} SEO-friendly URL
|
||||||
|
*/
|
||||||
|
getBlogPostUrl: (post) => {
|
||||||
|
if (!post) return '/blog';
|
||||||
|
|
||||||
|
const slug = post.slug ||
|
||||||
|
(post.title ? useSeoUrl().generateSlug(post.title) : '');
|
||||||
|
|
||||||
|
return `/blog/${slug}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate SEO-friendly URL for a tag
|
||||||
|
* @param {string} tag - Tag name
|
||||||
|
* @param {string} [type='products'] - URL type ('products' or 'blog')
|
||||||
|
* @returns {string} SEO-friendly URL
|
||||||
|
*/
|
||||||
|
getTagUrl: (tag, type = 'products') => {
|
||||||
|
if (!tag) return `/${type}`;
|
||||||
|
|
||||||
|
return `/${type}?tag=${encodeURIComponent(tag)}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a URL to extract SEO components
|
||||||
|
* @param {string} url - URL to parse
|
||||||
|
* @returns {Object} Extracted components
|
||||||
|
*/
|
||||||
|
parseUrl: (url) => {
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
const cleanUrl = url.startsWith('/') ? url.substring(1) : url;
|
||||||
|
|
||||||
|
// Product detail page pattern: products/[id]-[slug]
|
||||||
|
const productPattern = /^products\/([a-f0-9-]+)(?:-(.+))?$/;
|
||||||
|
const productMatch = cleanUrl.match(productPattern);
|
||||||
|
|
||||||
|
if (productMatch) {
|
||||||
|
return {
|
||||||
|
type: 'product',
|
||||||
|
id: productMatch[1],
|
||||||
|
slug: productMatch[2] || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blog post pattern: blog/[slug]
|
||||||
|
const blogPattern = /^blog\/(.+)$/;
|
||||||
|
const blogMatch = cleanUrl.match(blogPattern);
|
||||||
|
|
||||||
|
if (blogMatch) {
|
||||||
|
return {
|
||||||
|
type: 'blogPost',
|
||||||
|
slug: blogMatch[1]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Product category pattern: products?category=[name]
|
||||||
|
if (cleanUrl.startsWith('products?category=')) {
|
||||||
|
const category = decodeURIComponent(
|
||||||
|
cleanUrl.replace('products?category=', '')
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'category',
|
||||||
|
name: category
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag pattern: [type]?tag=[name]
|
||||||
|
const tagPattern = /^(products|blog)\?tag=(.+)$/;
|
||||||
|
const tagMatch = cleanUrl.match(tagPattern);
|
||||||
|
|
||||||
|
if (tagMatch) {
|
||||||
|
return {
|
||||||
|
type: 'tag',
|
||||||
|
contentType: tagMatch[1],
|
||||||
|
name: decodeURIComponent(tagMatch[2])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}), []);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useSeoUrl;
|
||||||
|
|
@ -19,20 +19,28 @@ import {
|
||||||
FormControl,
|
FormControl,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
|
Breadcrumbs,
|
||||||
|
Link,
|
||||||
Alert,
|
Alert,
|
||||||
Container
|
Container
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { useNavigate, useLocation, Link as RouterLink } from 'react-router-dom';
|
import { useNavigate, useLocation, Link as RouterLink } from 'react-router-dom';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import ClearIcon from '@mui/icons-material/Clear';
|
import ClearIcon from '@mui/icons-material/Clear';
|
||||||
|
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||||
import { useBlogPosts, useBlogCategories } from '@hooks/blogHooks';
|
import { useBlogPosts, useBlogCategories } from '@hooks/blogHooks';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import {Helmet} from 'react-helmet'
|
||||||
|
import useSeoMeta from '@hooks/useSeoMeta';
|
||||||
|
import useBrandingSettings from '@hooks/brandingHooks';
|
||||||
import imageUtils from '@utils/imageUtils';
|
import imageUtils from '@utils/imageUtils';
|
||||||
|
import SEOMetaTags from '@components/SEOMetaTags';
|
||||||
|
|
||||||
const BlogPage = () => {
|
const BlogPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const searchParams = new URLSearchParams(location.search);
|
const searchParams = new URLSearchParams(location.search);
|
||||||
|
const { data: brandingSettings } = useBrandingSettings();
|
||||||
|
|
||||||
// State for filters and search
|
// State for filters and search
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
|
|
@ -46,6 +54,31 @@ const BlogPage = () => {
|
||||||
const { data, isLoading, error } = useBlogPosts(filters);
|
const { data, isLoading, error } = useBlogPosts(filters);
|
||||||
const { data: categories } = useBlogCategories();
|
const { data: categories } = useBlogCategories();
|
||||||
|
|
||||||
|
|
||||||
|
const { structuredData } = useSeoMeta();
|
||||||
|
|
||||||
|
// Generate blog-specific structured data
|
||||||
|
const blogStructuredData = data?.posts && data.posts.length > 0 ? {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "Blog",
|
||||||
|
"name": brandingSettings?.blog_title || "Our Blog",
|
||||||
|
"description": brandingSettings?.blog_desc || "Discover insights about our natural collections, sourcing adventures, and unique specimens",
|
||||||
|
"url": `${window.location.origin}/blog`,
|
||||||
|
"blogPost": data.posts.map(post => ({
|
||||||
|
"@type": "BlogPosting",
|
||||||
|
"headline": post.title,
|
||||||
|
"description": post.excerpt || (post.content && post.content.substring(0, 150) + '...'),
|
||||||
|
"url": `${window.location.origin}/blog/${post.slug}`,
|
||||||
|
"datePublished": post.published_at,
|
||||||
|
"image": post.featured_image_path ? imageUtils.getImageUrl(post.featured_image_path) : null,
|
||||||
|
"author": {
|
||||||
|
"@type": "Person",
|
||||||
|
"name": `${post.author_first_name || ''} ${post.author_last_name || ''}`.trim()
|
||||||
|
},
|
||||||
|
"keywords": post.tags ? post.tags.join(', ') : undefined
|
||||||
|
}))
|
||||||
|
} : null;
|
||||||
|
|
||||||
// Update URL when filters change
|
// Update URL when filters change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
@ -95,7 +128,23 @@ const BlogPage = () => {
|
||||||
if (!dateString) return '';
|
if (!dateString) return '';
|
||||||
return format(new Date(dateString), 'MMMM d, yyyy');
|
return format(new Date(dateString), 'MMMM d, yyyy');
|
||||||
};
|
};
|
||||||
|
// SEO title based on filters
|
||||||
|
let seoTitle = brandingSettings?.blog_title || "Our Blog";
|
||||||
|
if (filters.category) {
|
||||||
|
seoTitle = `${filters.category} Posts | ${seoTitle}`;
|
||||||
|
} else if (filters.tag) {
|
||||||
|
seoTitle = `Posts Tagged "${filters.tag}" | ${seoTitle}`;
|
||||||
|
} else if (filters.search) {
|
||||||
|
seoTitle = `Search Results for "${filters.search}" | ${seoTitle}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SEO description
|
||||||
|
let seoDescription = brandingSettings?.blog_desc ||
|
||||||
|
"Discover insights about our natural collections, sourcing adventures, and unique specimens";
|
||||||
|
|
||||||
|
if (filters.category || filters.tag || filters.search) {
|
||||||
|
seoDescription = `${seoTitle}. ${seoDescription}`;
|
||||||
|
}
|
||||||
// Loading state
|
// Loading state
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -120,6 +169,36 @@ const BlogPage = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="lg">
|
<Container maxWidth="lg">
|
||||||
|
|
||||||
|
{/* SEO Meta Tags */}
|
||||||
|
<SEOMetaTags
|
||||||
|
title={seoTitle}
|
||||||
|
description={seoDescription}
|
||||||
|
type="website"
|
||||||
|
structuredData={structuredData}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Blog-specific structured data */}
|
||||||
|
{blogStructuredData && (
|
||||||
|
<Helmet>
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{JSON.stringify(blogStructuredData)}
|
||||||
|
</script>
|
||||||
|
</Helmet>
|
||||||
|
)}
|
||||||
|
{/* Breadcrumbs */}
|
||||||
|
<Breadcrumbs
|
||||||
|
separator={<NavigateNextIcon fontSize="small" />}
|
||||||
|
aria-label="breadcrumb"
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
>
|
||||||
|
<Link component={RouterLink} to="/" color="inherit">
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
<Link component={RouterLink} to="/blog" color="inherit">
|
||||||
|
Blog
|
||||||
|
</Link>
|
||||||
|
</Breadcrumbs>
|
||||||
<Box sx={{ py: 4 }}>
|
<Box sx={{ py: 4 }}>
|
||||||
<Typography variant="h4" component="h1" gutterBottom>
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
Our Blog
|
Our Blog
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,9 @@ const CartPage = () => {
|
||||||
<Link component={RouterLink} to="/" color="inherit">
|
<Link component={RouterLink} to="/" color="inherit">
|
||||||
Home
|
Home
|
||||||
</Link>
|
</Link>
|
||||||
<Typography color="text.primary">Your Cart</Typography>
|
<Link component={RouterLink} to="/cart" color="inherit">
|
||||||
|
Cart
|
||||||
|
</Link>
|
||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
|
|
||||||
<Typography variant="h4" component="h1" gutterBottom>
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
|
|
|
||||||
|
|
@ -4,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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
<Link
|
|
||||||
component={RouterLink}
|
|
||||||
to={`/products?category=${product.category_name}`}
|
|
||||||
color="inherit"
|
|
||||||
>
|
|
||||||
{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}>
|
<Box>
|
||||||
{/* Product Images */}
|
{/* Breadcrumbs navigation */}
|
||||||
<Grid item xs={12} md={6}>
|
<Breadcrumbs
|
||||||
<Box sx={{ mb: 2 }}>
|
separator={<NavigateNextIcon fontSize="small" />}
|
||||||
<Card>
|
aria-label="breadcrumb"
|
||||||
<CardMedia
|
sx={{ mb: 3 }}
|
||||||
component="img"
|
>
|
||||||
image={product.images && product.images.length > 0
|
<Link component={RouterLink} to="/" color="inherit">
|
||||||
?
|
Home
|
||||||
imageUtils.getImageUrl(product.images[selectedImage]?.path) : "https://placehold.co/600x400/000000/FFFF"
|
</Link>
|
||||||
}
|
<Link component={RouterLink} to="/products" color="inherit">
|
||||||
alt={product.name}
|
Products
|
||||||
sx={{
|
</Link>
|
||||||
height: 400,
|
<Link
|
||||||
objectFit: 'contain',
|
component={RouterLink}
|
||||||
bgcolor: 'background.paper'
|
to={`/products?category=${product.category_name}`}
|
||||||
}}
|
color="inherit"
|
||||||
/>
|
>
|
||||||
</Card>
|
{product.category_name}
|
||||||
</Box>
|
</Link>
|
||||||
|
<Typography color="text.primary">{product.name}</Typography>
|
||||||
{/* Thumbnail images */}
|
</Breadcrumbs>
|
||||||
{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={{
|
|
||||||
width: 80,
|
|
||||||
height: 80,
|
|
||||||
cursor: 'pointer',
|
|
||||||
border: index === selectedImage ? `2px solid ${theme.palette.primary.main}` : '2px solid transparent',
|
|
||||||
borderRadius: 1,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={image.path}
|
|
||||||
alt={`${product.name} thumbnail ${index + 1}`}
|
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* Product Details */}
|
<Grid container spacing={4}>
|
||||||
<Grid item xs={12} md={6}>
|
{/* Product Images */}
|
||||||
<Typography variant="h4" component="h1" gutterBottom>
|
<Grid item xs={12} md={6}>
|
||||||
{product.name}
|
<Box sx={{ mb: 2 }}>
|
||||||
</Typography>
|
<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={{
|
||||||
|
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>
|
||||||
|
|
||||||
<Typography variant="h5" color="primary" gutterBottom>
|
{/* Product Details */}
|
||||||
${parseFloat(product.price).toFixed(2)}
|
<Grid item xs={12} md={6}>
|
||||||
</Typography>
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
|
{product.name}
|
||||||
{/* 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 && (
|
{product.average_rating && (
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1, mb: 2 }}>
|
||||||
{product.stock_quantity} items available
|
<Rating
|
||||||
</Typography>
|
value={product.average_rating}
|
||||||
)}
|
readOnly
|
||||||
</Box>
|
precision={0.5}
|
||||||
|
|
||||||
{/* Add to cart section */}
|
|
||||||
{product.stock_quantity > 0 ? (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', mr: 2 }}>
|
|
||||||
<IconButton
|
|
||||||
onClick={decreaseQuantity}
|
|
||||||
disabled={quantity <= 1}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<RemoveIcon />
|
|
||||||
</IconButton>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
value={quantity}
|
|
||||||
onChange={handleQuantityChange}
|
|
||||||
inputProps={{ min: 1, max: product.stock_quantity }}
|
|
||||||
sx={{ width: 60, mx: 1 }}
|
|
||||||
size="small"
|
|
||||||
/>
|
/>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ ml: 1 }}>
|
||||||
<IconButton
|
{product.average_rating} ({product.review_count} {product.review_count === 1 ? 'review' : 'reviews'})
|
||||||
onClick={increaseQuantity}
|
</Typography>
|
||||||
disabled={quantity >= product.stock_quantity}
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
<AddIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
<Button
|
|
||||||
variant="contained"
|
{!product.average_rating && (
|
||||||
startIcon={<ShoppingCartIcon />}
|
<Box sx={{ mt: 1, mb: 2 }}>
|
||||||
onClick={handleAddToCart}
|
<Typography variant="body2" color="text.secondary">
|
||||||
disabled={addToCart.isLoading}
|
No reviews yet
|
||||||
sx={{ flexGrow: 1 }}
|
</Typography>
|
||||||
>
|
</Box>
|
||||||
{addToCart.isLoading ? 'Adding...' : 'Add to Cart'}
|
)}
|
||||||
</Button>
|
|
||||||
|
<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>
|
||||||
) : (
|
|
||||||
<Button
|
<Typography variant="body1" paragraph sx={{ mt: 2 }}>
|
||||||
variant="outlined"
|
{product.description}
|
||||||
color="error"
|
</Typography>
|
||||||
disabled
|
|
||||||
fullWidth
|
<Divider sx={{ my: 3 }} />
|
||||||
sx={{ mb: 3 }}
|
|
||||||
>
|
{/* Stock information */}
|
||||||
Out of Stock
|
<Box sx={{ mb: 3 }}>
|
||||||
</Button>
|
<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>
|
||||||
|
|
||||||
|
{product.stock_quantity > 0 && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{product.stock_quantity} items available
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Add to cart section */}
|
||||||
|
{product.stock_quantity > 0 ? (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mr: 2 }}>
|
||||||
|
<IconButton
|
||||||
|
onClick={decreaseQuantity}
|
||||||
|
disabled={quantity <= 1}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<RemoveIcon />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
value={quantity}
|
||||||
|
onChange={handleQuantityChange}
|
||||||
|
inputProps={{ min: 1, max: product.stock_quantity }}
|
||||||
|
sx={{ width: 60, mx: 1 }}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
onClick={increaseQuantity}
|
||||||
|
disabled={quantity >= product.stock_quantity}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<AddIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<ShoppingCartIcon />}
|
||||||
|
onClick={handleAddToCart}
|
||||||
|
disabled={addToCart.isLoading}
|
||||||
|
sx={{ flexGrow: 1 }}
|
||||||
|
>
|
||||||
|
{addToCart.isLoading ? 'Adding...' : 'Add to Cart'}
|
||||||
|
</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>
|
||||||
|
|
||||||
{/* Product properties table */}
|
{/* Reviews Section */}
|
||||||
<Typography variant="h6" gutterBottom>
|
<Grid item xs={12}>
|
||||||
Product Details
|
<Divider sx={{ my: 4 }} />
|
||||||
</Typography>
|
<ProductReviews productId={id} />
|
||||||
|
</Grid>
|
||||||
<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>
|
|
||||||
<Grid item xs={12}>
|
|
||||||
<Divider sx={{ my: 4 }} />
|
|
||||||
<ProductReviews productId={id} />
|
|
||||||
</Grid>
|
|
||||||
</TableContainer>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Box>
|
||||||
</Box>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,12 @@ import {
|
||||||
Drawer,
|
Drawer,
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
|
Breadcrumbs,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
Divider,
|
Divider,
|
||||||
Chip,
|
Chip,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
Link,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
Select,
|
Select,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
|
|
@ -29,6 +31,7 @@ import SearchIcon from '@mui/icons-material/Search';
|
||||||
import FilterListIcon from '@mui/icons-material/FilterList';
|
import FilterListIcon from '@mui/icons-material/FilterList';
|
||||||
import SortIcon from '@mui/icons-material/Sort';
|
import SortIcon from '@mui/icons-material/Sort';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||||
import { Link as RouterLink, useNavigate, useLocation } from 'react-router-dom';
|
import { Link as RouterLink, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useProducts, useCategories, useTags, useAddToCart } from '@hooks/apiHooks';
|
import { useProducts, useCategories, useTags, useAddToCart } from '@hooks/apiHooks';
|
||||||
|
|
@ -36,6 +39,9 @@ import ProductRatingDisplay from '@components/ProductRatingDisplay';
|
||||||
import { useAuth } from '@hooks/reduxHooks';
|
import { useAuth } from '@hooks/reduxHooks';
|
||||||
import imageUtils from '@utils/imageUtils';
|
import imageUtils from '@utils/imageUtils';
|
||||||
import useBrandingSettings from '@hooks/brandingHooks';
|
import useBrandingSettings from '@hooks/brandingHooks';
|
||||||
|
import SEOMetaTags from '@components/SEOMetaTags';
|
||||||
|
import useSeoMeta from '@hooks/useSeoMeta';
|
||||||
|
import useSeoUrl from '@hooks/useSeoUrl';
|
||||||
|
|
||||||
const ProductsPage = () => {
|
const ProductsPage = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
@ -44,6 +50,7 @@ const ProductsPage = () => {
|
||||||
const { isAuthenticated, user } = useAuth();
|
const { isAuthenticated, user } = useAuth();
|
||||||
const { data: brandingSettings } = useBrandingSettings();
|
const { data: brandingSettings } = useBrandingSettings();
|
||||||
|
|
||||||
|
const seoUrl = useSeoUrl();
|
||||||
// Parse query params
|
// Parse query params
|
||||||
const queryParams = new URLSearchParams(location.search);
|
const queryParams = new URLSearchParams(location.search);
|
||||||
|
|
||||||
|
|
@ -137,426 +144,508 @@ const ProductsPage = () => {
|
||||||
const paginatedProducts = products ?
|
const paginatedProducts = products ?
|
||||||
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>
|
||||||
|
|
||||||
|
<SEOMetaTags
|
||||||
|
title={getPageTitle()}
|
||||||
|
description={getPageDescription()}
|
||||||
|
canonical={`/products${location.search}`}
|
||||||
|
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>
|
||||||
|
|
||||||
<List disablePadding>
|
{/* Filter button (mobile) */}
|
||||||
<ListItem
|
<Box sx={{ display: { xs: 'block', md: 'none' } }}>
|
||||||
button
|
<Button
|
||||||
selected={filters.category === ''}
|
startIcon={<FilterListIcon />}
|
||||||
onClick={() => setFilters({ ...filters, category: '' })}
|
variant="outlined"
|
||||||
|
onClick={() => setDrawerOpen(true)}
|
||||||
>
|
>
|
||||||
<ListItemText primary="All Categories" />
|
Filters
|
||||||
</ListItem>
|
</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>
|
||||||
|
|
||||||
{categories?.map((category) => (
|
<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>
|
{categories?.map((category) => (
|
||||||
|
<ListItem
|
||||||
|
button
|
||||||
|
key={category.id}
|
||||||
|
selected={filters.category === category.name}
|
||||||
|
onClick={() => setFilters({ ...filters, category: category.name })}
|
||||||
|
>
|
||||||
|
<ListItemText primary={category.name} />
|
||||||
|
</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>
|
||||||
|
|
||||||
<Divider sx={{ my: 2 }} />
|
{/* Product grid */}
|
||||||
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
<Typography variant="h6" gutterBottom>
|
{isLoading ? (
|
||||||
Popular Tags
|
<Box sx={{ display: 'flex', justifyContent: 'center', my: 4 }}>
|
||||||
</Typography>
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
) : error ? (
|
||||||
{tags?.map((tag) => (
|
<Typography color="error" sx={{ my: 4 }}>
|
||||||
<Chip
|
Error loading products. Please try again.
|
||||||
key={tag.id}
|
</Typography>
|
||||||
label={tag.name}
|
) : paginatedProducts?.length === 0 ? (
|
||||||
onClick={() => setFilters({ ...filters, tag: tag.name })}
|
<Typography sx={{ my: 4 }}>
|
||||||
color={filters.tag === tag.name ? 'primary' : 'default'}
|
No products found with the selected filters.
|
||||||
clickable
|
</Typography>
|
||||||
size="small"
|
) : (
|
||||||
sx={{ mb: 1 }}
|
<>
|
||||||
/>
|
<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>
|
</Box>
|
||||||
|
|
||||||
{/* Product grid */}
|
{/* Filter drawer (mobile) */}
|
||||||
<Box sx={{ flexGrow: 1 }}>
|
<Drawer
|
||||||
{isLoading ? (
|
anchor="right"
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', my: 4 }}>
|
open={drawerOpen}
|
||||||
<CircularProgress />
|
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>
|
</Box>
|
||||||
) : error ? (
|
|
||||||
<Typography color="error" sx={{ my: 4 }}>
|
<Divider sx={{ mb: 2 }} />
|
||||||
Error loading products. Please try again.
|
|
||||||
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
|
Sort by
|
||||||
</Typography>
|
</Typography>
|
||||||
) : paginatedProducts?.length === 0 ? (
|
|
||||||
<Typography sx={{ my: 4 }}>
|
<FormControl fullWidth size="small" sx={{ mb: 2 }}>
|
||||||
No products found with the selected filters.
|
<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>
|
</Typography>
|
||||||
) : (
|
|
||||||
<>
|
<FormControl fullWidth size="small" sx={{ mb: 3 }}>
|
||||||
<Grid container spacing={3}>
|
<InputLabel id="category-label">Category</InputLabel>
|
||||||
{paginatedProducts.map((product) => (
|
<Select
|
||||||
<Grid item xs={12} sm={6} md={4} key={product.id}>
|
labelId="category-label"
|
||||||
<Card
|
id="category"
|
||||||
sx={{
|
name="category"
|
||||||
height: '100%',
|
value={filters.category}
|
||||||
display: 'flex',
|
label="Category"
|
||||||
flexDirection: 'column',
|
onChange={handleFilterChange}
|
||||||
transition: '0.3s',
|
>
|
||||||
'&:hover': {
|
<MenuItem value="">All Categories</MenuItem>
|
||||||
transform: 'translateY(-5px)',
|
{categories?.map((category) => (
|
||||||
boxShadow: (theme) => theme.shadows[8],
|
<MenuItem key={category.id} value={category.name}>
|
||||||
},
|
{category.name}
|
||||||
}}
|
</MenuItem>
|
||||||
>
|
|
||||||
<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>
|
</Select>
|
||||||
|
</FormControl>
|
||||||
{/* Pagination */}
|
|
||||||
{products && products.length > itemsPerPage && (
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
|
Popular Tags
|
||||||
<Pagination
|
</Typography>
|
||||||
count={Math.ceil(products.length / itemsPerPage)}
|
|
||||||
page={page}
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 3 }}>
|
||||||
onChange={(e, value) => setPage(value)}
|
{tags?.slice(0, 10).map((tag) => (
|
||||||
color="primary"
|
<Chip
|
||||||
/>
|
key={tag.id}
|
||||||
</Box>
|
label={tag.name}
|
||||||
)}
|
onClick={() => {
|
||||||
</>
|
setFilters({ ...filters, tag: tag.name });
|
||||||
)}
|
setDrawerOpen(false);
|
||||||
</Box>
|
}}
|
||||||
</Box>
|
color={filters.tag === tag.name ? 'primary' : 'default'}
|
||||||
|
clickable
|
||||||
{/* Filter drawer (mobile) */}
|
size="small"
|
||||||
<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>
|
</Box>
|
||||||
</FormControl>
|
|
||||||
|
<Button
|
||||||
<Typography variant="subtitle1" gutterBottom>
|
variant="outlined"
|
||||||
Popular Tags
|
fullWidth
|
||||||
</Typography>
|
onClick={() => {
|
||||||
|
clearFilters();
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 3 }}>
|
setDrawerOpen(false);
|
||||||
{tags?.slice(0, 10).map((tag) => (
|
}}
|
||||||
<Chip
|
>
|
||||||
key={tag.id}
|
Clear All Filters
|
||||||
label={tag.name}
|
</Button>
|
||||||
onClick={() => {
|
|
||||||
setFilters({ ...filters, tag: tag.name });
|
|
||||||
setDrawerOpen(false);
|
|
||||||
}}
|
|
||||||
color={filters.tag === tag.name ? 'primary' : 'default'}
|
|
||||||
clickable
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
</Box>
|
||||||
|
</Drawer>
|
||||||
<Button
|
</Box>
|
||||||
variant="outlined"
|
</>
|
||||||
fullWidth
|
|
||||||
onClick={() => {
|
|
||||||
clearFilters();
|
|
||||||
setDrawerOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Clear All Filters
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Drawer>
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,14 +24,15 @@ import {
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
|
Breadcrumbs,
|
||||||
Divider,
|
Divider,
|
||||||
Link
|
Link
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
import { useNavigate, useLocation, Link as RouterLink } from 'react-router-dom';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useUserOrders, useUserOrder } from '@hooks/apiHooks';
|
||||||
import { useUserOrders, useUserOrder } from '../hooks/apiHooks';
|
import ProductImage from '@components/ProductImage';
|
||||||
import ProductImage from '../components/ProductImage';
|
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||||
|
|
||||||
import useBrandingSettings from '@hooks/brandingHooks';
|
import useBrandingSettings from '@hooks/brandingHooks';
|
||||||
|
|
||||||
const UserOrdersPage = () => {
|
const UserOrdersPage = () => {
|
||||||
|
|
@ -129,6 +130,20 @@ const UserOrdersPage = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
|
||||||
|
{/* Breadcrumbs */}
|
||||||
|
<Breadcrumbs
|
||||||
|
separator={<NavigateNextIcon fontSize="small" />}
|
||||||
|
aria-label="breadcrumb"
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
>
|
||||||
|
<Link component={RouterLink} to="/" color="inherit">
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
<Link component={RouterLink} to="/account/orders" color="inherit">
|
||||||
|
Orders
|
||||||
|
</Link>
|
||||||
|
</Breadcrumbs>
|
||||||
<Typography variant="h4" component="h1" gutterBottom>
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
{brandingSettings?.orders_title || `My Orders`}
|
{brandingSettings?.orders_title || `My Orders`}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,6 @@ export const store = configureStore({
|
||||||
devTools: process.env.NODE_ENV !== 'production',
|
devTools: process.env.NODE_ENV !== 'production',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export types for cleaner usage in components
|
|
||||||
export * from '../features/auth/authSlice';
|
export * from '../features/auth/authSlice';
|
||||||
export * from '../features/cart/cartSlice';
|
export * from '../features/cart/cartSlice';
|
||||||
export * from '../features/ui/uiSlice';
|
export * from '../features/ui/uiSlice';
|
||||||
|
|
||||||
// Infer the `RootState` and `AppDispatch` types from the store itself
|
|
||||||
// export type RootState = ReturnType<typeof store.getState>;
|
|
||||||
// export type AppDispatch = typeof store.dispatch;
|
|
||||||
Loading…
Reference in a new issue