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:
Dwindi Ramadhana
2026-05-30 13:02:08 +07:00
parent e70aa1f554
commit 396ca25be4
118 changed files with 10162 additions and 3726 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,10 @@ import { ImageTextSection } from './sections/ImageTextSection';
import { FeatureGridSection } from './sections/FeatureGridSection';
import { CTABannerSection } from './sections/CTABannerSection';
import { ContactFormSection } from './sections/ContactFormSection';
import { BentoCategoryGrid } from './sections/BentoCategoryGrid';
import { ProductCarousel } from './sections/ProductCarousel';
import { ShoppableImage } from './sections/ShoppableImage';
import { MarqueeBanner } from './sections/MarqueeBanner';
// Types
interface SectionProp {
@@ -83,6 +87,14 @@ const SECTION_COMPONENTS: Record<string, React.ComponentType<any>> = {
'cta_banner': CTABannerSection,
'contact-form': ContactFormSection,
'contact_form': ContactFormSection,
'bento-category-grid': BentoCategoryGrid,
'bento_category_grid': BentoCategoryGrid,
'product-carousel': ProductCarousel,
'product_carousel': ProductCarousel,
'shoppable-image': ShoppableImage,
'shoppable_image': ShoppableImage,
'marquee-banner': MarqueeBanner,
'marquee_banner': MarqueeBanner,
};
/**
@@ -106,6 +118,10 @@ function flattenSectionProps(props: Record<string, any>): Record<string, any> {
}
}
if (flattened.items === undefined && flattened.features !== undefined) {
flattened.items = flattened.features;
}
return flattened;
}

View File

@@ -0,0 +1,140 @@
import { Link } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { getSectionBackground } from '@/lib/sectionStyles';
interface BentoItem {
label: string;
image?: string;
url?: string;
size?: 'small' | 'medium' | 'large' | 'tall';
}
interface BentoCategoryGridProps {
id: string;
title?: string;
items?: BentoItem[];
styles?: Record<string, any>;
elementStyles?: Record<string, any>;
}
// Default demo categories if no items are provided
const DEMO_ITEMS: BentoItem[] = [
{ label: 'New Arrivals', size: 'large' },
{ label: 'Best Sellers', size: 'medium' },
{ label: 'On Sale', size: 'small' },
{ label: 'Accessories', size: 'small' },
{ label: 'Collections', size: 'tall' },
];
// Map size variants to grid span classes
const SIZE_CLASSES: Record<string, string> = {
large: 'col-span-2 row-span-2',
medium: 'col-span-2 row-span-1',
tall: 'col-span-1 row-span-2',
small: 'col-span-1 row-span-1',
};
const HEIGHT_CLASSES: Record<string, string> = {
large: 'min-h-[280px] md:min-h-[340px]',
medium: 'min-h-[160px] md:min-h-[180px]',
tall: 'min-h-[280px] md:min-h-[340px]',
small: 'min-h-[140px] md:min-h-[160px]',
};
// Colour palette cycling through for items without images
const COLOURS = [
'from-violet-600 to-indigo-700',
'from-rose-500 to-pink-600',
'from-amber-500 to-orange-600',
'from-emerald-500 to-teal-600',
'from-sky-500 to-blue-600',
];
export function BentoCategoryGrid({
id,
title,
items,
styles,
elementStyles,
}: BentoCategoryGridProps) {
const sectionBg = getSectionBackground(styles);
// Keep initial demo layout stable: merge configured items over demo items by index.
// This prevents the preview grid from "collapsing" when the first item is added.
const displayItems: BentoItem[] = (() => {
if (!items || items.length === 0) return DEMO_ITEMS;
return DEMO_ITEMS.map((demo, idx) => {
const configured = items[idx];
return configured ? { ...demo, ...configured } : demo;
});
})();
return (
<section
id={id}
className="wn-section wn-bento-grid py-12 md:py-16"
style={sectionBg.style}
>
<div className="container mx-auto px-4 max-w-7xl">
{title && (
<h2
className="text-3xl md:text-4xl font-bold mb-8"
style={{ color: elementStyles?.title?.color }}
>
{title}
</h2>
)}
{/* Bento grid — 4-column on desktop, 2-column on mobile */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 auto-rows-auto">
{displayItems.map((item, idx) => {
const size = item.size || 'small';
const gradientClass = COLOURS[idx % COLOURS.length];
const inner = (
<div
className={cn(
'relative overflow-hidden rounded-2xl group cursor-pointer',
HEIGHT_CLASSES[size],
)}
>
{/* Background */}
{item.image ? (
<img
src={item.image}
alt={item.label}
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
) : (
<div className={cn('absolute inset-0 bg-gradient-to-br', gradientClass)} />
)}
{/* Overlay */}
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/30 transition-colors duration-300" />
{/* Label */}
<div className="absolute inset-0 flex items-end p-5">
<span className="text-white font-bold text-lg md:text-xl drop-shadow-lg">
{item.label}
</span>
</div>
</div>
);
return (
<div key={idx} className={cn(SIZE_CLASSES[size])}>
{item.url ? (
<Link to={item.url} className="block h-full">
{inner}
</Link>
) : (
inner
)}
</div>
);
})}
</div>
</div>
</section>
);
}

