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:
43
customer-spa/package-lock.json
generated
43
customer-spa/package-lock.json
generated
@@ -27,6 +27,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"lucide-react": "^0.547.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -4579,6 +4580,33 @@
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.38.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
|
||||
"integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.38.0",
|
||||
"motion-utils": "^12.36.0",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -5619,6 +5647,21 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.38.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
|
||||
"integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.36.0"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.36.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
|
||||
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"lucide-react": "^0.547.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
232
customer-spa/src/components/Layout/MiniCartDrawer.tsx
Normal file
232
customer-spa/src/components/Layout/MiniCartDrawer.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Trash2, ShoppingBag, ArrowRight } from 'lucide-react';
|
||||
import { useCartStore } from '@/lib/cart/store';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
|
||||
export function MiniCartDrawer() {
|
||||
const { cart, isOpen, closeCart, updateQuantity, removeItem, setCart } = useCartStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Close cart when pressing Escape
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') closeCart();
|
||||
};
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => window.removeEventListener('keydown', handleEscape);
|
||||
}, [closeCart]);
|
||||
|
||||
// Lock body scroll when cart is open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'unset';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Update quantity via API
|
||||
const handleUpdateQuantity = async (key: string, newQuantity: number) => {
|
||||
if (newQuantity < 1) return;
|
||||
updateQuantity(key, newQuantity);
|
||||
try {
|
||||
const response = await apiClient.post<{ cart: any }>('/woonoow/v1/cart/update', {
|
||||
key,
|
||||
quantity: newQuantity,
|
||||
});
|
||||
if (response.cart) setCart(response.cart);
|
||||
} catch (error) {
|
||||
console.error('Failed to update cart', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Remove item via API
|
||||
const handleRemoveItem = async (key: string) => {
|
||||
removeItem(key);
|
||||
try {
|
||||
const response = await apiClient.post<{ cart: any }>('/woonoow/v1/cart/remove', { key });
|
||||
if (response.cart) setCart(response.cart);
|
||||
} catch (error) {
|
||||
console.error('Failed to remove item', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckout = () => {
|
||||
closeCart();
|
||||
navigate('/checkout');
|
||||
};
|
||||
|
||||
const cartTotal = cart.items.reduce((total, item) => total + item.price * item.quantity, 0);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={closeCart}
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[10000]"
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||
className="fixed inset-y-0 right-0 w-full max-w-md bg-white dark:bg-background shadow-2xl z-[10001] flex flex-col"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 sm:p-6 border-b">
|
||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||
<ShoppingBag className="w-5 h-5" />
|
||||
Your Cart
|
||||
<span className="text-sm font-normal text-muted-foreground ml-2">
|
||||
({cart.items.length} {cart.items.length === 1 ? 'item' : 'items'})
|
||||
</span>
|
||||
</h2>
|
||||
<Button variant="ghost" size="icon" onClick={closeCart} className="rounded-full">
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Cart Items */}
|
||||
<div className="flex-1 overflow-y-auto p-4 sm:p-6">
|
||||
{cart.items.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center space-y-4">
|
||||
<div className="w-20 h-20 bg-muted rounded-full flex items-center justify-center">
|
||||
<ShoppingBag className="w-10 h-10 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">Your cart is empty</h3>
|
||||
<p className="text-muted-foreground">Looks like you haven't added anything yet.</p>
|
||||
<Button onClick={closeCart} className="mt-4">
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Free Shipping Motivator (Example Placeholder) */}
|
||||
<div className="bg-primary/10 p-3 rounded-lg text-sm text-center font-medium text-primary">
|
||||
You're $15 away from <strong>Free Shipping!</strong>
|
||||
</div>
|
||||
|
||||
{cart.items.map((item) => (
|
||||
<motion.div
|
||||
key={item.key}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="flex gap-4"
|
||||
>
|
||||
{/* Image */}
|
||||
<div className="w-20 h-20 rounded-md overflow-hidden bg-muted flex-shrink-0">
|
||||
{item.image ? (
|
||||
<img src={item.image} alt={item.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
||||
<ShoppingBag className="w-8 h-8 opacity-20" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="flex flex-col justify-between flex-1">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="font-medium line-clamp-2 text-sm">{item.name}</h4>
|
||||
{item.attributes && (
|
||||
<div className="text-xs text-muted-foreground mt-1 space-y-0.5">
|
||||
{Object.entries(item.attributes).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<span className="font-medium">{key}:</span> {value}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemoveItem(item.key)}
|
||||
className="text-muted-foreground hover:text-destructive p-1"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
{/* Quantity Selector */}
|
||||
<div className="flex items-center border rounded-md">
|
||||
<button
|
||||
onClick={() => handleUpdateQuantity(item.key, item.quantity - 1)}
|
||||
className="px-2 py-1 hover:bg-muted transition-colors text-sm"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="px-2 py-1 text-sm font-medium w-8 text-center">
|
||||
{item.quantity}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleUpdateQuantity(item.key, item.quantity + 1)}
|
||||
className="px-2 py-1 hover:bg-muted transition-colors text-sm"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="font-semibold text-sm">
|
||||
{formatPrice(item.price * item.quantity)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{cart.items.length > 0 && (
|
||||
<div className="border-t p-4 sm:p-6 bg-gray-50/50 dark:bg-card">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<span className="text-sm font-medium text-muted-foreground">Subtotal</span>
|
||||
<span className="text-lg font-bold">{formatPrice(cartTotal)}</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center mb-4">
|
||||
Taxes and shipping calculated at checkout
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button onClick={handleCheckout} className="w-full py-6 text-lg" size="lg">
|
||||
Checkout <ArrowRight className="ml-2 w-5 h-5" />
|
||||
</Button>
|
||||
|
||||
{/* Express Checkout Placeholders */}
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
<Button variant="outline" className="w-full flex gap-2 border-black dark:border-border hover:bg-gray-100 dark:hover:bg-accent">
|
||||
<svg viewBox="0 0 384 512" className="w-3 h-3"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zM201.2 43.6C227.1 12.8 244 0 244 0c-4.2 32.2-18.6 60.1-41.2 81.6-21.7 21-50.6 34.6-78.6 34.6-1.5-27.1 14.1-53.7 32.6-72.6z"/></svg>
|
||||
Pay
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full flex gap-2 border-[#4285F4] text-[#4285F4] hover:bg-blue-50 dark:hover:bg-accent">
|
||||
<svg viewBox="0 0 488 512" className="w-3 h-3" fill="currentColor"><path d="M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z"/></svg>
|
||||
Pay
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ThemeToggle } from '../ThemeToggle';
|
||||
|
||||
export function MinimalHeader() {
|
||||
const storeLogo = (window as any).woonoowCustomer?.storeLogo;
|
||||
@@ -8,7 +9,8 @@ export function MinimalHeader() {
|
||||
return (
|
||||
<header className="minimal-header bg-white border-b py-4">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center">
|
||||
<div />
|
||||
<Link to="/shop" className="flex items-center gap-2">
|
||||
{storeLogo ? (
|
||||
<img
|
||||
@@ -20,6 +22,9 @@ export function MinimalHeader() {
|
||||
<span className="text-xl font-semibold text-gray-900">{storeName}</span>
|
||||
)}
|
||||
</Link>
|
||||
<div className="flex justify-end">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
28
customer-spa/src/components/ThemeToggle.tsx
Normal file
28
customer-spa/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
|
||||
interface ThemeToggleProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ThemeToggle({ className }: ThemeToggleProps) {
|
||||
const { colorMode, toggleColorMode } = useTheme();
|
||||
const isDark = colorMode === 'dark';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleColorMode}
|
||||
className={cn(
|
||||
'font-[inherit] inline-flex h-9 w-9 items-center justify-center rounded-md border border-transparent text-gray-700 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-gray-800 dark:hover:text-white',
|
||||
className,
|
||||
)}
|
||||
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,9 @@ interface ThemeContextValue {
|
||||
isFullSPA: boolean;
|
||||
isCheckoutOnly: boolean;
|
||||
isLaunchLayout: boolean;
|
||||
colorMode: 'light' | 'dark';
|
||||
setColorMode: (mode: 'light' | 'dark') => void;
|
||||
toggleColorMode: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
@@ -77,6 +80,12 @@ export function ThemeProvider({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [config, setConfig] = useState<ThemeConfig>(initialConfig);
|
||||
const [colorMode, setColorModeState] = useState<'light' | 'dark'>(() => {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
const stored = window.localStorage.getItem('woonoow_customer_theme');
|
||||
if (stored === 'light' || stored === 'dark') return stored;
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Fetch settings from API
|
||||
@@ -171,11 +180,44 @@ export function ThemeProvider({
|
||||
handleLocationChange();
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(colorMode);
|
||||
root.style.colorScheme = colorMode;
|
||||
|
||||
if (colorMode === 'dark') {
|
||||
root.style.setProperty('--color-background', '#020817');
|
||||
root.style.setProperty('--color-text', '#F8FAFC');
|
||||
root.style.setProperty('--wn-background', '#020817');
|
||||
root.style.setProperty('--wn-text', '#F8FAFC');
|
||||
return;
|
||||
}
|
||||
|
||||
root.style.setProperty('--color-background', config.colors.background || '#ffffff');
|
||||
root.style.setProperty('--color-text', config.colors.text || '#111827');
|
||||
root.style.setProperty('--wn-background', config.colors.background || '#ffffff');
|
||||
root.style.setProperty('--wn-text', config.colors.text || '#111827');
|
||||
}, [colorMode, config.colors.background, config.colors.text]);
|
||||
|
||||
const setColorMode = (mode: 'light' | 'dark') => {
|
||||
window.localStorage.setItem('woonoow_customer_theme', mode);
|
||||
setColorModeState(mode);
|
||||
};
|
||||
|
||||
const toggleColorMode = () => {
|
||||
setColorMode(colorMode === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
const contextValue: ThemeContextValue = {
|
||||
config,
|
||||
isFullSPA: config.mode === 'full',
|
||||
isCheckoutOnly: config.mode === 'checkout_only',
|
||||
isLaunchLayout: config.layout === 'launch',
|
||||
colorMode,
|
||||
setColorMode,
|
||||
toggleColorMode,
|
||||
loading,
|
||||
};
|
||||
|
||||
@@ -196,4 +238,3 @@ export function useTheme() {
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,73 @@
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
|
||||
html.dark #woonoow-customer-app {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
html.dark #woonoow-customer-app h1,
|
||||
html.dark #woonoow-customer-app h2,
|
||||
html.dark #woonoow-customer-app h3,
|
||||
html.dark #woonoow-customer-app h4,
|
||||
html.dark #woonoow-customer-app h5,
|
||||
html.dark #woonoow-customer-app h6 {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
html.dark #woonoow-customer-app .bg-white,
|
||||
html.dark #woonoow-customer-app .bg-gray-50,
|
||||
html.dark #woonoow-customer-app .bg-gray-100 {
|
||||
background-color: hsl(var(--card));
|
||||
}
|
||||
|
||||
html.dark #woonoow-customer-app .hover\:bg-gray-50:hover,
|
||||
html.dark #woonoow-customer-app .hover\:bg-gray-100:hover {
|
||||
background-color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
html.dark #woonoow-customer-app .text-gray-900,
|
||||
html.dark #woonoow-customer-app .text-gray-800,
|
||||
html.dark #woonoow-customer-app .text-gray-700 {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
html.dark #woonoow-customer-app .text-gray-600,
|
||||
html.dark #woonoow-customer-app .text-gray-500,
|
||||
html.dark #woonoow-customer-app .text-gray-400 {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
html.dark #woonoow-customer-app .border,
|
||||
html.dark #woonoow-customer-app .border-t,
|
||||
html.dark #woonoow-customer-app .border-b,
|
||||
html.dark #woonoow-customer-app .border-l,
|
||||
html.dark #woonoow-customer-app .border-r {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
html.dark #woonoow-customer-app input,
|
||||
html.dark #woonoow-customer-app textarea,
|
||||
html.dark #woonoow-customer-app select {
|
||||
color: hsl(var(--foreground));
|
||||
background-color: hsl(var(--background));
|
||||
border-color: hsl(var(--input));
|
||||
}
|
||||
|
||||
html.dark #woonoow-customer-app input::placeholder,
|
||||
html.dark #woonoow-customer-app textarea::placeholder {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
html.dark #woonoow-customer-app .prose,
|
||||
html.dark #woonoow-customer-app .prose :where(p, li, strong, em, blockquote, figcaption, td, th) {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
html.dark #woonoow-customer-app .prose :where(a) {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* Mobile-first responsive utilities */
|
||||
@layer utilities {
|
||||
.container-safe {
|
||||
@@ -101,3 +168,14 @@
|
||||
@apply min-h-[44px] min-w-[44px];
|
||||
}
|
||||
}
|
||||
|
||||
/* Marquee Banner animation */
|
||||
@keyframes marquee {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
.animate-marquee {
|
||||
animation: marquee linear infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { LayoutWrapper } from './LayoutWrapper';
|
||||
import { useModules } from '../hooks/useModules';
|
||||
import { useModuleSettings } from '../hooks/useModuleSettings';
|
||||
import { CouponURLHandler } from '../components/CouponURLHandler';
|
||||
import { MiniCartDrawer } from '../components/Layout/MiniCartDrawer';
|
||||
import { ThemeToggle } from '../components/ThemeToggle';
|
||||
|
||||
interface BaseLayoutProps {
|
||||
children: ReactNode;
|
||||
@@ -25,6 +27,7 @@ export function BaseLayout({ children }: BaseLayoutProps) {
|
||||
return (
|
||||
<>
|
||||
<CouponURLHandler />
|
||||
<MiniCartDrawer />
|
||||
{/* Map header styles to layouts */}
|
||||
{headerSettings.style === 'classic' && <ClassicLayout>{children}</ClassicLayout>}
|
||||
{headerSettings.style === 'centered' && <ModernLayout>{children}</ModernLayout>}
|
||||
@@ -40,7 +43,7 @@ export function BaseLayout({ children }: BaseLayoutProps) {
|
||||
* Classic Layout - Traditional ecommerce
|
||||
*/
|
||||
function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
const { cart } = useCartStore();
|
||||
const { cart, openCart } = useCartStore();
|
||||
const itemCount = cart.items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
const storeLogo = (window as any).woonoowCustomer?.storeLogo;
|
||||
const storeName = (window as any).woonoowCustomer?.storeName || (window as any).woonoowCustomer?.siteTitle || 'Store Title';
|
||||
@@ -54,7 +57,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
|
||||
const heightClass = headerSettings.height === 'compact' ? 'h-16' : headerSettings.height === 'tall' ? 'h-24' : 'h-20';
|
||||
const hasActions = headerSettings.elements.search || headerSettings.elements.account || headerSettings.elements.cart || headerSettings.elements.wishlist;
|
||||
const hasActions = true;
|
||||
|
||||
const footerColsClass: Record<string, string> = {
|
||||
'1': 'grid-cols-1',
|
||||
@@ -126,6 +129,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
{/* Actions - Hidden on mobile when using bottom-nav */}
|
||||
{hasActions && (
|
||||
<div className={`flex items-center gap-3 ${headerSettings.mobile_menu === 'bottom-nav' ? 'max-md:hidden' : ''}`}>
|
||||
<ThemeToggle />
|
||||
{/* Search */}
|
||||
{headerSettings.elements.search && (
|
||||
<button
|
||||
@@ -158,7 +162,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
|
||||
{/* Cart */}
|
||||
{headerSettings.elements.cart && (
|
||||
<Link to="/cart" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||
<button onClick={openCart} className="font-[inherit] flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors">
|
||||
<div className="relative">
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
{itemCount > 0 && (
|
||||
@@ -170,7 +174,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
<span className="hidden lg:block">
|
||||
Cart ({itemCount})
|
||||
</span>
|
||||
</Link>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu Toggle - Only for hamburger and slide-in */}
|
||||
@@ -243,6 +247,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
<span>Shop</span>
|
||||
</Link>
|
||||
<ThemeToggle className="flex h-auto w-auto flex-col gap-1 px-4 py-2 text-xs font-medium" />
|
||||
{headerSettings.elements.search && (
|
||||
<button
|
||||
onClick={() => setSearchOpen(true)}
|
||||
@@ -253,7 +258,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
</button>
|
||||
)}
|
||||
{headerSettings.elements.cart && (
|
||||
<Link to="/cart" className="flex flex-col items-center gap-1 px-4 py-2 text-xs font-medium text-gray-700 hover:text-gray-900 no-underline relative">
|
||||
<button onClick={openCart} className="font-[inherit] flex flex-col items-center gap-1 px-4 py-2 text-xs font-medium text-gray-700 hover:text-gray-900 relative">
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
{itemCount > 0 && (
|
||||
<span className="absolute top-1 right-2 h-4 w-4 rounded-full bg-gray-900 text-white text-xs flex items-center justify-center">
|
||||
@@ -261,7 +266,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
</span>
|
||||
)}
|
||||
<span>Cart</span>
|
||||
</Link>
|
||||
</button>
|
||||
)}
|
||||
{headerSettings.elements.account && (
|
||||
user?.isLoggedIn ? (
|
||||
@@ -398,7 +403,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
* Modern Layout - Minimalist, clean
|
||||
*/
|
||||
function ModernLayout({ children }: BaseLayoutProps) {
|
||||
const { cart } = useCartStore();
|
||||
const { cart, openCart } = useCartStore();
|
||||
const itemCount = cart.items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
const storeLogo = (window as any).woonoowCustomer?.storeLogo;
|
||||
const storeName = (window as any).woonoowCustomer?.storeName || (window as any).woonoowCustomer?.siteTitle || 'Store Title';
|
||||
@@ -411,7 +416,7 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
|
||||
const paddingClass = headerSettings.height === 'compact' ? 'py-4' : headerSettings.height === 'tall' ? 'py-8' : 'py-6';
|
||||
const hasActions = headerSettings.elements.search || headerSettings.elements.account || headerSettings.elements.cart || headerSettings.elements.wishlist;
|
||||
const hasActions = true;
|
||||
|
||||
return (
|
||||
<div className="modern-layout min-h-screen flex flex-col">
|
||||
@@ -472,6 +477,7 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
||||
<Search className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
{headerSettings.elements.account && (
|
||||
user?.isLoggedIn ? (
|
||||
<Link to="/my-account" className="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||
@@ -490,20 +496,23 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
||||
</Link>
|
||||
)}
|
||||
{headerSettings.elements.cart && (
|
||||
<Link to="/cart" className="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||
<button onClick={openCart} className="font-[inherit] flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors">
|
||||
<ShoppingCart className="h-4 w-4" /> Cart ({itemCount})
|
||||
</Link>
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu Toggle */}
|
||||
<button
|
||||
className="md:hidden mt-4 flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
{mobileMenuOpen ? <X className="h-5 w-5 text-gray-600" /> : <Menu className="h-5 w-5 text-gray-600" />}
|
||||
</button>
|
||||
<div className="md:hidden mt-4 flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<button
|
||||
className="font-[inherit] flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
{mobileMenuOpen ? <X className="h-5 w-5 text-gray-600" /> : <Menu className="h-5 w-5 text-gray-600" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
@@ -554,7 +563,7 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
||||
* Boutique Layout - Luxury, elegant
|
||||
*/
|
||||
function BoutiqueLayout({ children }: BaseLayoutProps) {
|
||||
const { cart } = useCartStore();
|
||||
const { cart, openCart } = useCartStore();
|
||||
const itemCount = cart.items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
const storeLogo = (window as any).woonoowCustomer?.storeLogo;
|
||||
const storeName = (window as any).woonoowCustomer?.storeName || (window as any).woonoowCustomer?.siteTitle || 'BOUTIQUE';
|
||||
@@ -567,7 +576,7 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
|
||||
const heightClass = headerSettings.height === 'compact' ? 'h-20' : headerSettings.height === 'tall' ? 'h-28' : 'h-24';
|
||||
const hasActions = headerSettings.elements.search || headerSettings.elements.account || headerSettings.elements.cart || headerSettings.elements.wishlist;
|
||||
const hasActions = true;
|
||||
|
||||
return (
|
||||
<div className="boutique-layout min-h-screen flex flex-col font-serif">
|
||||
@@ -630,6 +639,7 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
||||
<Search className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<ThemeToggle className="uppercase tracking-wider" />
|
||||
{headerSettings.elements.account && (user?.isLoggedIn ? (
|
||||
<Link to="/my-account" className="flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||
<User className="h-4 w-4" /> Account
|
||||
@@ -646,20 +656,23 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
||||
</Link>
|
||||
)}
|
||||
{headerSettings.elements.cart && (
|
||||
<Link to="/cart" className="flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||
<button onClick={openCart} className="font-[inherit] flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors">
|
||||
<ShoppingCart className="h-4 w-4" /> Cart ({itemCount})
|
||||
</Link>
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu Toggle */}
|
||||
<button
|
||||
className="font-[inherit] md:hidden flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
{mobileMenuOpen ? <X className="h-5 w-5 text-gray-600" /> : <Menu className="h-5 w-5 text-gray-600" />}
|
||||
</button>
|
||||
<div className="md:hidden flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<button
|
||||
className="font-[inherit] flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
{mobileMenuOpen ? <X className="h-5 w-5 text-gray-600" /> : <Menu className="h-5 w-5 text-gray-600" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -737,7 +750,8 @@ function LaunchLayout({ children }: BaseLayoutProps) {
|
||||
<div className="launch-layout min-h-screen flex flex-col bg-gray-50">
|
||||
<header className="launch-header bg-white border-b">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className={`flex items-center justify-center ${heightClass}`}>
|
||||
<div className={`grid grid-cols-[1fr_auto_1fr] items-center ${heightClass}`}>
|
||||
<div />
|
||||
{headerSettings.elements.logo && (
|
||||
<Link to="/">
|
||||
{storeLogo ? (
|
||||
@@ -756,6 +770,9 @@ function LaunchLayout({ children }: BaseLayoutProps) {
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useLocation } from 'react-router-dom';
|
||||
import { useCheckoutSettings, useThankYouSettings } from '../hooks/useAppearanceSettings';
|
||||
import { MinimalHeader } from '../components/Layout/MinimalHeader';
|
||||
import { MinimalFooter } from '../components/Layout/MinimalFooter';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
interface LayoutWrapperProps {
|
||||
children: ReactNode;
|
||||
@@ -14,6 +15,7 @@ export function LayoutWrapper({ children, header, footer }: LayoutWrapperProps)
|
||||
const location = useLocation();
|
||||
const checkoutSettings = useCheckoutSettings();
|
||||
const thankYouSettings = useThankYouSettings();
|
||||
const { colorMode } = useTheme();
|
||||
|
||||
// Determine visibility settings based on current route
|
||||
let headerVisibility = 'show';
|
||||
@@ -45,7 +47,10 @@ export function LayoutWrapper({ children, header, footer }: LayoutWrapperProps)
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="layout-wrapper min-h-screen flex flex-col" style={backgroundColor ? { backgroundColor } : undefined}>
|
||||
<div
|
||||
className="layout-wrapper min-h-screen flex flex-col"
|
||||
style={backgroundColor && colorMode !== 'dark' ? { backgroundColor } : undefined}
|
||||
>
|
||||
{renderHeader()}
|
||||
<main className="flex-1">
|
||||
{children}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
138
customer-spa/src/pages/DynamicPage/sections/ProductCarousel.tsx
Normal file
138
customer-spa/src/pages/DynamicPage/sections/ProductCarousel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
190
customer-spa/src/pages/DynamicPage/sections/ShoppableImage.tsx
Normal file
190
customer-spa/src/pages/DynamicPage/sections/ShoppableImage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,8 +189,25 @@
|
||||
* DARK MODE
|
||||
* ======================================== */
|
||||
|
||||
:root.dark {
|
||||
--color-background: #1F2937;
|
||||
--color-text: #F9FAFB;
|
||||
|
||||
/* Invert gray scale for dark mode */
|
||||
--color-gray-50: #111827;
|
||||
--color-gray-100: #1F2937;
|
||||
--color-gray-200: #374151;
|
||||
--color-gray-300: #4B5563;
|
||||
--color-gray-400: #6B7280;
|
||||
--color-gray-500: #9CA3AF;
|
||||
--color-gray-600: #D1D5DB;
|
||||
--color-gray-700: #E5E7EB;
|
||||
--color-gray-800: #F3F4F6;
|
||||
--color-gray-900: #F9FAFB;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
:root:not(.light):not(.dark) {
|
||||
--color-background: #1F2937;
|
||||
--color-text: #F9FAFB;
|
||||
|
||||
@@ -328,4 +345,4 @@ img {
|
||||
.container {
|
||||
max-width: 1536px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"allowJs": false,
|
||||
"types": [],
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
"paths": { "@/*": ["./src/*"] },
|
||||
"ignoreDeprecations": "6.0"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user