feat: Page Editor v1.0 - canonical schema, SSR parity, and migration
Major improvements to WooNooW Page Editor system: Schema & Architecture: - Canonical section schema with unified sectionSchema.ts - Normalized feature-grid to use items (not features) - Standardized default values across all section types - Schema versioning with automatic migration on read Backend (PHP): - Enhanced PlaceholderRenderer with typed output contracts - Added fallback behavior for empty/invalid dynamic sources - Added caching support for post data resolution - New SchemaMigration class for backward compatibility - New Features class for feature flags - Enhanced PageSSR with full style support - Removed controller-level special-casing for related_posts Frontend (Admin SPA): - Updated CanvasRenderer with schema-aware transformation - Enhanced InspectorPanel with canonical schema metadata - Added new section renderers Frontend (Customer SPA): - New section components: BentoCategoryGrid, MarqueeBanner, ProductCarousel, ShoppableImage - Updated FeatureGridSection for items prop contract Testing: - Add PHP tests: SchemaMigrationTest, PlaceholderRendererTest, PageSSRTest - Add TypeScript tests: schema-integration, feature-grid-regression - Add parity tests for React vs SSR content matching - Add CI script: check-schema-drift.mjs - Add VERIFICATION_CHECKLIST.md Documentation: - RELEASE_NOTES-v1.0.md with full release notes - docs/PAGE_EDITOR_SECTION_SCHEMA_V1.md - docs/PAGE_EDITOR_SSR_COVERAGE_AUDIT.md
This commit is contained in:
@@ -1,16 +1,19 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useCartStore } from '@/lib/cart/store';
|
||||
import { useProductSettings } from '@/hooks/useAppearanceSettings';
|
||||
import { useWishlist } from '@/hooks/useWishlist';
|
||||
import { useModules } from '@/hooks/useModules';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Container from '@/components/Layout/Container';
|
||||
import { ProductCard } from '@/components/ProductCard';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
import { ShoppingCart, Minus, Plus, ArrowLeft, ChevronLeft, ChevronRight, Heart } from 'lucide-react';
|
||||
import { ShoppingCart, Minus, Plus, ArrowLeft, ChevronLeft, ChevronRight, Heart, Play } from 'lucide-react';
|
||||
|
||||
import { toast } from 'sonner';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
import type { Product as ProductType, ProductsResponse } from '@/types/product';
|
||||
@@ -25,23 +28,26 @@ export default function Product() {
|
||||
const [selectedVariation, setSelectedVariation] = useState<any>(null);
|
||||
const [selectedAttributes, setSelectedAttributes] = useState<Record<string, string>>({});
|
||||
const thumbnailsRef = useRef<HTMLDivElement>(null);
|
||||
const addToCartRef = useRef<HTMLDivElement>(null);
|
||||
const [showStickyCTA, setShowStickyCTA] = useState(false);
|
||||
const { addItem } = useCartStore();
|
||||
const { isEnabled: wishlistEnabled, isInWishlist, toggleWishlist } = useWishlist();
|
||||
const { isEnabled: isModuleEnabled } = useModules();
|
||||
const { colorMode } = useTheme();
|
||||
|
||||
// Apply white background to <main> in flat mode so the full viewport width is white
|
||||
useEffect(() => {
|
||||
const main = document.querySelector('main');
|
||||
if (!main) return;
|
||||
if (layout.layout_style === 'flat') {
|
||||
(main as HTMLElement).style.backgroundColor = '#ffffff';
|
||||
(main as HTMLElement).style.backgroundColor = colorMode === 'dark' ? 'hsl(var(--background))' : '#ffffff';
|
||||
} else {
|
||||
(main as HTMLElement).style.backgroundColor = '';
|
||||
}
|
||||
return () => {
|
||||
(main as HTMLElement).style.backgroundColor = '';
|
||||
};
|
||||
}, [layout.layout_style]);
|
||||
}, [layout.layout_style, colorMode]);
|
||||
|
||||
// Fetch product details by slug
|
||||
const { data: product, isLoading, error } = useQuery<ProductType | null>({
|
||||
@@ -182,6 +188,7 @@ export default function Product() {
|
||||
}, [selectedVariation]);
|
||||
|
||||
// Build complete image gallery including variation images (BEFORE early returns)
|
||||
// Also includes a video sentinel '__video__' when a video_url is set
|
||||
const allImages = React.useMemo(() => {
|
||||
if (!product) return [];
|
||||
|
||||
@@ -196,10 +203,17 @@ export default function Product() {
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out any falsy values (false, null, undefined, empty strings)
|
||||
return images.filter(img => img && typeof img === 'string' && img.trim() !== '');
|
||||
const filtered = images.filter(img => img && typeof img === 'string' && img.trim() !== '');
|
||||
|
||||
// Append a video sentinel so the thumbnail strip shows a video slot
|
||||
if ((product as any).video_url) {
|
||||
filtered.push('__video__');
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [product]);
|
||||
|
||||
|
||||
// Scroll thumbnails
|
||||
const scrollThumbnails = (direction: 'left' | 'right') => {
|
||||
if (thumbnailsRef.current) {
|
||||
@@ -211,6 +225,26 @@ export default function Product() {
|
||||
}
|
||||
};
|
||||
|
||||
// Intersection Observer for Sticky CTA
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.boundingClientRect.y < 0) {
|
||||
setShowStickyCTA(!entry.isIntersecting);
|
||||
} else {
|
||||
setShowStickyCTA(false);
|
||||
}
|
||||
},
|
||||
{ threshold: 0, rootMargin: "-100px 0px 0px 0px" }
|
||||
);
|
||||
|
||||
if (addToCartRef.current) {
|
||||
observer.observe(addToCartRef.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [product]);
|
||||
|
||||
const handleAttributeChange = (attributeName: string, value: string) => {
|
||||
setSelectedAttributes(prev => ({
|
||||
...prev,
|
||||
@@ -361,9 +395,43 @@ export default function Product() {
|
||||
<div className={`grid gap-6 lg:gap-8 ${layout.image_position === 'right' ? 'lg:grid-cols-[5fr_7fr]' : 'lg:grid-cols-[7fr_5fr]'}`}>
|
||||
{/* Product Images */}
|
||||
<div className={`lg:sticky lg:top-8 lg:self-start ${layout.image_position === 'right' ? 'lg:order-2' : ''}`}>
|
||||
{/* Main Image - ENHANCED */}
|
||||
{/* Main Image / Video Viewer */}
|
||||
<div className="relative w-full aspect-square rounded-2xl overflow-hidden bg-gray-50 mb-6">
|
||||
{selectedImage ? (
|
||||
{selectedImage === '__video__' ? (
|
||||
// Video player
|
||||
(() => {
|
||||
const vid = (product as any).video_url as string;
|
||||
const vtype = (product as any).video_type as string;
|
||||
if (vtype === 'youtube') {
|
||||
const ytId = vid.match(/(?:v=|youtu\.be\/)([\w-]{11})/)?.[1];
|
||||
return ytId ? (
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${ytId}?autoplay=1`}
|
||||
allow="autoplay; encrypted-media"
|
||||
allowFullScreen
|
||||
className="w-full h-full"
|
||||
title={product.name}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
if (vtype === 'vimeo') {
|
||||
const vmId = vid.match(/vimeo\.com\/(\d+)/)?.[1];
|
||||
return vmId ? (
|
||||
<iframe
|
||||
src={`https://player.vimeo.com/video/${vmId}?autoplay=1`}
|
||||
allow="autoplay; encrypted-media"
|
||||
allowFullScreen
|
||||
className="w-full h-full"
|
||||
title={product.name}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
// mp4 / direct
|
||||
return (
|
||||
<video src={vid} controls autoPlay className="w-full h-full object-contain" />
|
||||
);
|
||||
})()
|
||||
) : selectedImage ? (
|
||||
<img
|
||||
src={selectedImage}
|
||||
alt={product.name}
|
||||
@@ -380,13 +448,14 @@ export default function Product() {
|
||||
</div>
|
||||
)}
|
||||
{/* Sale Badge on Image */}
|
||||
{isOnSale && (
|
||||
{isOnSale && selectedImage !== '__video__' && (
|
||||
<div className="absolute top-6 left-6 bg-red-500 text-white px-4 py-2 rounded-full font-bold text-xs uppercase tracking-wider shadow-xl">
|
||||
Sale
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Dots Navigation - Show based on gallery_style */}
|
||||
{allImages && allImages.length > 1 && layout.gallery_style === 'dots' && (
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
@@ -429,16 +498,23 @@ 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
|
||||
? 'border-primary ring-4 ring-primary ring-offset-2'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={img}
|
||||
alt={`${product.name} ${index + 1}`}
|
||||
className="w-full !h-full object-cover"
|
||||
/>
|
||||
{img === '__video__' ? (
|
||||
<div className="w-full h-full bg-gray-900 flex items-center justify-center">
|
||||
<Play className="w-8 h-8 text-white" fill="white" />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={img}
|
||||
alt={`${product.name} ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -539,7 +615,7 @@ export default function Product() {
|
||||
|
||||
{/* Quantity & Add to Cart */}
|
||||
{stockStatus === 'instock' && (
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="space-y-4 mb-6" ref={addToCartRef}>
|
||||
{/* Quantity Selector */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-gray-700 uppercase tracking-wider">Quantity</span>
|
||||
@@ -700,7 +776,7 @@ export default function Product() {
|
||||
}>
|
||||
<button
|
||||
onClick={() => setActiveTab(activeTab === 'description' ? '' : 'description')}
|
||||
className="w-full flex items-center justify-between p-5 hover:bg-gray-50 transition-colors bg-transparent"
|
||||
className="w-full flex items-center justify-between p-5 hover:bg-gray-50 dark:hover:bg-accent transition-colors bg-transparent"
|
||||
>
|
||||
<h2 className="text-xl font-bold text-gray-900">Product Description</h2>
|
||||
<svg
|
||||
@@ -713,7 +789,7 @@ export default function Product() {
|
||||
</svg>
|
||||
</button>
|
||||
{activeTab === 'description' && (
|
||||
<div className="p-6 bg-white">
|
||||
<div className="p-6 bg-white dark:bg-background">
|
||||
{product.description ? (
|
||||
<div
|
||||
className="prose prose-sm max-w-none"
|
||||
@@ -733,7 +809,7 @@ export default function Product() {
|
||||
}>
|
||||
<button
|
||||
onClick={() => setActiveTab(activeTab === 'additional' ? '' : 'additional')}
|
||||
className="w-full flex items-center justify-between p-5 hover:bg-gray-50 transition-colors bg-transparent"
|
||||
className="w-full flex items-center justify-between p-5 hover:bg-gray-50 dark:hover:bg-accent transition-colors bg-transparent"
|
||||
>
|
||||
<h2 className="text-xl font-bold text-gray-900">Specifications</h2>
|
||||
<svg
|
||||
@@ -746,13 +822,13 @@ export default function Product() {
|
||||
</svg>
|
||||
</button>
|
||||
{activeTab === 'additional' && (
|
||||
<div className="bg-white">
|
||||
<div className="bg-white dark:bg-background">
|
||||
{product.attributes && product.attributes.length > 0 ? (
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{product.attributes.map((attr: any, index: number) => (
|
||||
<tr key={index} className="border-b border-gray-200 last:border-0">
|
||||
<td className="py-4 px-6 font-semibold text-gray-900 bg-gray-50 w-1/3">
|
||||
<td className="py-4 px-6 font-semibold text-gray-900 bg-gray-50 dark:bg-card w-1/3">
|
||||
{attr.name}
|
||||
</td>
|
||||
<td className="py-4 px-6 text-gray-700">
|
||||
@@ -776,7 +852,7 @@ export default function Product() {
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
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 dark:bg-background hover:bg-gray-50 dark:hover:bg-accent transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-xl font-bold text-gray-900">Customer Reviews</h2>
|
||||
@@ -803,7 +879,7 @@ export default function Product() {
|
||||
</svg>
|
||||
</button>
|
||||
{activeTab === 'reviews' && (
|
||||
<div className="p-6 bg-white space-y-6">
|
||||
<div className="p-6 bg-white dark:bg-background space-y-6">
|
||||
{/* Review Summary */}
|
||||
<div className="flex items-start gap-8 pb-6 border-b">
|
||||
<div className="text-center">
|
||||
@@ -929,8 +1005,39 @@ export default function Product() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Upsells */}
|
||||
{(product as any).upsells && (product as any).upsells.length > 0 && (
|
||||
<div className="mt-12">
|
||||
<h2 className="text-2xl font-bold mb-6">You might also like</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{(product as any).upsells.map((up: any) => (
|
||||
<Link
|
||||
key={up.id}
|
||||
to={`/product/${up.slug}`}
|
||||
className="group block bg-white border rounded-xl overflow-hidden hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="aspect-square bg-gray-50 overflow-hidden">
|
||||
{up.image ? (
|
||||
<img src={up.image} alt={up.name} className="w-full h-full object-contain p-4 group-hover:scale-105 transition-transform" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-300">
|
||||
<ShoppingCart className="w-8 h-8" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<p className="font-medium text-sm line-clamp-2 mb-1">{up.name}</p>
|
||||
<p className="text-primary font-bold text-sm">{formatPrice(parseFloat(up.price))}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Related Products */}
|
||||
{elements.related_products && relatedProducts && relatedProducts.length > 0 && (
|
||||
|
||||
<div className="mt-12">
|
||||
<h2 className="text-2xl font-bold mb-6">{relatedProductsSettings.title}</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
@@ -943,34 +1050,42 @@ export default function Product() {
|
||||
</div>
|
||||
|
||||
{/* Sticky CTA Bar */}
|
||||
{layout.sticky_add_to_cart && stockStatus === 'instock' && (
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t-2 border-gray-200 p-3 shadow-2xl z-50">
|
||||
<div className="max-w-6xl mx-auto flex items-center justify-between gap-3 px-2">
|
||||
<div className="flex-1 flex flex-col justify-center min-w-0">
|
||||
{/* Show selected variation for variable products */}
|
||||
{product.type === 'variable' && Object.keys(selectedAttributes).length > 0 && (
|
||||
<div className="text-xs text-gray-600 mb-1 flex items-center gap-1 flex-wrap">
|
||||
{Object.entries(selectedAttributes).map(([key, value], index) => (
|
||||
<span key={key} className="inline-flex items-center">
|
||||
<span className="font-medium">{value}</span>
|
||||
{index < Object.keys(selectedAttributes).length - 1 && <span className="mx-1">•</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xl font-bold text-gray-900">{formatPrice(currentPrice)}</div>
|
||||
<AnimatePresence>
|
||||
{showStickyCTA && layout.sticky_add_to_cart && stockStatus === 'instock' && (
|
||||
<motion.div
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||
className="fixed bottom-0 left-0 right-0 bg-white border-t-2 border-gray-200 p-3 shadow-2xl z-50"
|
||||
>
|
||||
<div className="max-w-6xl mx-auto flex items-center justify-between gap-3 px-2">
|
||||
<div className="flex-1 flex flex-col justify-center min-w-0">
|
||||
{/* Show selected variation for variable products */}
|
||||
{product.type === 'variable' && Object.keys(selectedAttributes).length > 0 && (
|
||||
<div className="text-xs text-gray-600 mb-1 flex items-center gap-1 flex-wrap">
|
||||
{Object.entries(selectedAttributes).map(([key, value], index) => (
|
||||
<span key={key} className="inline-flex items-center">
|
||||
<span className="font-medium">{value}</span>
|
||||
{index < Object.keys(selectedAttributes).length - 1 && <span className="mx-1">•</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xl font-bold text-gray-900">{formatPrice(currentPrice)}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className="flex-shrink-0 h-12 px-6 flex items-center justify-center gap-2 bg-gray-900 text-white rounded-xl font-semibold hover:bg-gray-800 transition-all shadow-lg"
|
||||
>
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
<span className="hidden xs:inline">Add to Cart</span>
|
||||
<span className="xs:hidden">Add</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className="flex-shrink-0 h-12 px-6 flex items-center justify-center gap-2 bg-gray-900 text-white rounded-xl font-semibold hover:bg-gray-800 transition-all shadow-lg"
|
||||
>
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
<span className="hidden xs:inline">Add to Cart</span>
|
||||
<span className="xs:hidden">Add</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user