feat: add dynamic meta tags for social sharing (Phase 4-5)
Phase 4: Dynamic Meta Tags - Added react-helmet-async dependency - Created SEOHead component with Open Graph and Twitter Card support - Added HelmetProvider wrapper to App.tsx - Integrated SEOHead in Product page (title, description, image, product info) - Integrated SEOHead in Shop page (basic meta tags) Phase 5: Auto-Flush Permalinks - Enhanced settings change handler to only flush when spa_mode, spa_page, or use_browser_router changes - Plugin already flushes on activation (Installer.php) This enables proper link previews when sharing product URLs on Facebook, Twitter, Slack, etc.
This commit is contained in:
36
customer-spa/package-lock.json
generated
36
customer-spa/package-lock.json
generated
@@ -28,6 +28,7 @@
|
|||||||
"lucide-react": "^0.547.0",
|
"lucide-react": "^0.547.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-helmet-async": "^2.0.5",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.9.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
@@ -4927,6 +4928,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/invariant": {
|
||||||
|
"version": "2.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||||
|
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-array-buffer": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||||
@@ -6214,6 +6224,26 @@
|
|||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-fast-compare": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/react-helmet-async": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-rYUYHeus+i27MvFE+Jaa4WsyBKGkL6qVgbJvSBoX8mbsWoABJXdEO0bZyi0F6i+4f0NuIb8AvqPMj3iXFHkMwg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"invariant": "^2.2.4",
|
||||||
|
"react-fast-compare": "^3.2.2",
|
||||||
|
"shallowequal": "^1.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.6.0 || ^17.0.0 || ^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-hook-form": {
|
"node_modules/react-hook-form": {
|
||||||
"version": "7.66.1",
|
"version": "7.66.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.1.tgz",
|
||||||
@@ -6658,6 +6688,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/shallowequal": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"lucide-react": "^0.547.0",
|
"lucide-react": "^0.547.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-helmet-async": "^2.0.5",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.9.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { HashRouter, BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { HashRouter, BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { HelmetProvider } from 'react-helmet-async';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
|
|
||||||
// Theme
|
// Theme
|
||||||
@@ -128,16 +129,18 @@ function App() {
|
|||||||
const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any;
|
const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<HelmetProvider>
|
||||||
<ThemeProvider config={themeConfig}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<RouterProvider>
|
<ThemeProvider config={themeConfig}>
|
||||||
<AppRoutes />
|
<RouterProvider>
|
||||||
</RouterProvider>
|
<AppRoutes />
|
||||||
|
</RouterProvider>
|
||||||
|
|
||||||
{/* Toast notifications - position from settings */}
|
{/* Toast notifications - position from settings */}
|
||||||
<Toaster position={toastPosition} richColors />
|
<Toaster position={toastPosition} richColors />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
</HelmetProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
68
customer-spa/src/components/SEOHead.tsx
Normal file
68
customer-spa/src/components/SEOHead.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Helmet } from 'react-helmet-async';
|
||||||
|
|
||||||
|
interface SEOHeadProps {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
image?: string;
|
||||||
|
url?: string;
|
||||||
|
type?: 'website' | 'product' | 'article';
|
||||||
|
product?: {
|
||||||
|
price?: string;
|
||||||
|
currency?: string;
|
||||||
|
availability?: 'in stock' | 'out of stock';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SEOHead Component
|
||||||
|
* Adds dynamic meta tags for social media sharing (Open Graph, Twitter Cards)
|
||||||
|
* Used for link previews on Facebook, Twitter, Slack, etc.
|
||||||
|
*/
|
||||||
|
export function SEOHead({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
image,
|
||||||
|
url,
|
||||||
|
type = 'website',
|
||||||
|
product,
|
||||||
|
}: SEOHeadProps) {
|
||||||
|
const config = (window as any).woonoowCustomer;
|
||||||
|
const siteName = config?.siteName || 'Store';
|
||||||
|
const siteUrl = config?.siteUrl || '';
|
||||||
|
|
||||||
|
const fullTitle = title ? `${title} | ${siteName}` : siteName;
|
||||||
|
const fullUrl = url || (typeof window !== 'undefined' ? window.location.href : '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Helmet>
|
||||||
|
{/* Basic Meta Tags */}
|
||||||
|
<title>{fullTitle}</title>
|
||||||
|
{description && <meta name="description" content={description} />}
|
||||||
|
|
||||||
|
{/* Open Graph (Facebook, LinkedIn, etc.) */}
|
||||||
|
<meta property="og:site_name" content={siteName} />
|
||||||
|
<meta property="og:title" content={title || siteName} />
|
||||||
|
{description && <meta property="og:description" content={description} />}
|
||||||
|
<meta property="og:type" content={type} />
|
||||||
|
<meta property="og:url" content={fullUrl} />
|
||||||
|
{image && <meta property="og:image" content={image} />}
|
||||||
|
|
||||||
|
{/* Twitter Card */}
|
||||||
|
<meta name="twitter:card" content={image ? 'summary_large_image' : 'summary'} />
|
||||||
|
<meta name="twitter:title" content={title || siteName} />
|
||||||
|
{description && <meta name="twitter:description" content={description} />}
|
||||||
|
{image && <meta name="twitter:image" content={image} />}
|
||||||
|
|
||||||
|
{/* Product-specific meta tags */}
|
||||||
|
{type === 'product' && product && (
|
||||||
|
<>
|
||||||
|
<meta property="product:price:amount" content={product.price} />
|
||||||
|
<meta property="product:price:currency" content={product.currency} />
|
||||||
|
<meta property="product:availability" content={product.availability} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Helmet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SEOHead;
|
||||||
@@ -12,6 +12,7 @@ import { ProductCard } from '@/components/ProductCard';
|
|||||||
import { formatPrice } from '@/lib/currency';
|
import { formatPrice } from '@/lib/currency';
|
||||||
import { ShoppingCart, Minus, Plus, ArrowLeft, ChevronLeft, ChevronRight, Heart } from 'lucide-react';
|
import { ShoppingCart, Minus, Plus, ArrowLeft, ChevronLeft, ChevronRight, Heart } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import SEOHead from '@/components/SEOHead';
|
||||||
import type { Product as ProductType, ProductsResponse } from '@/types/product';
|
import type { Product as ProductType, ProductsResponse } from '@/types/product';
|
||||||
|
|
||||||
export default function Product() {
|
export default function Product() {
|
||||||
@@ -43,20 +44,20 @@ export default function Product() {
|
|||||||
queryKey: ['related-products', product?.id],
|
queryKey: ['related-products', product?.id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!product) return [];
|
if (!product) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (product.related_ids && product.related_ids.length > 0) {
|
if (product.related_ids && product.related_ids.length > 0) {
|
||||||
const ids = product.related_ids.slice(0, 4).join(',');
|
const ids = product.related_ids.slice(0, 4).join(',');
|
||||||
const response = await apiClient.get<ProductsResponse>(`/shop/products?include=${ids}`);
|
const response = await apiClient.get<ProductsResponse>(`/shop/products?include=${ids}`);
|
||||||
return response.products || [];
|
return response.products || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryId = product.categories?.[0]?.term_id || product.categories?.[0]?.id;
|
const categoryId = product.categories?.[0]?.term_id || product.categories?.[0]?.id;
|
||||||
if (categoryId) {
|
if (categoryId) {
|
||||||
const response = await apiClient.get<ProductsResponse>(`/shop/products?category=${categoryId}&per_page=4&exclude=${product.id}`);
|
const response = await apiClient.get<ProductsResponse>(`/shop/products?category=${categoryId}&per_page=4&exclude=${product.id}`);
|
||||||
return response.products || [];
|
return response.products || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch related products:', error);
|
console.error('Failed to fetch related products:', error);
|
||||||
@@ -77,13 +78,13 @@ export default function Product() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (product?.type === 'variable' && product.attributes && Object.keys(selectedAttributes).length === 0) {
|
if (product?.type === 'variable' && product.attributes && Object.keys(selectedAttributes).length === 0) {
|
||||||
const initialAttributes: Record<string, string> = {};
|
const initialAttributes: Record<string, string> = {};
|
||||||
|
|
||||||
product.attributes.forEach((attr: any) => {
|
product.attributes.forEach((attr: any) => {
|
||||||
if (attr.variation && attr.options && attr.options.length > 0) {
|
if (attr.variation && attr.options && attr.options.length > 0) {
|
||||||
initialAttributes[attr.name] = attr.options[0];
|
initialAttributes[attr.name] = attr.options[0];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (Object.keys(initialAttributes).length > 0) {
|
if (Object.keys(initialAttributes).length > 0) {
|
||||||
setSelectedAttributes(initialAttributes);
|
setSelectedAttributes(initialAttributes);
|
||||||
}
|
}
|
||||||
@@ -95,30 +96,30 @@ export default function Product() {
|
|||||||
if (product?.type === 'variable' && product.variations && Object.keys(selectedAttributes).length > 0) {
|
if (product?.type === 'variable' && product.variations && Object.keys(selectedAttributes).length > 0) {
|
||||||
const variation = (product.variations as any[]).find(v => {
|
const variation = (product.variations as any[]).find(v => {
|
||||||
if (!v.attributes) return false;
|
if (!v.attributes) return false;
|
||||||
|
|
||||||
return Object.entries(selectedAttributes).every(([attrName, attrValue]) => {
|
return Object.entries(selectedAttributes).every(([attrName, attrValue]) => {
|
||||||
const normalizedValue = attrValue.toLowerCase().trim();
|
const normalizedValue = attrValue.toLowerCase().trim();
|
||||||
|
|
||||||
// Check all attribute keys in variation (case-insensitive)
|
// Check all attribute keys in variation (case-insensitive)
|
||||||
for (const [vKey, vValue] of Object.entries(v.attributes)) {
|
for (const [vKey, vValue] of Object.entries(v.attributes)) {
|
||||||
const vKeyLower = vKey.toLowerCase();
|
const vKeyLower = vKey.toLowerCase();
|
||||||
const attrNameLower = attrName.toLowerCase();
|
const attrNameLower = attrName.toLowerCase();
|
||||||
|
|
||||||
if (vKeyLower === `attribute_${attrNameLower}` ||
|
if (vKeyLower === `attribute_${attrNameLower}` ||
|
||||||
vKeyLower === `attribute_pa_${attrNameLower}` ||
|
vKeyLower === `attribute_pa_${attrNameLower}` ||
|
||||||
vKeyLower === attrNameLower) {
|
vKeyLower === attrNameLower) {
|
||||||
|
|
||||||
const varValueNormalized = String(vValue).toLowerCase().trim();
|
const varValueNormalized = String(vValue).toLowerCase().trim();
|
||||||
if (varValueNormalized === normalizedValue) {
|
if (varValueNormalized === normalizedValue) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
setSelectedVariation(variation || null);
|
setSelectedVariation(variation || null);
|
||||||
} else if (product?.type !== 'variable') {
|
} else if (product?.type !== 'variable') {
|
||||||
setSelectedVariation(null);
|
setSelectedVariation(null);
|
||||||
@@ -135,9 +136,9 @@ export default function Product() {
|
|||||||
// Build complete image gallery including variation images (BEFORE early returns)
|
// Build complete image gallery including variation images (BEFORE early returns)
|
||||||
const allImages = React.useMemo(() => {
|
const allImages = React.useMemo(() => {
|
||||||
if (!product) return [];
|
if (!product) return [];
|
||||||
|
|
||||||
const images = [...(product.images || [])];
|
const images = [...(product.images || [])];
|
||||||
|
|
||||||
// Add variation images if they don't exist in main gallery
|
// Add variation images if they don't exist in main gallery
|
||||||
if (product.type === 'variable' && product.variations) {
|
if (product.type === 'variable' && product.variations) {
|
||||||
(product.variations as any[]).forEach(variation => {
|
(product.variations as any[]).forEach(variation => {
|
||||||
@@ -146,7 +147,7 @@ export default function Product() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out any falsy values (false, null, undefined, empty strings)
|
// Filter out any falsy values (false, null, undefined, empty strings)
|
||||||
return images.filter(img => img && typeof img === 'string' && img.trim() !== '');
|
return images.filter(img => img && typeof img === 'string' && img.trim() !== '');
|
||||||
}, [product]);
|
}, [product]);
|
||||||
@@ -198,8 +199,8 @@ export default function Product() {
|
|||||||
virtual: product.virtual,
|
virtual: product.virtual,
|
||||||
downloadable: product.downloadable,
|
downloadable: product.downloadable,
|
||||||
// Use selectedAttributes from state (user's selections) for variable products
|
// Use selectedAttributes from state (user's selections) for variable products
|
||||||
attributes: product.type === 'variable' && Object.keys(selectedAttributes).length > 0
|
attributes: product.type === 'variable' && Object.keys(selectedAttributes).length > 0
|
||||||
? selectedAttributes
|
? selectedAttributes
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -257,6 +258,18 @@ export default function Product() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
|
{/* SEO Meta Tags for Social Sharing */}
|
||||||
|
<SEOHead
|
||||||
|
title={product.name}
|
||||||
|
description={product.short_description?.replace(/<[^>]+>/g, '').slice(0, 160) || product.description?.replace(/<[^>]+>/g, '').slice(0, 160)}
|
||||||
|
image={product.image || product.images?.[0]}
|
||||||
|
type="product"
|
||||||
|
product={{
|
||||||
|
price: currentPrice,
|
||||||
|
currency: (window as any).woonoowCustomer?.currency?.code || 'USD',
|
||||||
|
availability: stockStatus === 'instock' ? 'in stock' : 'out of stock',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div className="max-w-6xl mx-auto py-8">
|
<div className="max-w-6xl mx-auto py-8">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
{elements.breadcrumbs && (
|
{elements.breadcrumbs && (
|
||||||
@@ -297,7 +310,7 @@ export default function Product() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dots Navigation - Show based on gallery_style */}
|
{/* Dots Navigation - Show based on gallery_style */}
|
||||||
{allImages && allImages.length > 1 && layout.gallery_style === 'dots' && (
|
{allImages && allImages.length > 1 && layout.gallery_style === 'dots' && (
|
||||||
<div className="flex justify-center gap-2 mt-4">
|
<div className="flex justify-center gap-2 mt-4">
|
||||||
@@ -306,18 +319,17 @@ export default function Product() {
|
|||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => setSelectedImage(img)}
|
onClick={() => setSelectedImage(img)}
|
||||||
className={`w-2 h-2 rounded-full transition-all ${
|
className={`w-2 h-2 rounded-full transition-all ${selectedImage === img
|
||||||
selectedImage === img
|
|
||||||
? 'bg-primary w-6'
|
? 'bg-primary w-6'
|
||||||
: 'bg-gray-300 hover:bg-gray-400'
|
: 'bg-gray-300 hover:bg-gray-400'
|
||||||
}`}
|
}`}
|
||||||
aria-label={`View image ${index + 1}`}
|
aria-label={`View image ${index + 1}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Thumbnail Slider - Show based on gallery_style */}
|
{/* Thumbnail Slider - Show based on gallery_style */}
|
||||||
{allImages && allImages.length > 1 && layout.gallery_style === 'thumbnails' && (
|
{allImages && allImages.length > 1 && layout.gallery_style === 'thumbnails' && (
|
||||||
<div className="relative w-full overflow-hidden">
|
<div className="relative w-full overflow-hidden">
|
||||||
@@ -330,7 +342,7 @@ export default function Product() {
|
|||||||
<ChevronLeft className="h-5 w-5" />
|
<ChevronLeft className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Scrollable Thumbnails */}
|
{/* Scrollable Thumbnails */}
|
||||||
<div
|
<div
|
||||||
ref={thumbnailsRef}
|
ref={thumbnailsRef}
|
||||||
@@ -341,11 +353,10 @@ export default function Product() {
|
|||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => setSelectedImage(img)}
|
onClick={() => setSelectedImage(img)}
|
||||||
className={`flex-shrink-0 w-24 h-24 md:w-28 md:h-28 rounded-lg overflow-hidden border-2 transition-all shadow-md hover:shadow-lg ${
|
className={`flex-shrink-0 w-24 h-24 md:w-28 md:h-28 rounded-lg overflow-hidden border-2 transition-all shadow-md hover:shadow-lg ${selectedImage === img
|
||||||
selectedImage === img
|
|
||||||
? 'border-primary ring-4 ring-primary ring-offset-2'
|
? 'border-primary ring-4 ring-primary ring-offset-2'
|
||||||
: 'border-gray-300 hover:border-gray-400'
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={img}
|
src={img}
|
||||||
@@ -355,7 +366,7 @@ export default function Product() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Arrow */}
|
{/* Right Arrow */}
|
||||||
{allImages.length > 4 && (
|
{allImages.length > 4 && (
|
||||||
<button
|
<button
|
||||||
@@ -434,11 +445,10 @@ export default function Product() {
|
|||||||
<button
|
<button
|
||||||
key={optIndex}
|
key={optIndex}
|
||||||
onClick={() => handleAttributeChange(attr.name, option)}
|
onClick={() => handleAttributeChange(attr.name, option)}
|
||||||
className={`min-w-[48px] min-h-[48px] px-5 py-3 rounded-xl border-2 font-medium transition-all ${
|
className={`min-w-[48px] min-h-[48px] px-5 py-3 rounded-xl border-2 font-medium transition-all ${isSelected
|
||||||
isSelected
|
|
||||||
? 'bg-gray-900 text-white border-gray-900 shadow-lg'
|
? 'bg-gray-900 text-white border-gray-900 shadow-lg'
|
||||||
: 'bg-white text-gray-700 border-gray-200 hover:border-gray-400 hover:shadow-md'
|
: 'bg-white text-gray-700 border-gray-200 hover:border-gray-400 hover:shadow-md'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{option}
|
{option}
|
||||||
</button>
|
</button>
|
||||||
@@ -490,17 +500,15 @@ export default function Product() {
|
|||||||
Add to Cart
|
Add to Cart
|
||||||
</button>
|
</button>
|
||||||
{isModuleEnabled('wishlist') && wishlistEnabled && (
|
{isModuleEnabled('wishlist') && wishlistEnabled && (
|
||||||
<button
|
<button
|
||||||
onClick={() => product && toggleWishlist(product.id)}
|
onClick={() => product && toggleWishlist(product.id)}
|
||||||
className={`w-full h-14 flex items-center justify-center gap-2 rounded-xl font-semibold text-base border-2 transition-all ${
|
className={`w-full h-14 flex items-center justify-center gap-2 rounded-xl font-semibold text-base border-2 transition-all ${product && isInWishlist(product.id)
|
||||||
product && isInWishlist(product.id)
|
|
||||||
? 'bg-red-50 text-red-600 border-red-200 hover:border-red-400'
|
? 'bg-red-50 text-red-600 border-red-200 hover:border-red-400'
|
||||||
: 'bg-white text-gray-900 border-gray-200 hover:border-gray-400'
|
: 'bg-white text-gray-900 border-gray-200 hover:border-gray-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Heart className={`h-5 w-5 ${
|
<Heart className={`h-5 w-5 ${product && isInWishlist(product.id) ? 'fill-red-500' : ''
|
||||||
product && isInWishlist(product.id) ? 'fill-red-500' : ''
|
}`} />
|
||||||
}`} />
|
|
||||||
{product && isInWishlist(product.id) ? 'Remove from Wishlist' : 'Add to Wishlist'}
|
{product && isInWishlist(product.id) ? 'Remove from Wishlist' : 'Add to Wishlist'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -519,7 +527,7 @@ export default function Product() {
|
|||||||
<p className="font-medium text-sm text-gray-900">Free Shipping</p>
|
<p className="font-medium text-sm text-gray-900">Free Shipping</p>
|
||||||
<p className="text-xs text-gray-500 mt-1">On orders over $50</p>
|
<p className="text-xs text-gray-500 mt-1">On orders over $50</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Returns */}
|
{/* Returns */}
|
||||||
<div className="flex flex-col items-center text-center">
|
<div className="flex flex-col items-center text-center">
|
||||||
<div className="w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center mb-2">
|
<div className="w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center mb-2">
|
||||||
@@ -530,7 +538,7 @@ export default function Product() {
|
|||||||
<p className="font-medium text-sm text-gray-900">Easy Returns</p>
|
<p className="font-medium text-sm text-gray-900">Easy Returns</p>
|
||||||
<p className="text-xs text-gray-500 mt-1">30-day guarantee</p>
|
<p className="text-xs text-gray-500 mt-1">30-day guarantee</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Secure */}
|
{/* Secure */}
|
||||||
<div className="flex flex-col items-center text-center">
|
<div className="flex flex-col items-center text-center">
|
||||||
<div className="w-12 h-12 rounded-full bg-purple-50 flex items-center justify-center mb-2">
|
<div className="w-12 h-12 rounded-full bg-purple-50 flex items-center justify-center mb-2">
|
||||||
@@ -562,13 +570,13 @@ export default function Product() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Share Buttons */}
|
{/* Share Buttons */}
|
||||||
{elements.share_buttons && (
|
{elements.share_buttons && (
|
||||||
<div className="flex items-center gap-3 pt-4 border-t border-gray-200">
|
<div className="flex items-center gap-3 pt-4 border-t border-gray-200">
|
||||||
<span className="text-sm text-gray-600 font-medium">Share:</span>
|
<span className="text-sm text-gray-600 font-medium">Share:</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const url = encodeURIComponent(window.location.href);
|
const url = encodeURIComponent(window.location.href);
|
||||||
window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`, '_blank', 'width=600,height=400');
|
window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`, '_blank', 'width=600,height=400');
|
||||||
@@ -576,9 +584,9 @@ export default function Product() {
|
|||||||
className="w-9 h-9 rounded-full bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center transition-colors"
|
className="w-9 h-9 rounded-full bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center transition-colors"
|
||||||
title="Share on Facebook"
|
title="Share on Facebook"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" /></svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const url = encodeURIComponent(window.location.href);
|
const url = encodeURIComponent(window.location.href);
|
||||||
const text = encodeURIComponent(product.name);
|
const text = encodeURIComponent(product.name);
|
||||||
@@ -587,9 +595,9 @@ export default function Product() {
|
|||||||
className="w-9 h-9 rounded-full bg-sky-500 hover:bg-sky-600 text-white flex items-center justify-center transition-colors"
|
className="w-9 h-9 rounded-full bg-sky-500 hover:bg-sky-600 text-white flex items-center justify-center transition-colors"
|
||||||
title="Share on Twitter"
|
title="Share on Twitter"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg>
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z" /></svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const url = encodeURIComponent(window.location.href);
|
const url = encodeURIComponent(window.location.href);
|
||||||
const text = encodeURIComponent(product.name);
|
const text = encodeURIComponent(product.name);
|
||||||
@@ -598,7 +606,7 @@ export default function Product() {
|
|||||||
className="w-9 h-9 rounded-full bg-green-600 hover:bg-green-700 text-white flex items-center justify-center transition-colors"
|
className="w-9 h-9 rounded-full bg-green-600 hover:bg-green-700 text-white flex items-center justify-center transition-colors"
|
||||||
title="Share on WhatsApp"
|
title="Share on WhatsApp"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/></svg>
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" /></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -682,160 +690,160 @@ export default function Product() {
|
|||||||
{elements.reviews && reviewSettings.placement === 'product_page' && (
|
{elements.reviews && reviewSettings.placement === 'product_page' && (
|
||||||
// Show reviews only if: 1) not hiding when empty, OR 2) has reviews
|
// Show reviews only if: 1) not hiding when empty, OR 2) has reviews
|
||||||
(!reviewSettings.hide_if_empty || (product.review_count && product.review_count > 0)) && (
|
(!reviewSettings.hide_if_empty || (product.review_count && product.review_count > 0)) && (
|
||||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab(activeTab === 'reviews' ? '' : 'reviews')}
|
onClick={() => setActiveTab(activeTab === 'reviews' ? '' : 'reviews')}
|
||||||
className="w-full flex items-center justify-between p-5 bg-white hover:bg-gray-50 transition-colors"
|
className="w-full flex items-center justify-between p-5 bg-white hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<h2 className="text-xl font-bold text-gray-900">Customer Reviews</h2>
|
<h2 className="text-xl font-bold text-gray-900">Customer Reviews</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
{[1, 2, 3, 4, 5].map((star) => (
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
<svg key={star} className={`w-5 h-5 ${star <= Number(product.average_rating || 0) ? 'text-yellow-400' : 'text-gray-300'} fill-current`} viewBox="0 0 20 20">
|
<svg key={star} className={`w-5 h-5 ${star <= Number(product.average_rating || 0) ? 'text-yellow-400' : 'text-gray-300'} fill-current`} viewBox="0 0 20 20">
|
||||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||||
</svg>
|
</svg>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray-600 font-medium">
|
|
||||||
{product.average_rating || 0} ({product.review_count || 0} reviews)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<svg
|
|
||||||
className={`w-6 h-6 transition-transform ${activeTab === 'reviews' ? 'rotate-180' : ''}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{activeTab === 'reviews' && (
|
|
||||||
<div className="p-6 bg-white space-y-6">
|
|
||||||
{/* Review Summary */}
|
|
||||||
<div className="flex items-start gap-8 pb-6 border-b">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-5xl font-bold text-gray-900 mb-2">5.0</div>
|
|
||||||
<div className="flex mb-2">
|
|
||||||
{[1, 2, 3, 4, 5].map((star) => (
|
|
||||||
<svg key={star} className="w-5 h-5 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
|
||||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
|
||||||
</svg>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">Based on 128 reviews</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
{[5, 4, 3, 2, 1].map((rating) => (
|
|
||||||
<div key={rating} className="flex items-center gap-3">
|
|
||||||
<span className="text-sm text-gray-600 w-8">{rating} ★</span>
|
|
||||||
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-yellow-400"
|
|
||||||
style={{ width: rating === 5 ? '95%' : rating === 4 ? '4%' : '1%' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray-600 w-12">{rating === 5 ? '122' : rating === 4 ? '5' : '1'}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sample Reviews */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Review 1 */}
|
|
||||||
<div className="border-b pb-6">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-semibold">
|
|
||||||
JD
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="font-semibold text-gray-900">John Doe</span>
|
|
||||||
<span className="text-sm text-gray-500">• 2 days ago</span>
|
|
||||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Verified Purchase</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex mb-2">
|
|
||||||
{[1, 2, 3, 4, 5].map((star) => (
|
|
||||||
<svg key={star} className="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
|
||||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
|
||||||
</svg>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-3">
|
|
||||||
Absolutely love this product! The quality exceeded my expectations and it arrived quickly.
|
|
||||||
The packaging was also very professional. Highly recommend!
|
|
||||||
</p>
|
|
||||||
<button className="text-sm text-gray-600 hover:text-gray-900">Helpful (24)</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-sm text-gray-600 font-medium">
|
||||||
|
{product.average_rating || 0} ({product.review_count || 0} reviews)
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<svg
|
||||||
{/* Review 2 */}
|
className={`w-6 h-6 transition-transform ${activeTab === 'reviews' ? 'rotate-180' : ''}`}
|
||||||
<div className="border-b pb-6">
|
fill="none"
|
||||||
<div className="flex items-start gap-4">
|
stroke="currentColor"
|
||||||
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-semibold">
|
viewBox="0 0 24 24"
|
||||||
SM
|
>
|
||||||
</div>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
<div className="flex-1">
|
</svg>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="font-semibold text-gray-900">Sarah Miller</span>
|
|
||||||
<span className="text-sm text-gray-500">• 1 week ago</span>
|
|
||||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Verified Purchase</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex mb-2">
|
|
||||||
{[1, 2, 3, 4, 5].map((star) => (
|
|
||||||
<svg key={star} className="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
|
||||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
|
||||||
</svg>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-3">
|
|
||||||
Great value for money. Works exactly as described. Customer service was also very responsive
|
|
||||||
when I had questions before purchasing.
|
|
||||||
</p>
|
|
||||||
<button className="text-sm text-gray-600 hover:text-gray-900">Helpful (18)</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Review 3 */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-semibold">
|
|
||||||
MJ
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="font-semibold text-gray-900">Michael Johnson</span>
|
|
||||||
<span className="text-sm text-gray-500">• 2 weeks ago</span>
|
|
||||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Verified Purchase</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex mb-2">
|
|
||||||
{[1, 2, 3, 4, 5].map((star) => (
|
|
||||||
<svg key={star} className="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
|
||||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
|
||||||
</svg>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-700 leading-relaxed mb-3">
|
|
||||||
Perfect! This is my third purchase and I keep coming back. The consistency in quality is impressive.
|
|
||||||
Will definitely buy again.
|
|
||||||
</p>
|
|
||||||
<button className="text-sm text-gray-600 hover:text-gray-900">Helpful (32)</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button className="w-full py-3 border-2 border-gray-200 rounded-xl font-semibold text-gray-900 hover:border-gray-400 transition-all">
|
|
||||||
Load More Reviews
|
|
||||||
</button>
|
</button>
|
||||||
|
{activeTab === 'reviews' && (
|
||||||
|
<div className="p-6 bg-white space-y-6">
|
||||||
|
{/* Review Summary */}
|
||||||
|
<div className="flex items-start gap-8 pb-6 border-b">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-5xl font-bold text-gray-900 mb-2">5.0</div>
|
||||||
|
<div className="flex mb-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<svg key={star} className="w-5 h-5 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||||
|
</svg>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">Based on 128 reviews</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
{[5, 4, 3, 2, 1].map((rating) => (
|
||||||
|
<div key={rating} className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-gray-600 w-8">{rating} ★</span>
|
||||||
|
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-yellow-400"
|
||||||
|
style={{ width: rating === 5 ? '95%' : rating === 4 ? '4%' : '1%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-600 w-12">{rating === 5 ? '122' : rating === 4 ? '5' : '1'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sample Reviews */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Review 1 */}
|
||||||
|
<div className="border-b pb-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-semibold">
|
||||||
|
JD
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="font-semibold text-gray-900">John Doe</span>
|
||||||
|
<span className="text-sm text-gray-500">• 2 days ago</span>
|
||||||
|
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Verified Purchase</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex mb-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<svg key={star} className="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||||
|
</svg>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-700 leading-relaxed mb-3">
|
||||||
|
Absolutely love this product! The quality exceeded my expectations and it arrived quickly.
|
||||||
|
The packaging was also very professional. Highly recommend!
|
||||||
|
</p>
|
||||||
|
<button className="text-sm text-gray-600 hover:text-gray-900">Helpful (24)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Review 2 */}
|
||||||
|
<div className="border-b pb-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-semibold">
|
||||||
|
SM
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="font-semibold text-gray-900">Sarah Miller</span>
|
||||||
|
<span className="text-sm text-gray-500">• 1 week ago</span>
|
||||||
|
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Verified Purchase</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex mb-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<svg key={star} className="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||||
|
</svg>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-700 leading-relaxed mb-3">
|
||||||
|
Great value for money. Works exactly as described. Customer service was also very responsive
|
||||||
|
when I had questions before purchasing.
|
||||||
|
</p>
|
||||||
|
<button className="text-sm text-gray-600 hover:text-gray-900">Helpful (18)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Review 3 */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-semibold">
|
||||||
|
MJ
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="font-semibold text-gray-900">Michael Johnson</span>
|
||||||
|
<span className="text-sm text-gray-500">• 2 weeks ago</span>
|
||||||
|
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Verified Purchase</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex mb-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<svg key={star} className="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||||
|
</svg>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-700 leading-relaxed mb-3">
|
||||||
|
Perfect! This is my third purchase and I keep coming back. The consistency in quality is impressive.
|
||||||
|
Will definitely buy again.
|
||||||
|
</p>
|
||||||
|
<button className="text-sm text-gray-600 hover:text-gray-900">Helpful (32)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="w-full py-3 border-2 border-gray-200 rounded-xl font-semibold text-gray-900 hover:border-gray-400 transition-all">
|
||||||
|
Load More Reviews
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Related Products */}
|
{/* Related Products */}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ProductCard } from '@/components/ProductCard';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTheme, useLayout } from '@/contexts/ThemeContext';
|
import { useTheme, useLayout } from '@/contexts/ThemeContext';
|
||||||
import { useShopSettings } from '@/hooks/useAppearanceSettings';
|
import { useShopSettings } from '@/hooks/useAppearanceSettings';
|
||||||
|
import SEOHead from '@/components/SEOHead';
|
||||||
import type { ProductsResponse, ProductCategory, Product } from '@/types/product';
|
import type { ProductsResponse, ProductCategory, Product } from '@/types/product';
|
||||||
|
|
||||||
export default function Shop() {
|
export default function Shop() {
|
||||||
@@ -22,25 +23,25 @@ export default function Shop() {
|
|||||||
const [category, setCategory] = useState('');
|
const [category, setCategory] = useState('');
|
||||||
const [sortBy, setSortBy] = useState('');
|
const [sortBy, setSortBy] = useState('');
|
||||||
const { addItem } = useCartStore();
|
const { addItem } = useCartStore();
|
||||||
|
|
||||||
// Map grid columns setting to Tailwind classes (responsive)
|
// Map grid columns setting to Tailwind classes (responsive)
|
||||||
const gridCols = typeof shopLayout.grid_columns === 'object'
|
const gridCols = typeof shopLayout.grid_columns === 'object'
|
||||||
? shopLayout.grid_columns
|
? shopLayout.grid_columns
|
||||||
: { mobile: '2', tablet: '3', desktop: '4' };
|
: { mobile: '2', tablet: '3', desktop: '4' };
|
||||||
|
|
||||||
// Map to actual Tailwind classes (can't use template literals due to purging)
|
// Map to actual Tailwind classes (can't use template literals due to purging)
|
||||||
const mobileClass = {
|
const mobileClass = {
|
||||||
'1': 'grid-cols-1',
|
'1': 'grid-cols-1',
|
||||||
'2': 'grid-cols-2',
|
'2': 'grid-cols-2',
|
||||||
'3': 'grid-cols-3',
|
'3': 'grid-cols-3',
|
||||||
}[gridCols.mobile] || 'grid-cols-2';
|
}[gridCols.mobile] || 'grid-cols-2';
|
||||||
|
|
||||||
const tabletClass = {
|
const tabletClass = {
|
||||||
'2': 'md:grid-cols-2',
|
'2': 'md:grid-cols-2',
|
||||||
'3': 'md:grid-cols-3',
|
'3': 'md:grid-cols-3',
|
||||||
'4': 'md:grid-cols-4',
|
'4': 'md:grid-cols-4',
|
||||||
}[gridCols.tablet] || 'md:grid-cols-3';
|
}[gridCols.tablet] || 'md:grid-cols-3';
|
||||||
|
|
||||||
const desktopClass = {
|
const desktopClass = {
|
||||||
'2': 'lg:grid-cols-2',
|
'2': 'lg:grid-cols-2',
|
||||||
'3': 'lg:grid-cols-3',
|
'3': 'lg:grid-cols-3',
|
||||||
@@ -48,22 +49,22 @@ export default function Shop() {
|
|||||||
'5': 'lg:grid-cols-5',
|
'5': 'lg:grid-cols-5',
|
||||||
'6': 'lg:grid-cols-6',
|
'6': 'lg:grid-cols-6',
|
||||||
}[gridCols.desktop] || 'lg:grid-cols-4';
|
}[gridCols.desktop] || 'lg:grid-cols-4';
|
||||||
|
|
||||||
const gridColsClass = `${mobileClass} ${tabletClass} ${desktopClass}`;
|
const gridColsClass = `${mobileClass} ${tabletClass} ${desktopClass}`;
|
||||||
|
|
||||||
// Masonry column classes
|
// Masonry column classes
|
||||||
const masonryMobileClass = {
|
const masonryMobileClass = {
|
||||||
'1': 'columns-1',
|
'1': 'columns-1',
|
||||||
'2': 'columns-2',
|
'2': 'columns-2',
|
||||||
'3': 'columns-3',
|
'3': 'columns-3',
|
||||||
}[gridCols.mobile] || 'columns-2';
|
}[gridCols.mobile] || 'columns-2';
|
||||||
|
|
||||||
const masonryTabletClass = {
|
const masonryTabletClass = {
|
||||||
'2': 'md:columns-2',
|
'2': 'md:columns-2',
|
||||||
'3': 'md:columns-3',
|
'3': 'md:columns-3',
|
||||||
'4': 'md:columns-4',
|
'4': 'md:columns-4',
|
||||||
}[gridCols.tablet] || 'md:columns-3';
|
}[gridCols.tablet] || 'md:columns-3';
|
||||||
|
|
||||||
const masonryDesktopClass = {
|
const masonryDesktopClass = {
|
||||||
'2': 'lg:columns-2',
|
'2': 'lg:columns-2',
|
||||||
'3': 'lg:columns-3',
|
'3': 'lg:columns-3',
|
||||||
@@ -71,9 +72,9 @@ export default function Shop() {
|
|||||||
'5': 'lg:columns-5',
|
'5': 'lg:columns-5',
|
||||||
'6': 'lg:columns-6',
|
'6': 'lg:columns-6',
|
||||||
}[gridCols.desktop] || 'lg:columns-4';
|
}[gridCols.desktop] || 'lg:columns-4';
|
||||||
|
|
||||||
const masonryColsClass = `${masonryMobileClass} ${masonryTabletClass} ${masonryDesktopClass}`;
|
const masonryColsClass = `${masonryMobileClass} ${masonryTabletClass} ${masonryDesktopClass}`;
|
||||||
|
|
||||||
const isMasonry = shopLayout.grid_style === 'masonry';
|
const isMasonry = shopLayout.grid_style === 'masonry';
|
||||||
|
|
||||||
// Fetch products
|
// Fetch products
|
||||||
@@ -99,7 +100,7 @@ export default function Shop() {
|
|||||||
product_id: product.id,
|
product_id: product.id,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add to local cart store
|
// Add to local cart store
|
||||||
addItem({
|
addItem({
|
||||||
key: `${product.id}`,
|
key: `${product.id}`,
|
||||||
@@ -111,7 +112,7 @@ export default function Shop() {
|
|||||||
virtual: product.virtual,
|
virtual: product.virtual,
|
||||||
downloadable: product.downloadable,
|
downloadable: product.downloadable,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success(`${product.name} added to cart!`, {
|
toast.success(`${product.name} added to cart!`, {
|
||||||
action: {
|
action: {
|
||||||
label: 'View Cart',
|
label: 'View Cart',
|
||||||
@@ -126,6 +127,11 @@ export default function Shop() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
|
{/* SEO Meta Tags for Social Sharing */}
|
||||||
|
<SEOHead
|
||||||
|
title="Shop"
|
||||||
|
description="Browse our collection of products"
|
||||||
|
/>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-4xl font-bold mb-2">Shop</h1>
|
<h1 className="text-4xl font-bold mb-2">Shop</h1>
|
||||||
|
|||||||
@@ -16,10 +16,21 @@ class TemplateOverride
|
|||||||
// Register rewrite rules for BrowserRouter SEO (must be on 'init')
|
// Register rewrite rules for BrowserRouter SEO (must be on 'init')
|
||||||
add_action('init', [__CLASS__, 'register_spa_rewrite_rules']);
|
add_action('init', [__CLASS__, 'register_spa_rewrite_rules']);
|
||||||
|
|
||||||
// Flush rewrite rules when appearance settings are updated
|
// Flush rewrite rules when relevant settings change
|
||||||
add_action('update_option_woonoow_appearance_settings', function() {
|
add_action('update_option_woonoow_appearance_settings', function($old_value, $new_value) {
|
||||||
flush_rewrite_rules();
|
$old_general = $old_value['general'] ?? [];
|
||||||
});
|
$new_general = $new_value['general'] ?? [];
|
||||||
|
|
||||||
|
// Only flush if spa_mode, spa_page, or use_browser_router changed
|
||||||
|
$needs_flush =
|
||||||
|
($old_general['spa_mode'] ?? '') !== ($new_general['spa_mode'] ?? '') ||
|
||||||
|
($old_general['spa_page'] ?? '') !== ($new_general['spa_page'] ?? '') ||
|
||||||
|
($old_general['use_browser_router'] ?? true) !== ($new_general['use_browser_router'] ?? true);
|
||||||
|
|
||||||
|
if ($needs_flush) {
|
||||||
|
flush_rewrite_rules();
|
||||||
|
}
|
||||||
|
}, 10, 2);
|
||||||
|
|
||||||
// Redirect WooCommerce pages to SPA routes early (before template loads)
|
// Redirect WooCommerce pages to SPA routes early (before template loads)
|
||||||
add_action('template_redirect', [__CLASS__, 'redirect_wc_pages_to_spa'], 5);
|
add_action('template_redirect', [__CLASS__, 'redirect_wc_pages_to_spa'], 5);
|
||||||
|
|||||||
Reference in New Issue
Block a user