diff --git a/customer-spa/package-lock.json b/customer-spa/package-lock.json index 33f33ad..e5d97da 100644 --- a/customer-spa/package-lock.json +++ b/customer-spa/package-lock.json @@ -28,6 +28,7 @@ "lucide-react": "^0.547.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-helmet-async": "^2.0.5", "react-hook-form": "^7.54.2", "react-router-dom": "^7.9.4", "sonner": "^2.0.7", @@ -4927,6 +4928,15 @@ "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": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -6214,6 +6224,26 @@ "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": { "version": "7.66.1", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.1.tgz", @@ -6658,6 +6688,12 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/customer-spa/package.json b/customer-spa/package.json index f19ef73..2ab06b5 100644 --- a/customer-spa/package.json +++ b/customer-spa/package.json @@ -30,6 +30,7 @@ "lucide-react": "^0.547.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-helmet-async": "^2.0.5", "react-hook-form": "^7.54.2", "react-router-dom": "^7.9.4", "sonner": "^2.0.7", diff --git a/customer-spa/src/App.tsx b/customer-spa/src/App.tsx index 202d879..105af13 100644 --- a/customer-spa/src/App.tsx +++ b/customer-spa/src/App.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { HashRouter, BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { HelmetProvider } from 'react-helmet-async'; import { Toaster } from 'sonner'; // Theme @@ -128,16 +129,18 @@ function App() { const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any; return ( - - - - - + + + + + + - {/* Toast notifications - position from settings */} - - - + {/* Toast notifications - position from settings */} + + + + ); } diff --git a/customer-spa/src/components/SEOHead.tsx b/customer-spa/src/components/SEOHead.tsx new file mode 100644 index 0000000..a17fea4 --- /dev/null +++ b/customer-spa/src/components/SEOHead.tsx @@ -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 ( + + {/* Basic Meta Tags */} + {fullTitle} + {description && } + + {/* Open Graph (Facebook, LinkedIn, etc.) */} + + + {description && } + + + {image && } + + {/* Twitter Card */} + + + {description && } + {image && } + + {/* Product-specific meta tags */} + {type === 'product' && product && ( + <> + + + + + )} + + ); +} + +export default SEOHead; diff --git a/customer-spa/src/pages/Product/index.tsx b/customer-spa/src/pages/Product/index.tsx index 8975a1f..b4ac1ef 100644 --- a/customer-spa/src/pages/Product/index.tsx +++ b/customer-spa/src/pages/Product/index.tsx @@ -12,6 +12,7 @@ import { ProductCard } from '@/components/ProductCard'; import { formatPrice } from '@/lib/currency'; import { ShoppingCart, Minus, Plus, ArrowLeft, ChevronLeft, ChevronRight, Heart } from 'lucide-react'; import { toast } from 'sonner'; +import SEOHead from '@/components/SEOHead'; import type { Product as ProductType, ProductsResponse } from '@/types/product'; export default function Product() { @@ -43,20 +44,20 @@ export default function Product() { queryKey: ['related-products', product?.id], queryFn: async () => { if (!product) return []; - + try { if (product.related_ids && product.related_ids.length > 0) { const ids = product.related_ids.slice(0, 4).join(','); const response = await apiClient.get(`/shop/products?include=${ids}`); return response.products || []; } - + const categoryId = product.categories?.[0]?.term_id || product.categories?.[0]?.id; if (categoryId) { const response = await apiClient.get(`/shop/products?category=${categoryId}&per_page=4&exclude=${product.id}`); return response.products || []; } - + return []; } catch (error) { console.error('Failed to fetch related products:', error); @@ -77,13 +78,13 @@ export default function Product() { useEffect(() => { if (product?.type === 'variable' && product.attributes && Object.keys(selectedAttributes).length === 0) { const initialAttributes: Record = {}; - + product.attributes.forEach((attr: any) => { if (attr.variation && attr.options && attr.options.length > 0) { initialAttributes[attr.name] = attr.options[0]; } }); - + if (Object.keys(initialAttributes).length > 0) { setSelectedAttributes(initialAttributes); } @@ -95,30 +96,30 @@ export default function Product() { if (product?.type === 'variable' && product.variations && Object.keys(selectedAttributes).length > 0) { const variation = (product.variations as any[]).find(v => { if (!v.attributes) return false; - + return Object.entries(selectedAttributes).every(([attrName, attrValue]) => { const normalizedValue = attrValue.toLowerCase().trim(); - + // Check all attribute keys in variation (case-insensitive) for (const [vKey, vValue] of Object.entries(v.attributes)) { const vKeyLower = vKey.toLowerCase(); const attrNameLower = attrName.toLowerCase(); - - if (vKeyLower === `attribute_${attrNameLower}` || - vKeyLower === `attribute_pa_${attrNameLower}` || - vKeyLower === attrNameLower) { - + + if (vKeyLower === `attribute_${attrNameLower}` || + vKeyLower === `attribute_pa_${attrNameLower}` || + vKeyLower === attrNameLower) { + const varValueNormalized = String(vValue).toLowerCase().trim(); if (varValueNormalized === normalizedValue) { return true; } } } - + return false; }); }); - + setSelectedVariation(variation || null); } else if (product?.type !== 'variable') { setSelectedVariation(null); @@ -135,9 +136,9 @@ export default function Product() { // Build complete image gallery including variation images (BEFORE early returns) const allImages = React.useMemo(() => { if (!product) return []; - + const images = [...(product.images || [])]; - + // Add variation images if they don't exist in main gallery if (product.type === 'variable' && product.variations) { (product.variations as any[]).forEach(variation => { @@ -146,7 +147,7 @@ export default function Product() { } }); } - + // Filter out any falsy values (false, null, undefined, empty strings) return images.filter(img => img && typeof img === 'string' && img.trim() !== ''); }, [product]); @@ -198,8 +199,8 @@ export default function Product() { virtual: product.virtual, downloadable: product.downloadable, // Use selectedAttributes from state (user's selections) for variable products - attributes: product.type === 'variable' && Object.keys(selectedAttributes).length > 0 - ? selectedAttributes + attributes: product.type === 'variable' && Object.keys(selectedAttributes).length > 0 + ? selectedAttributes : undefined, }); @@ -257,6 +258,18 @@ export default function Product() { return ( + {/* SEO Meta Tags for Social Sharing */} + ]+>/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', + }} + />
{/* Breadcrumb */} {elements.breadcrumbs && ( @@ -297,7 +310,7 @@ export default function Product() {
)} - + {/* Dots Navigation - Show based on gallery_style */} {allImages && allImages.length > 1 && layout.gallery_style === 'dots' && (
@@ -306,18 +319,17 @@ export default function Product() {
)} - + {/* Thumbnail Slider - Show based on gallery_style */} {allImages && allImages.length > 1 && layout.gallery_style === 'thumbnails' && (
@@ -330,7 +342,7 @@ export default function Product() { )} - + {/* Scrollable Thumbnails */}
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 ${ - selectedImage === 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 ${selectedImage === img ? 'border-primary ring-4 ring-primary ring-offset-2' : 'border-gray-300 hover:border-gray-400' - }`} + }`} > ))}
- + {/* Right Arrow */} {allImages.length > 4 && ( @@ -490,17 +500,15 @@ export default function Product() { Add to Cart {isModuleEnabled('wishlist') && wishlistEnabled && ( - )} @@ -519,7 +527,7 @@ export default function Product() {

Free Shipping

On orders over $50

- + {/* Returns */}
@@ -530,7 +538,7 @@ export default function Product() {

Easy Returns

30-day guarantee

- + {/* Secure */}
@@ -562,13 +570,13 @@ export default function Product() { )}
)} - + {/* Share Buttons */} {elements.share_buttons && (
Share:
- - -
@@ -682,160 +690,160 @@ export default function Product() { {elements.reviews && reviewSettings.placement === 'product_page' && ( // Show reviews only if: 1) not hiding when empty, OR 2) has reviews (!reviewSettings.hide_if_empty || (product.review_count && product.review_count > 0)) && ( -
- - {activeTab === 'reviews' && ( -
- {/* Review Summary */} -
-
-
5.0
-
- {[1, 2, 3, 4, 5].map((star) => ( - - - - ))} -
-
Based on 128 reviews
-
-
- {[5, 4, 3, 2, 1].map((rating) => ( -
- {rating} ★ -
-
-
- {rating === 5 ? '122' : rating === 4 ? '5' : '1'} -
- ))} -
-
- - {/* Sample Reviews */} -
- {/* Review 1 */} -
-
-
- JD -
-
-
- John Doe - • 2 days ago - Verified Purchase -
-
- {[1, 2, 3, 4, 5].map((star) => ( - - - - ))} -
-

- Absolutely love this product! The quality exceeded my expectations and it arrived quickly. - The packaging was also very professional. Highly recommend! -

- +
+ -
-
-
- - {/* Review 3 */} -
-
-
- MJ -
-
-
- Michael Johnson - • 2 weeks ago - Verified Purchase -
-
- {[1, 2, 3, 4, 5].map((star) => ( - - - - ))} -
-

- Perfect! This is my third purchase and I keep coming back. The consistency in quality is impressive. - Will definitely buy again. -

- -
-
-
-
- - + {activeTab === 'reviews' && ( +
+ {/* Review Summary */} +
+
+
5.0
+
+ {[1, 2, 3, 4, 5].map((star) => ( + + + + ))} +
+
Based on 128 reviews
+
+
+ {[5, 4, 3, 2, 1].map((rating) => ( +
+ {rating} ★ +
+
+
+ {rating === 5 ? '122' : rating === 4 ? '5' : '1'} +
+ ))} +
+
+ + {/* Sample Reviews */} +
+ {/* Review 1 */} +
+
+
+ JD +
+
+
+ John Doe + • 2 days ago + Verified Purchase +
+
+ {[1, 2, 3, 4, 5].map((star) => ( + + + + ))} +
+

+ Absolutely love this product! The quality exceeded my expectations and it arrived quickly. + The packaging was also very professional. Highly recommend! +

+ +
+
+
+ + {/* Review 2 */} +
+
+
+ SM +
+
+
+ Sarah Miller + • 1 week ago + Verified Purchase +
+
+ {[1, 2, 3, 4, 5].map((star) => ( + + + + ))} +
+

+ Great value for money. Works exactly as described. Customer service was also very responsive + when I had questions before purchasing. +

+ +
+
+
+ + {/* Review 3 */} +
+
+
+ MJ +
+
+
+ Michael Johnson + • 2 weeks ago + Verified Purchase +
+
+ {[1, 2, 3, 4, 5].map((star) => ( + + + + ))} +
+

+ Perfect! This is my third purchase and I keep coming back. The consistency in quality is impressive. + Will definitely buy again. +

+ +
+
+
+
+ + +
+ )}
- )} -
- ))} + ))}
{/* Related Products */} diff --git a/customer-spa/src/pages/Shop/index.tsx b/customer-spa/src/pages/Shop/index.tsx index 2cc72e2..df08bc5 100644 --- a/customer-spa/src/pages/Shop/index.tsx +++ b/customer-spa/src/pages/Shop/index.tsx @@ -10,6 +10,7 @@ import { ProductCard } from '@/components/ProductCard'; import { toast } from 'sonner'; import { useTheme, useLayout } from '@/contexts/ThemeContext'; import { useShopSettings } from '@/hooks/useAppearanceSettings'; +import SEOHead from '@/components/SEOHead'; import type { ProductsResponse, ProductCategory, Product } from '@/types/product'; export default function Shop() { @@ -22,25 +23,25 @@ export default function Shop() { const [category, setCategory] = useState(''); const [sortBy, setSortBy] = useState(''); const { addItem } = useCartStore(); - + // Map grid columns setting to Tailwind classes (responsive) - const gridCols = typeof shopLayout.grid_columns === 'object' - ? shopLayout.grid_columns + const gridCols = typeof shopLayout.grid_columns === 'object' + ? shopLayout.grid_columns : { mobile: '2', tablet: '3', desktop: '4' }; - + // Map to actual Tailwind classes (can't use template literals due to purging) const mobileClass = { '1': 'grid-cols-1', '2': 'grid-cols-2', '3': 'grid-cols-3', }[gridCols.mobile] || 'grid-cols-2'; - + const tabletClass = { '2': 'md:grid-cols-2', '3': 'md:grid-cols-3', '4': 'md:grid-cols-4', }[gridCols.tablet] || 'md:grid-cols-3'; - + const desktopClass = { '2': 'lg:grid-cols-2', '3': 'lg:grid-cols-3', @@ -48,22 +49,22 @@ export default function Shop() { '5': 'lg:grid-cols-5', '6': 'lg:grid-cols-6', }[gridCols.desktop] || 'lg:grid-cols-4'; - + const gridColsClass = `${mobileClass} ${tabletClass} ${desktopClass}`; - + // Masonry column classes const masonryMobileClass = { '1': 'columns-1', '2': 'columns-2', '3': 'columns-3', }[gridCols.mobile] || 'columns-2'; - + const masonryTabletClass = { '2': 'md:columns-2', '3': 'md:columns-3', '4': 'md:columns-4', }[gridCols.tablet] || 'md:columns-3'; - + const masonryDesktopClass = { '2': 'lg:columns-2', '3': 'lg:columns-3', @@ -71,9 +72,9 @@ export default function Shop() { '5': 'lg:columns-5', '6': 'lg:columns-6', }[gridCols.desktop] || 'lg:columns-4'; - + const masonryColsClass = `${masonryMobileClass} ${masonryTabletClass} ${masonryDesktopClass}`; - + const isMasonry = shopLayout.grid_style === 'masonry'; // Fetch products @@ -99,7 +100,7 @@ export default function Shop() { product_id: product.id, quantity: 1, }); - + // Add to local cart store addItem({ key: `${product.id}`, @@ -111,7 +112,7 @@ export default function Shop() { virtual: product.virtual, downloadable: product.downloadable, }); - + toast.success(`${product.name} added to cart!`, { action: { label: 'View Cart', @@ -126,6 +127,11 @@ export default function Shop() { return ( + {/* SEO Meta Tags for Social Sharing */} + {/* Header */}

Shop

diff --git a/includes/Frontend/TemplateOverride.php b/includes/Frontend/TemplateOverride.php index 42de655..a41c137 100644 --- a/includes/Frontend/TemplateOverride.php +++ b/includes/Frontend/TemplateOverride.php @@ -16,10 +16,21 @@ class TemplateOverride // Register rewrite rules for BrowserRouter SEO (must be on 'init') add_action('init', [__CLASS__, 'register_spa_rewrite_rules']); - // Flush rewrite rules when appearance settings are updated - add_action('update_option_woonoow_appearance_settings', function() { - flush_rewrite_rules(); - }); + // Flush rewrite rules when relevant settings change + add_action('update_option_woonoow_appearance_settings', function($old_value, $new_value) { + $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) add_action('template_redirect', [__CLASS__, 'redirect_wc_pages_to_spa'], 5);