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",
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,6 +129,7 @@ function App() {
|
||||
const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any;
|
||||
|
||||
return (
|
||||
<HelmetProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider config={themeConfig}>
|
||||
<RouterProvider>
|
||||
@@ -138,6 +140,7 @@ function App() {
|
||||
<Toaster position={toastPosition} richColors />
|
||||
</ThemeProvider>
|
||||
</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 { 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() {
|
||||
@@ -257,6 +258,18 @@ export default function Product() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* Breadcrumb */}
|
||||
{elements.breadcrumbs && (
|
||||
@@ -306,8 +319,7 @@ export default function Product() {
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
className={`w-2 h-2 rounded-full transition-all ${
|
||||
selectedImage === img
|
||||
className={`w-2 h-2 rounded-full transition-all ${selectedImage === img
|
||||
? 'bg-primary w-6'
|
||||
: 'bg-gray-300 hover:bg-gray-400'
|
||||
}`}
|
||||
@@ -341,8 +353,7 @@ export default function Product() {
|
||||
<button
|
||||
key={index}
|
||||
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 ${
|
||||
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'
|
||||
}`}
|
||||
@@ -434,8 +445,7 @@ export default function Product() {
|
||||
<button
|
||||
key={optIndex}
|
||||
onClick={() => handleAttributeChange(attr.name, option)}
|
||||
className={`min-w-[48px] min-h-[48px] px-5 py-3 rounded-xl border-2 font-medium transition-all ${
|
||||
isSelected
|
||||
className={`min-w-[48px] min-h-[48px] px-5 py-3 rounded-xl border-2 font-medium transition-all ${isSelected
|
||||
? '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'
|
||||
}`}
|
||||
@@ -492,14 +502,12 @@ export default function Product() {
|
||||
{isModuleEnabled('wishlist') && wishlistEnabled && (
|
||||
<button
|
||||
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 ${
|
||||
product && isInWishlist(product.id)
|
||||
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)
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<Heart className={`h-5 w-5 ${
|
||||
product && isInWishlist(product.id) ? 'fill-red-500' : ''
|
||||
<Heart className={`h-5 w-5 ${product && isInWishlist(product.id) ? 'fill-red-500' : ''
|
||||
}`} />
|
||||
{product && isInWishlist(product.id) ? 'Remove from Wishlist' : 'Add to Wishlist'}
|
||||
</button>
|
||||
|
||||
@@ -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() {
|
||||
@@ -126,6 +127,11 @@ export default function Shop() {
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{/* SEO Meta Tags for Social Sharing */}
|
||||
<SEOHead
|
||||
title="Shop"
|
||||
description="Browse our collection of products"
|
||||
/>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<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')
|
||||
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 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);
|
||||
|
||||
Reference in New Issue
Block a user