View File

@@ -41,7 +41,9 @@ export function FeatureGridSection({
};
const customPadding = styles?.paddingTop || styles?.paddingBottom;
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-12 md:py-20');
const listItems = items.length > 0 ? items : features;
const safeItems = Array.isArray(items) ? items : [];
const safeFeatures = Array.isArray(features) ? features : [];
const listItems = safeItems.length > 0 ? safeItems : safeFeatures;
const gridCols = {
'grid-2': 'md:grid-cols-2',

View File

@@ -0,0 +1,52 @@
import { useEffect, useRef } from 'react';
import { cn } from '@/lib/utils';
import { getSectionBackground } from '@/lib/sectionStyles';
interface MarqueeBannerProps {
id: string;
text?: string;
speed?: number; // seconds for one full cycle
separator?: string;
styles?: Record<string, any>;
}
export function MarqueeBanner({
id,
text = 'Free shipping on orders over $50 ✦ New arrivals every week ✦ Limited time deals',
speed = 30,
separator = '✦',
styles,
}: MarqueeBannerProps) {
const sectionBg = getSectionBackground(styles);
const items = text.split(separator).map(t => t.trim()).filter(Boolean);
return (
<section
id={id}
className="wn-section wn-marquee overflow-hidden py-3"
style={{
backgroundColor: sectionBg.style?.backgroundColor || 'var(--wn-primary, #1a1a1a)',
color: sectionBg.style?.color || '#fff',
}}
>
<div className="flex whitespace-nowrap">
{/* Duplicate twice for seamless infinite scroll */}
{[0, 1].map((i) => (
<div
key={i}
className={cn('flex items-center gap-8 pr-8 shrink-0', 'animate-marquee')}
style={{ animationDuration: `${speed}s` }}
aria-hidden={i === 1}
>
{items.map((item, idx) => (
<span key={idx} className="flex items-center gap-8 text-sm font-medium tracking-wide uppercase">
{item}
{idx < items.length - 1 && <span className="opacity-50 text-xs"></span>}
</span>
))}
</div>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,138 @@
import { useRef } from 'react';
import { Link } from 'react-router-dom';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api/client';
import { ProductCard } from '@/components/ProductCard';
import { cn } from '@/lib/utils';
import { getSectionBackground } from '@/lib/sectionStyles';
import type { ProductsResponse } from '@/types/product';
interface ProductCarouselProps {
id: string;
title?: string;
subtitle?: string;
cta_text?: string;
cta_url?: string;
/** 'trending' | 'new' | 'on_sale' | 'featured' — maps to a query param */
source?: string;
/** Explicit product IDs to display */
product_ids?: number[];
/** Category ID to filter */
category_id?: number;
limit?: number;
styles?: Record<string, any>;
elementStyles?: Record<string, any>;
}
export function ProductCarousel({
id,
title = 'Trending Now',
subtitle,
cta_text,
cta_url,
source = 'trending',
product_ids,
category_id,
limit = 8,
styles,
elementStyles,
}: ProductCarouselProps) {
const trackRef = useRef<HTMLDivElement>(null);
const sectionBg = getSectionBackground(styles);
// Build query params
const queryParams = new URLSearchParams({ per_page: String(limit) });
if (product_ids && product_ids.length > 0) {
queryParams.set('include', product_ids.join(','));
} else if (category_id) {
queryParams.set('category', String(category_id));
} else {
if (source === 'on_sale') queryParams.set('on_sale', '1');
if (source === 'featured') queryParams.set('featured', '1');
if (source === 'new') queryParams.set('orderby', 'date');
if (source === 'trending') queryParams.set('orderby', 'popularity');
}
const { data, isLoading } = useQuery<ProductsResponse>({
queryKey: ['product-carousel', id, source, product_ids, category_id, limit],
queryFn: () => apiClient.get<ProductsResponse>(`/shop/products?${queryParams}`),
staleTime: 5 * 60 * 1000,
});
const products = data?.products || [];
const scroll = (direction: 'left' | 'right') => {
if (!trackRef.current) return;
const cardWidth = trackRef.current.children[0]?.clientWidth || 280;
trackRef.current.scrollBy({ left: direction === 'left' ? -cardWidth * 2 : cardWidth * 2, behavior: 'smooth' });
};
return (
<section id={id} className="wn-section wn-product-carousel py-12 md:py-16" style={sectionBg.style}>
<div className="container mx-auto px-4 max-w-7xl">
{/* Header */}
<div className="flex items-end justify-between mb-8">
<div>
{title && (
<h2
className="text-3xl md:text-4xl font-bold"
style={{ color: elementStyles?.title?.color }}
>
{title}
</h2>
)}
{subtitle && (
<p className="text-muted-foreground mt-2" style={{ color: elementStyles?.subtitle?.color }}>
{subtitle}
</p>
)}
</div>
<div className="flex items-center gap-3">
{cta_text && cta_url && (
<Link to={cta_url} className="text-sm font-semibold hover:underline mr-4 whitespace-nowrap">
{cta_text}
</Link>
)}
{/* Arrow buttons */}
<button
onClick={() => scroll('left')}
className="hidden md:flex font-[inherit] w-10 h-10 rounded-full border border-border items-center justify-center hover:bg-muted transition-colors"
aria-label="Scroll left"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={() => scroll('right')}
className="hidden md:flex font-[inherit] w-10 h-10 rounded-full border border-border items-center justify-center hover:bg-muted transition-colors"
aria-label="Scroll right"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
{/* Scrollable Track */}
<div
ref={trackRef}
className="flex gap-4 overflow-x-auto snap-x snap-mandatory scrollbar-hide pb-2"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{isLoading
? Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="snap-start flex-shrink-0 w-52 md:w-64 animate-pulse">
<div className="aspect-square bg-muted rounded-xl mb-3" />
<div className="h-4 bg-muted rounded w-3/4 mb-2" />
<div className="h-4 bg-muted rounded w-1/2" />
</div>
))
: products.map((product) => (
<div key={product.id} className="snap-start flex-shrink-0 w-52 md:w-64">
<ProductCard product={product} />
</div>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,190 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { X, ShoppingCart, Eye } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getSectionBackground } from '@/lib/sectionStyles';
import { formatPrice } from '@/lib/currency';
import { useCartStore } from '@/lib/cart/store';
import { apiClient } from '@/lib/api/client';
import { toast } from 'sonner';
interface Hotspot {
/** 0-100 percentage from left */
x: number;
/** 0-100 percentage from top */
y: number;
product_id: number;
product_name?: string;
product_slug?: string;
product_price?: string;
product_image?: string;
}
interface ShoppableImageProps {
id: string;
image?: string;
alt?: string;
title?: string;
subtitle?: string;
hotspots?: Hotspot[];
styles?: Record<string, any>;
elementStyles?: Record<string, any>;
}
// Demo hotspots shown when no data is configured
const DEMO_HOTSPOTS: Hotspot[] = [
{ x: 30, y: 40, product_id: 0, product_name: 'Sample Product A', product_price: '29.99', product_slug: 'sample-a' },
{ x: 65, y: 60, product_id: 0, product_name: 'Sample Product B', product_price: '49.99', product_slug: 'sample-b' },
];
export function ShoppableImage({
id,
image,
alt = 'Shoppable image',
title,
subtitle,
hotspots,
styles,
elementStyles,
}: ShoppableImageProps) {
const sectionBg = getSectionBackground(styles);
const [activeHotspot, setActiveHotspot] = useState<number | null>(null);
const { addItem, openCart } = useCartStore();
const displayHotspots = (hotspots && hotspots.length > 0) ? hotspots : DEMO_HOTSPOTS;
const hasImage = !!image;
const handleAddToCart = async (hotspot: Hotspot, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!hotspot.product_id) {
toast.info('Configure this hotspot in the Admin panel');
return;
}
try {
await apiClient.post(apiClient.endpoints.cart.add, {
product_id: hotspot.product_id,
quantity: 1,
});
addItem({
key: String(hotspot.product_id),
product_id: hotspot.product_id,
name: hotspot.product_name || 'Product',
price: parseFloat(hotspot.product_price || '0'),
quantity: 1,
image: hotspot.product_image,
});
toast.success(`${hotspot.product_name} added to cart!`);
openCart();
} catch {
toast.error('Failed to add to cart');
}
};
return (
<section id={id} className="wn-section wn-shoppable-image py-12 md:py-16" style={sectionBg.style}>
<div className="container mx-auto px-4 max-w-7xl">
{(title || subtitle) && (
<div className="mb-8 text-center">
{title && (
<h2 className="text-3xl md:text-4xl font-bold" style={{ color: elementStyles?.title?.color }}>
{title}
</h2>
)}
{subtitle && (
<p className="text-muted-foreground mt-2" style={{ color: elementStyles?.subtitle?.color }}>
{subtitle}
</p>
)}
</div>
)}
{/* Image + hotspot container */}
<div className="relative inline-block w-full rounded-2xl overflow-hidden">
{hasImage ? (
<img src={image} alt={alt} className="w-full h-auto block" />
) : (
/* Placeholder gradient when no image is set */
<div className="w-full aspect-[16/9] bg-gradient-to-br from-violet-100 to-indigo-100 flex items-center justify-center">
<div className="text-center text-muted-foreground">
<Eye className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">Set an image in the page editor to activate this section</p>
</div>
</div>
)}
{/* Hotspot pins */}
{displayHotspots.map((hotspot, idx) => {
const isActive = activeHotspot === idx;
return (
<div
key={idx}
className="absolute"
style={{ left: `${hotspot.x}%`, top: `${hotspot.y}%`, transform: 'translate(-50%, -50%)' }}
>
{/* Pulsing pin */}
<button
className={cn(
'font-[inherit] relative w-8 h-8 rounded-full bg-white shadow-lg border-2 border-primary flex items-center justify-center transition-transform',
'hover:scale-110 focus:outline-none',
isActive && 'scale-110',
)}
onClick={() => setActiveHotspot(isActive ? null : idx)}
aria-label={`View ${hotspot.product_name}`}
>
{/* Ripple */}
<span className="absolute inset-0 rounded-full bg-primary/20 animate-ping" />
<span className="relative block w-3 h-3 rounded-full bg-primary" />
</button>
{/* Tooltip card */}
{isActive && (
<div
className={cn(
'absolute z-20 w-56 bg-white rounded-xl shadow-2xl border p-3',
hotspot.x > 60 ? 'right-full mr-3' : 'left-full ml-3',
hotspot.y > 60 ? 'bottom-0' : 'top-0',
)}
>
{/* Close */}
<button
className="font-[inherit] absolute top-2 right-2 text-muted-foreground hover:text-foreground"
onClick={(e) => { e.stopPropagation(); setActiveHotspot(null); }}
>
<X className="w-3 h-3" />
</button>
{hotspot.product_image && (
<img src={hotspot.product_image} alt={hotspot.product_name} className="w-full aspect-square object-cover rounded-lg mb-2" />
)}
<p className="font-semibold text-sm line-clamp-2 mb-1">{hotspot.product_name || 'Product'}</p>
{hotspot.product_price && (
<p className="text-sm font-bold mb-2">{formatPrice(hotspot.product_price)}</p>
)}
<div className="flex gap-2">
{hotspot.product_slug && (
<Link
to={`/product/${hotspot.product_slug}`}
className="flex-1 text-xs text-center py-1.5 border border-border rounded-lg hover:bg-muted transition-colors"
>
View
</Link>
)}
<button
onClick={(e) => handleAddToCart(hotspot, e)}
className="font-[inherit] flex-1 text-xs py-1.5 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity flex items-center justify-center gap-1"
>
<ShoppingCart className="w-3 h-3" /> Add
</button>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
</section>
);
}

View File

@@ -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>
);
}