feat: implement header/footer visibility controls for checkout and thankyou pages
- Created LayoutWrapper component to conditionally render header/footer based on route - Created MinimalHeader component (logo only) - Created MinimalFooter component (trust badges + policy links) - Created usePageVisibility hook to get visibility settings per page - Wrapped ClassicLayout with LayoutWrapper for conditional rendering - Header/footer visibility now controlled directly in React SPA - Settings: show/minimal/hide for both header and footer - Background color support for checkout and thankyou pages
This commit is contained in:
@@ -12,6 +12,7 @@ import Shop from './pages/Shop';
|
||||
import Product from './pages/Product';
|
||||
import Cart from './pages/Cart';
|
||||
import Checkout from './pages/Checkout';
|
||||
import ThankYou from './pages/ThankYou';
|
||||
import Account from './pages/Account';
|
||||
|
||||
// Create QueryClient instance
|
||||
@@ -61,7 +62,7 @@ function App() {
|
||||
{/* Cart & Checkout */}
|
||||
<Route path="/cart" element={<Cart />} />
|
||||
<Route path="/checkout" element={<Checkout />} />
|
||||
<Route path="/order-received/:orderId" element={<div>Thank You Page</div>} />
|
||||
<Route path="/order-received/:orderId" element={<ThankYou />} />
|
||||
|
||||
{/* My Account */}
|
||||
<Route path="/my-account/*" element={<Account />} />
|
||||
|
||||
@@ -1,91 +1,197 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Facebook, Instagram, Twitter, Youtube, Mail } from 'lucide-react';
|
||||
|
||||
export default function Footer() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
// Get logo and store name from WordPress global
|
||||
const storeLogo = (window as any).woonoowCustomer?.storeLogo;
|
||||
const storeName = (window as any).woonoowCustomer?.storeName || 'My Wordpress Store';
|
||||
|
||||
return (
|
||||
<footer className="border-t bg-muted/50 mt-auto">
|
||||
<div className="container-safe py-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
{/* About */}
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">About</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Modern e-commerce experience powered by WooNooW.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Shop */}
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">Shop</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>
|
||||
<Link to="/" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
All Products
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/cart" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
Shopping Cart
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/checkout" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
Checkout
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Account */}
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">Account</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>
|
||||
<Link to="/account" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
My Account
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/account/orders" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
Order History
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/account/profile" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
Profile Settings
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Support */}
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">Support</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li>
|
||||
<a href="#" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
Contact Us
|
||||
<footer className="bg-gray-50 border-t border-gray-200 mt-auto">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Main Footer Content */}
|
||||
<div className="py-12 lg:py-16">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-8 lg:gap-12">
|
||||
{/* Brand & Description */}
|
||||
<div className="lg:col-span-2">
|
||||
<Link to="/" className="flex items-center gap-3 mb-4">
|
||||
{storeLogo ? (
|
||||
<img src={storeLogo} alt={storeName} className="h-10 w-auto" />
|
||||
) : (
|
||||
<>
|
||||
<div className="w-10 h-10 bg-gray-900 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-xl">W</span>
|
||||
</div>
|
||||
<span className="text-xl font-serif font-light text-gray-900">
|
||||
{storeName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
<p className="text-sm text-gray-600 leading-relaxed mb-6 max-w-sm">
|
||||
Your store description here. Modern e-commerce experience with quality products and exceptional customer service.
|
||||
</p>
|
||||
|
||||
{/* Social Media */}
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="#"
|
||||
className="w-10 h-10 rounded-full bg-white border border-gray-200 flex items-center justify-center hover:bg-gray-900 hover:text-white transition-all"
|
||||
>
|
||||
<Facebook className="h-4 w-4" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
Shipping Info
|
||||
<a
|
||||
href="#"
|
||||
className="w-10 h-10 rounded-full bg-white border border-gray-200 flex items-center justify-center hover:bg-gray-900 hover:text-white transition-all"
|
||||
>
|
||||
<Instagram className="h-4 w-4" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
Returns
|
||||
<a
|
||||
href="#"
|
||||
className="w-10 h-10 rounded-full bg-white border border-gray-200 flex items-center justify-center hover:bg-gray-900 hover:text-white transition-all"
|
||||
>
|
||||
<Twitter className="h-4 w-4" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<a
|
||||
href="#"
|
||||
className="w-10 h-10 rounded-full bg-white border border-gray-200 flex items-center justify-center hover:bg-gray-900 hover:text-white transition-all"
|
||||
>
|
||||
<Youtube className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-4 text-sm uppercase tracking-wider">Shop</h3>
|
||||
<ul className="space-y-3">
|
||||
<li>
|
||||
<Link to="/" className="text-sm text-gray-600 hover:text-gray-900 transition-colors">
|
||||
All Products
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/shop" className="text-sm text-gray-600 hover:text-gray-900 transition-colors">
|
||||
New Arrivals
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/shop" className="text-sm text-gray-600 hover:text-gray-900 transition-colors">
|
||||
Best Sellers
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/shop" className="text-sm text-gray-600 hover:text-gray-900 transition-colors">
|
||||
Sale
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Customer Service */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-4 text-sm uppercase tracking-wider">Customer Service</h3>
|
||||
<ul className="space-y-3">
|
||||
<li>
|
||||
<Link to="/contact" className="text-sm text-gray-600 hover:text-gray-900 transition-colors">
|
||||
Contact Us
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" className="text-sm text-gray-600 hover:text-gray-900 transition-colors">
|
||||
Shipping & Returns
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" className="text-sm text-gray-600 hover:text-gray-900 transition-colors">
|
||||
FAQ
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/account" className="text-sm text-gray-600 hover:text-gray-900 transition-colors">
|
||||
My Account
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/account/orders" className="text-sm text-gray-600 hover:text-gray-900 transition-colors">
|
||||
Track Order
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Newsletter */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-4 text-sm uppercase tracking-wider">Newsletter</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Subscribe to get special offers and updates.
|
||||
</p>
|
||||
<form className="space-y-2">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
className="w-full px-4 py-2.5 pr-12 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute right-1.5 top-1.5 p-1.5 bg-gray-900 text-white rounded-md hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<Mail className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
By subscribing, you agree to our Privacy Policy.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Methods & Trust Badges */}
|
||||
<div className="py-6 border-t border-gray-200">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4 flex-wrap justify-center md:justify-start">
|
||||
<span className="text-xs text-gray-500 uppercase tracking-wider">We Accept</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 px-3 bg-white border border-gray-200 rounded flex items-center justify-center">
|
||||
<span className="text-xs font-semibold text-gray-700">VISA</span>
|
||||
</div>
|
||||
<div className="h-8 px-3 bg-white border border-gray-200 rounded flex items-center justify-center">
|
||||
<span className="text-xs font-semibold text-gray-700">MC</span>
|
||||
</div>
|
||||
<div className="h-8 px-3 bg-white border border-gray-200 rounded flex items-center justify-center">
|
||||
<span className="text-xs font-semibold text-gray-700">AMEX</span>
|
||||
</div>
|
||||
<div className="h-8 px-3 bg-white border border-gray-200 rounded flex items-center justify-center">
|
||||
<span className="text-xs font-semibold text-gray-700">PayPal</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<a href="#" className="hover:text-gray-900 transition-colors">Privacy Policy</a>
|
||||
<span>•</span>
|
||||
<a href="#" className="hover:text-gray-900 transition-colors">Terms of Service</a>
|
||||
<span>•</span>
|
||||
<a href="#" className="hover:text-gray-900 transition-colors">Sitemap</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copyright */}
|
||||
<div className="mt-8 pt-8 border-t text-center text-sm text-muted-foreground">
|
||||
<p>© {currentYear} WooNooW. All rights reserved.</p>
|
||||
<div className="py-6 border-t border-gray-200">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
© {currentYear} Your Store. All rights reserved.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Powered by <span className="font-semibold text-gray-700">WooNooW</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ShoppingCart, User, Menu, Search } from 'lucide-react';
|
||||
import { useCartStore } from '@/lib/cart/store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function Header() {
|
||||
const { cart, toggleCart } = useCartStore();
|
||||
const itemCount = cart.items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
|
||||
// Get user info from WordPress global
|
||||
const user = (window as any).woonoowCustomer?.user;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container-safe flex h-16 items-center justify-between">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<span className="text-xl font-bold">WooNooW</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex items-center gap-6">
|
||||
<Link to="/" className="text-sm font-medium hover:text-primary transition-colors">
|
||||
Shop
|
||||
</Link>
|
||||
<Link to="/cart" className="text-sm font-medium hover:text-primary transition-colors">
|
||||
Cart
|
||||
</Link>
|
||||
{user?.isLoggedIn && (
|
||||
<Link to="/account" className="text-sm font-medium hover:text-primary transition-colors">
|
||||
My Account
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Search */}
|
||||
<Button variant="ghost" size="icon" className="hidden md:flex">
|
||||
<Search className="h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
{/* Cart */}
|
||||
<Button variant="ghost" size="icon" onClick={toggleCart} className="relative">
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
{itemCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-primary text-primary-foreground text-xs flex items-center justify-center">
|
||||
{itemCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Account */}
|
||||
{user?.isLoggedIn ? (
|
||||
<Link to="/account">
|
||||
<Button variant="ghost" size="icon">
|
||||
<User className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<a href="/wp-login.php">
|
||||
<Button variant="outline" size="sm">
|
||||
Log In
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<Button variant="ghost" size="icon" className="md:hidden">
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
50
customer-spa/src/components/Layout/MinimalFooter.tsx
Normal file
50
customer-spa/src/components/Layout/MinimalFooter.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { Shield, Lock, Truck, RefreshCw } from 'lucide-react';
|
||||
|
||||
export function MinimalFooter() {
|
||||
const storeName = (window as any).woonoowCustomer?.storeName || (window as any).woonoowCustomer?.siteTitle || 'Store';
|
||||
|
||||
return (
|
||||
<footer className="minimal-footer bg-gray-50 border-t py-6">
|
||||
<div className="container mx-auto px-4">
|
||||
{/* Trust Badges */}
|
||||
<div className="flex flex-wrap justify-center gap-6 mb-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Shield className="w-4 h-4" />
|
||||
<span>Secure Checkout</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Lock className="w-4 h-4" />
|
||||
<span>SSL Encrypted</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Truck className="w-4 h-4" />
|
||||
<span>Free Shipping</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span>Easy Returns</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Policy Links */}
|
||||
<div className="flex flex-wrap justify-center gap-4 mb-4">
|
||||
<a href="/privacy-policy" className="text-sm text-gray-600 hover:text-gray-900 no-underline">
|
||||
Privacy Policy
|
||||
</a>
|
||||
<a href="/terms-of-service" className="text-sm text-gray-600 hover:text-gray-900 no-underline">
|
||||
Terms of Service
|
||||
</a>
|
||||
<a href="/refund-policy" className="text-sm text-gray-600 hover:text-gray-900 no-underline">
|
||||
Refund Policy
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Copyright */}
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
© {new Date().getFullYear()} {storeName}. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
27
customer-spa/src/components/Layout/MinimalHeader.tsx
Normal file
27
customer-spa/src/components/Layout/MinimalHeader.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export function MinimalHeader() {
|
||||
const storeLogo = (window as any).woonoowCustomer?.storeLogo;
|
||||
const storeName = (window as any).woonoowCustomer?.storeName || (window as any).woonoowCustomer?.siteTitle || 'Store';
|
||||
|
||||
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">
|
||||
<Link to="/shop" className="flex items-center gap-2">
|
||||
{storeLogo ? (
|
||||
<img
|
||||
src={storeLogo}
|
||||
alt={storeName}
|
||||
className="h-8 object-contain !max-w-[300px]"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xl font-semibold text-gray-900">{storeName}</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
70
customer-spa/src/components/NewsletterForm.tsx
Normal file
70
customer-spa/src/components/NewsletterForm.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface NewsletterFormProps {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function NewsletterForm({ description }: NewsletterFormProps) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email || !email.includes('@')) {
|
||||
toast.error('Please enter a valid email address');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
|
||||
const response = await fetch(`${apiRoot}/newsletter/subscribe`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(data.message || 'Successfully subscribed to newsletter!');
|
||||
setEmail('');
|
||||
} else {
|
||||
toast.error(data.message || 'Failed to subscribe. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Newsletter subscription error:', error);
|
||||
toast.error('An error occurred. Please try again later.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{description && <p className="text-sm text-gray-600 mb-4">{description}</p>}
|
||||
<form onSubmit={handleSubmit} className="space-y-2">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Your email"
|
||||
className="w-full px-4 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full px-4 py-2 bg-gray-900 text-white text-sm font-medium rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Subscribing...' : 'Subscribe'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { ShoppingCart, Heart } from 'lucide-react';
|
||||
import { formatPrice, formatDiscount } from '@/lib/currency';
|
||||
import { Button } from './ui/button';
|
||||
import { useLayout } from '@/contexts/ThemeContext';
|
||||
import { useShopSettings } from '@/hooks/useAppearanceSettings';
|
||||
|
||||
interface ProductCardProps {
|
||||
product: {
|
||||
@@ -22,6 +23,14 @@ interface ProductCardProps {
|
||||
|
||||
export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
||||
const { isClassic, isModern, isBoutique, isLaunch } = useLayout();
|
||||
const { layout, elements, addToCart, saleBadge, isLoading } = useShopSettings();
|
||||
|
||||
// Aspect ratio classes
|
||||
const aspectRatioClass = {
|
||||
'square': 'aspect-square',
|
||||
'portrait': 'aspect-[3/4]',
|
||||
'landscape': 'aspect-[4/3]',
|
||||
}[layout.aspect_ratio] || 'aspect-square';
|
||||
|
||||
const handleAddToCart = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -34,28 +43,79 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
||||
? formatDiscount(parseFloat(product.regular_price), parseFloat(product.sale_price))
|
||||
: null;
|
||||
|
||||
// Show skeleton while settings are loading to prevent layout shift
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="bg-gray-200 aspect-square rounded-lg mb-4" />
|
||||
<div className="h-4 bg-gray-200 rounded mb-2" />
|
||||
<div className="h-4 bg-gray-200 rounded w-2/3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine button variant and position based on settings
|
||||
const buttonVariant = addToCart.style === 'outline' ? 'outline' : addToCart.style === 'text' ? 'ghost' : 'default';
|
||||
const showButtonOnHover = addToCart.position === 'overlay';
|
||||
const buttonPosition = addToCart.position; // 'below', 'overlay', 'bottom'
|
||||
const isTextOnly = addToCart.style === 'text';
|
||||
|
||||
// Card style variations - adapt to column count
|
||||
const cardStyle = layout.card_style || 'card';
|
||||
const gridCols = parseInt(layout.grid_columns) || 3;
|
||||
|
||||
// More columns = cleaner styling
|
||||
const getCardClasses = () => {
|
||||
if (cardStyle === 'minimal') {
|
||||
return gridCols >= 4
|
||||
? 'overflow-hidden hover:opacity-90 transition-opacity'
|
||||
: 'overflow-hidden hover:opacity-80 transition-opacity border-b border-gray-100 pb-4';
|
||||
}
|
||||
if (cardStyle === 'overlay') {
|
||||
return gridCols >= 4
|
||||
? 'relative overflow-hidden group-hover:shadow-lg transition-all rounded-md'
|
||||
: 'relative overflow-hidden group-hover:shadow-xl transition-all rounded-lg bg-white';
|
||||
}
|
||||
// Default 'card' style
|
||||
return gridCols >= 4
|
||||
? 'border border-gray-200 rounded-md overflow-hidden hover:shadow-md transition-shadow bg-white'
|
||||
: 'border rounded-lg overflow-hidden hover:shadow-lg transition-shadow bg-white';
|
||||
};
|
||||
|
||||
const cardClasses = getCardClasses();
|
||||
|
||||
// Text alignment class
|
||||
const textAlignClass = {
|
||||
'left': 'text-left',
|
||||
'center': 'text-center',
|
||||
'right': 'text-right',
|
||||
}[layout.card_text_align || 'left'] || 'text-left';
|
||||
|
||||
// Classic Layout - Traditional card with border
|
||||
if (isClassic) {
|
||||
return (
|
||||
<Link to={`/product/${product.slug}`} className="group">
|
||||
<div className="border rounded-lg overflow-hidden hover:shadow-lg transition-shadow bg-white">
|
||||
<Link to={`/product/${product.slug}`} className="group h-full">
|
||||
<div className={`${cardClasses} h-full flex flex-col`}>
|
||||
{/* Image */}
|
||||
<div className="relative w-full h-64 overflow-hidden bg-gray-100" style={{ fontSize: 0 }}>
|
||||
<div className={`relative w-full overflow-hidden bg-gray-100 ${aspectRatioClass}`}>
|
||||
{product.image ? (
|
||||
<img
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
className="block w-full !h-full object-cover object-center group-hover:scale-105 transition-transform duration-300"
|
||||
className="absolute inset-0 w-full !h-full object-cover object-center group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full !h-full flex items-center justify-center text-gray-400" style={{ fontSize: '1rem' }}>
|
||||
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sale Badge */}
|
||||
{product.on_sale && discount && (
|
||||
<div className="absolute top-2 right-2 bg-red-500 text-white text-xs font-bold px-2 py-1 rounded">
|
||||
{elements.sale_badges && product.on_sale && discount && (
|
||||
<div
|
||||
className="absolute top-2 right-2 text-white text-xs font-bold px-2 py-1 rounded"
|
||||
style={{ backgroundColor: saleBadge.color }}
|
||||
>
|
||||
{discount}
|
||||
</div>
|
||||
)}
|
||||
@@ -66,16 +126,31 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
||||
<Heart className="w-4 h-4 block" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hover/Overlay Button */}
|
||||
{showButtonOnHover && (
|
||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-10 transition-all duration-300 flex items-center justify-center">
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
variant={buttonVariant}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
disabled={product.stock_status === 'outofstock'}
|
||||
>
|
||||
{!isTextOnly && addToCart.show_icon && <ShoppingCart className="w-4 h-4 mr-2" />}
|
||||
{product.stock_status === 'outofstock' ? 'Out of Stock' : 'Add to Cart'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
<div className={`p-4 flex-1 flex flex-col ${textAlignClass}`}>
|
||||
<h3 className="font-semibold text-gray-900 mb-2 line-clamp-2 group-hover:text-primary transition-colors">
|
||||
{product.name}
|
||||
</h3>
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className={`flex items-center gap-2 mb-3 ${(layout.card_text_align || 'left') === 'center' ? 'justify-center' : (layout.card_text_align || 'left') === 'right' ? 'justify-end' : ''}`}>
|
||||
{product.on_sale && product.regular_price ? (
|
||||
<>
|
||||
<span className="text-lg font-bold" style={{ color: 'var(--color-primary)' }}>
|
||||
@@ -92,15 +167,18 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add to Cart Button */}
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
className="w-full"
|
||||
disabled={product.stock_status === 'outofstock'}
|
||||
>
|
||||
<ShoppingCart className="w-4 h-4 mr-2" />
|
||||
{product.stock_status === 'outofstock' ? 'Out of Stock' : 'Add to Cart'}
|
||||
</Button>
|
||||
{/* Add to Cart Button - Below Image */}
|
||||
{!showButtonOnHover && (
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
variant={buttonVariant}
|
||||
className={`w-full mt-auto ${isTextOnly ? 'border-0 shadow-none hover:bg-transparent hover:underline' : ''}`}
|
||||
disabled={product.stock_status === 'outofstock'}
|
||||
>
|
||||
{!isTextOnly && addToCart.show_icon && <ShoppingCart className="w-4 h-4 mr-2" />}
|
||||
{product.stock_status === 'outofstock' ? 'Out of Stock' : 'Add to Cart'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -113,36 +191,43 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
||||
<Link to={`/product/${product.slug}`} className="group">
|
||||
<div className="overflow-hidden">
|
||||
{/* Image */}
|
||||
<div className="relative w-full h-64 mb-4 overflow-hidden bg-gray-50" style={{ fontSize: 0 }}>
|
||||
<div className={`relative w-full mb-4 overflow-hidden bg-gray-50 ${aspectRatioClass}`} style={{ fontSize: 0 }}>
|
||||
{product.image ? (
|
||||
<img
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
className="block w-full h-full object-cover object-center group-hover:scale-105 transition-transform duration-500"
|
||||
className="block w-full !h-full object-cover object-center group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-300" style={{ fontSize: '1rem' }}>
|
||||
<div className="w-full !h-full flex items-center justify-center text-gray-300" style={{ fontSize: '1rem' }}>
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sale Badge */}
|
||||
{product.on_sale && discount && (
|
||||
<div className="absolute top-4 left-4 bg-black text-white text-xs font-medium px-3 py-1">
|
||||
{elements.sale_badges && product.on_sale && discount && (
|
||||
<div
|
||||
className="absolute top-4 left-4 text-white text-xs font-medium px-3 py-1"
|
||||
style={{ backgroundColor: saleBadge.color }}
|
||||
>
|
||||
{discount}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover Overlay */}
|
||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-10 transition-all duration-300 flex items-center justify-center">
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
disabled={product.stock_status === 'outofstock'}
|
||||
>
|
||||
{product.stock_status === 'outofstock' ? 'Out of Stock' : 'Add to Cart'}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Hover Overlay - Only show if position is hover/overlay */}
|
||||
{showButtonOnHover && (
|
||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-10 transition-all duration-300 flex items-center justify-center">
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
variant={buttonVariant}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
disabled={product.stock_status === 'outofstock'}
|
||||
>
|
||||
{addToCart.show_icon && <ShoppingCart className="w-4 h-4 mr-2" />}
|
||||
{product.stock_status === 'outofstock' ? 'Out of Stock' : 'Add to Cart'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -152,7 +237,7 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
||||
</h3>
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="flex items-center justify-center gap-2 mb-3">
|
||||
{product.on_sale && product.regular_price ? (
|
||||
<>
|
||||
<span className="font-semibold" style={{ color: 'var(--color-primary)' }}>
|
||||
@@ -168,6 +253,35 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add to Cart Button - Below or Bottom */}
|
||||
{!showButtonOnHover && (
|
||||
<div className="flex flex-col mt-auto">
|
||||
{buttonPosition === 'below' && (
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
variant={buttonVariant}
|
||||
className="w-full"
|
||||
disabled={product.stock_status === 'outofstock'}
|
||||
>
|
||||
{!isTextOnly && addToCart.show_icon && <ShoppingCart className="w-4 h-4 mr-2" />}
|
||||
{product.stock_status === 'outofstock' ? 'Out of Stock' : 'Add to Cart'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{buttonPosition === 'bottom' && (
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
variant={buttonVariant}
|
||||
className="w-full"
|
||||
disabled={product.stock_status === 'outofstock'}
|
||||
>
|
||||
{!isTextOnly && addToCart.show_icon && <ShoppingCart className="w-4 h-4 mr-2" />}
|
||||
{product.stock_status === 'outofstock' ? 'Out of Stock' : 'Add to Cart'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -180,22 +294,25 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
||||
<Link to={`/product/${product.slug}`} className="group">
|
||||
<div className="overflow-hidden">
|
||||
{/* Image */}
|
||||
<div className="relative w-full h-80 mb-6 overflow-hidden bg-gray-50" style={{ fontSize: 0 }}>
|
||||
<div className={`relative w-full mb-6 overflow-hidden bg-gray-50 ${aspectRatioClass}`} style={{ fontSize: 0 }}>
|
||||
{product.image ? (
|
||||
<img
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
className="block w-full h-full object-cover object-center group-hover:scale-110 transition-transform duration-700"
|
||||
className="block w-full !h-full object-cover object-center group-hover:scale-110 transition-transform duration-700"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-300 font-serif" style={{ fontSize: '1rem' }}>
|
||||
<div className="w-full !h-full flex items-center justify-center text-gray-300 font-serif" style={{ fontSize: '1rem' }}>
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sale Badge */}
|
||||
{product.on_sale && discount && (
|
||||
<div className="absolute top-6 right-6 bg-white text-black text-xs font-medium px-4 py-2 tracking-wider">
|
||||
{elements.sale_badges && product.on_sale && discount && (
|
||||
<div
|
||||
className="absolute top-6 right-6 text-white text-xs font-medium px-4 py-2 tracking-wider"
|
||||
style={{ backgroundColor: saleBadge.color }}
|
||||
>
|
||||
{discount}
|
||||
</div>
|
||||
)}
|
||||
@@ -249,10 +366,10 @@ export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
||||
<img
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
className="block w-full h-full object-cover object-center"
|
||||
className="block w-full !h-full object-cover object-center"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400" style={{ fontSize: '1rem' }}>
|
||||
<div className="w-full !h-full flex items-center justify-center text-gray-400" style={{ fontSize: '1rem' }}>
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
|
||||
137
customer-spa/src/components/SearchModal.tsx
Normal file
137
customer-spa/src/components/SearchModal.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface SearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
prices: {
|
||||
price: string;
|
||||
regular_price: string;
|
||||
sale_price: string;
|
||||
};
|
||||
images: Array<{
|
||||
src: string;
|
||||
name: string;
|
||||
}>;
|
||||
permalink: string;
|
||||
}
|
||||
|
||||
export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim()) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const searchProducts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/wp-json/wc/store/products?search=${encodeURIComponent(query)}&per_page=5`
|
||||
);
|
||||
const data = await response.json();
|
||||
setResults(data);
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const debounce = setTimeout(searchProducts, 300);
|
||||
return () => clearTimeout(debounce);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-start justify-center pt-20 px-4">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
|
||||
<div className="relative w-full max-w-2xl bg-white rounded-lg shadow-2xl">
|
||||
<div className="flex items-center gap-3 p-4 border-b">
|
||||
<Search className="h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search products..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="flex-1 outline-none text-lg"
|
||||
autoFocus
|
||||
/>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-lg">
|
||||
<X className="h-5 w-5 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{loading && (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
Searching...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && query && results.length === 0 && (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
No products found for "{query}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && results.length > 0 && (
|
||||
<div className="divide-y">
|
||||
{results.map((product) => (
|
||||
<Link
|
||||
key={product.id}
|
||||
to={`/product/${product.id}`}
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-4 p-4 hover:bg-gray-50 transition-colors no-underline"
|
||||
>
|
||||
{product.images && product.images.length > 0 && (
|
||||
<img
|
||||
src={product.images[0].src}
|
||||
alt={product.name}
|
||||
className="w-16 h-16 object-cover rounded"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium text-gray-900">{product.name}</h3>
|
||||
<p className="text-sm text-gray-600">{product.prices.price}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!query && (
|
||||
<div className="p-8 text-center text-gray-400">
|
||||
Start typing to search products
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 font-[inherit]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, useEffect, ReactNode } from 'react';
|
||||
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||
|
||||
interface ThemeColors {
|
||||
primary: string;
|
||||
@@ -14,6 +14,7 @@ interface ThemeTypography {
|
||||
heading: string;
|
||||
body: string;
|
||||
};
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
interface ThemeConfig {
|
||||
@@ -28,32 +29,41 @@ interface ThemeContextValue {
|
||||
isFullSPA: boolean;
|
||||
isCheckoutOnly: boolean;
|
||||
isLaunchLayout: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
// Map our predefined font pairs to presets
|
||||
const FONT_PAIR_MAP: Record<string, string> = {
|
||||
modern: 'modern',
|
||||
editorial: 'elegant',
|
||||
friendly: 'professional',
|
||||
elegant: 'elegant',
|
||||
};
|
||||
|
||||
const TYPOGRAPHY_PRESETS = {
|
||||
professional: {
|
||||
modern: {
|
||||
heading: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
body: "'Lora', Georgia, serif",
|
||||
body: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
headingWeight: 700,
|
||||
bodyWeight: 400,
|
||||
},
|
||||
modern: {
|
||||
professional: {
|
||||
heading: "'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
body: "'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
body: "'Open Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
headingWeight: 600,
|
||||
bodyWeight: 400,
|
||||
},
|
||||
elegant: {
|
||||
heading: "'Playfair Display', Georgia, serif",
|
||||
body: "'Source Sans Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
body: "'Source Sans 3', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
headingWeight: 700,
|
||||
bodyWeight: 400,
|
||||
},
|
||||
tech: {
|
||||
heading: "'Space Grotesk', monospace",
|
||||
body: "'IBM Plex Mono', monospace",
|
||||
heading: "'Cormorant Garamond', Georgia, serif",
|
||||
body: "'Lato', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
headingWeight: 700,
|
||||
bodyWeight: 400,
|
||||
},
|
||||
@@ -112,12 +122,62 @@ function generateColorShades(baseColor: string): Record<number, string> {
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
config,
|
||||
config: initialConfig,
|
||||
children
|
||||
}: {
|
||||
config: ThemeConfig;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [config, setConfig] = useState<ThemeConfig>(initialConfig);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Fetch settings from API
|
||||
useEffect(() => {
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
|
||||
const response = await fetch(`${apiRoot}/appearance/settings`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const settings = data.data;
|
||||
|
||||
if (settings?.general) {
|
||||
const general = settings.general;
|
||||
|
||||
// Map API settings to theme config
|
||||
const mappedPreset = FONT_PAIR_MAP[general.typography?.predefined_pair] || 'modern';
|
||||
const newConfig: ThemeConfig = {
|
||||
mode: general.spa_mode || 'full',
|
||||
layout: 'modern', // Keep existing layout for now
|
||||
colors: {
|
||||
primary: general.colors?.primary || '#3B82F6',
|
||||
secondary: general.colors?.secondary || '#8B5CF6',
|
||||
accent: general.colors?.accent || '#10B981',
|
||||
background: general.colors?.background || '#ffffff',
|
||||
text: general.colors?.text || '#111827',
|
||||
},
|
||||
typography: {
|
||||
preset: mappedPreset as 'professional' | 'modern' | 'elegant' | 'tech' | 'custom',
|
||||
scale: general.typography?.scale || 1.0,
|
||||
},
|
||||
};
|
||||
|
||||
setConfig(newConfig);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch appearance settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
@@ -142,8 +202,13 @@ export function ThemeProvider({
|
||||
root.style.setProperty('--font-weight-body', typoPreset.bodyWeight.toString());
|
||||
}
|
||||
|
||||
// Load Google Fonts
|
||||
loadTypography(config.typography.preset, config.typography.customFonts);
|
||||
// Apply font scale
|
||||
if (config.typography.scale) {
|
||||
root.style.setProperty('--font-scale', config.typography.scale.toString());
|
||||
}
|
||||
|
||||
// We're using self-hosted fonts now, no need to load from Google
|
||||
// loadTypography(config.typography.preset, config.typography.customFonts);
|
||||
|
||||
// Add layout class to body
|
||||
document.body.classList.remove('layout-classic', 'layout-modern', 'layout-boutique', 'layout-launch');
|
||||
@@ -159,6 +224,7 @@ export function ThemeProvider({
|
||||
isFullSPA: config.mode === 'full',
|
||||
isCheckoutOnly: config.mode === 'checkout_only',
|
||||
isLaunchLayout: config.layout === 'launch',
|
||||
loading,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
296
customer-spa/src/hooks/useAppearanceSettings.ts
Normal file
296
customer-spa/src/hooks/useAppearanceSettings.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
interface AppearanceSettings {
|
||||
general: {
|
||||
spa_mode: string;
|
||||
typography: any;
|
||||
colors: any;
|
||||
};
|
||||
header: any;
|
||||
footer: any;
|
||||
pages: {
|
||||
shop: {
|
||||
layout: {
|
||||
grid_columns: string;
|
||||
card_style: string;
|
||||
aspect_ratio: string;
|
||||
};
|
||||
elements: {
|
||||
category_filter: boolean;
|
||||
search_bar: boolean;
|
||||
sort_dropdown: boolean;
|
||||
sale_badges: boolean;
|
||||
quick_view: boolean;
|
||||
};
|
||||
sale_badge: {
|
||||
color: string;
|
||||
};
|
||||
add_to_cart: {
|
||||
position: 'below' | 'hover' | 'overlay';
|
||||
style: 'solid' | 'outline' | 'ghost';
|
||||
show_icon: boolean;
|
||||
};
|
||||
};
|
||||
product: any;
|
||||
cart: any;
|
||||
checkout: any;
|
||||
thankyou: any;
|
||||
account: any;
|
||||
};
|
||||
}
|
||||
|
||||
export function useAppearanceSettings() {
|
||||
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
|
||||
|
||||
// Get preloaded settings from window object
|
||||
const preloadedSettings = (window as any).woonoowCustomer?.appearanceSettings;
|
||||
|
||||
return useQuery<AppearanceSettings>({
|
||||
queryKey: ['appearance-settings'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${apiRoot}/appearance/settings`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch appearance settings');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
},
|
||||
initialData: preloadedSettings,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useShopSettings() {
|
||||
const { data, isLoading } = useAppearanceSettings();
|
||||
|
||||
const defaultSettings = {
|
||||
layout: {
|
||||
grid_columns: '3' as string,
|
||||
grid_style: 'standard' as string,
|
||||
card_style: 'card' as string,
|
||||
aspect_ratio: 'square' as string,
|
||||
card_text_align: 'left' as string,
|
||||
},
|
||||
elements: {
|
||||
category_filter: true,
|
||||
search_bar: true,
|
||||
sort_dropdown: true,
|
||||
sale_badges: true,
|
||||
quick_view: false,
|
||||
},
|
||||
saleBadge: {
|
||||
color: '#ef4444' as string,
|
||||
},
|
||||
addToCart: {
|
||||
position: 'below' as string,
|
||||
style: 'solid' as string,
|
||||
show_icon: true,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
layout: { ...defaultSettings.layout, ...(data?.pages?.shop?.layout || {}) },
|
||||
elements: { ...defaultSettings.elements, ...(data?.pages?.shop?.elements || {}) },
|
||||
saleBadge: { ...defaultSettings.saleBadge, ...(data?.pages?.shop?.sale_badge || {}) } as { color: string },
|
||||
addToCart: { ...defaultSettings.addToCart, ...(data?.pages?.shop?.add_to_cart || {}) } as { position: string; style: string; show_icon: boolean },
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export function useProductSettings() {
|
||||
const { data, isLoading } = useAppearanceSettings();
|
||||
|
||||
const defaultSettings = {
|
||||
layout: {
|
||||
image_position: 'left' as string,
|
||||
gallery_style: 'thumbnails' as string,
|
||||
sticky_add_to_cart: false,
|
||||
},
|
||||
elements: {
|
||||
breadcrumbs: true,
|
||||
related_products: true,
|
||||
reviews: true,
|
||||
share_buttons: false,
|
||||
product_meta: true,
|
||||
},
|
||||
related_products: {
|
||||
title: 'You May Also Like' as string,
|
||||
},
|
||||
reviews: {
|
||||
placement: 'product_page' as string,
|
||||
hide_if_empty: true,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
layout: { ...defaultSettings.layout, ...(data?.pages?.product?.layout || {}) },
|
||||
elements: { ...defaultSettings.elements, ...(data?.pages?.product?.elements || {}) },
|
||||
related_products: { ...defaultSettings.related_products, ...(data?.pages?.product?.related_products || {}) },
|
||||
reviews: { ...defaultSettings.reviews, ...(data?.pages?.product?.reviews || {}) },
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCartSettings() {
|
||||
const { data, isLoading } = useAppearanceSettings();
|
||||
|
||||
const defaultSettings = {
|
||||
layout: {
|
||||
style: 'fullwidth' as string,
|
||||
summary_position: 'right' as string,
|
||||
},
|
||||
elements: {
|
||||
product_images: true,
|
||||
continue_shopping_button: true,
|
||||
coupon_field: true,
|
||||
shipping_calculator: false,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
layout: { ...defaultSettings.layout, ...(data?.pages?.cart?.layout || {}) },
|
||||
elements: { ...defaultSettings.elements, ...(data?.pages?.cart?.elements || {}) },
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCheckoutSettings() {
|
||||
const { data, isLoading } = useAppearanceSettings();
|
||||
|
||||
const defaultSettings = {
|
||||
layout: {
|
||||
style: 'two-column' as string,
|
||||
order_summary: 'sidebar' as string,
|
||||
header_visibility: 'minimal' as string,
|
||||
footer_visibility: 'minimal' as string,
|
||||
background_color: '#f9fafb' as string,
|
||||
},
|
||||
elements: {
|
||||
order_notes: true,
|
||||
coupon_field: true,
|
||||
shipping_options: true,
|
||||
payment_icons: true,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
layout: { ...defaultSettings.layout, ...(data?.pages?.checkout?.layout || {}) },
|
||||
elements: { ...defaultSettings.elements, ...(data?.pages?.checkout?.elements || {}) },
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export function useThankYouSettings() {
|
||||
const { data, isLoading } = useAppearanceSettings();
|
||||
|
||||
const defaultSettings = {
|
||||
template: 'basic',
|
||||
header_visibility: 'show',
|
||||
footer_visibility: 'minimal',
|
||||
background_color: '#f9fafb',
|
||||
custom_message: 'Thank you for your order! We\'ll send you a confirmation email shortly.',
|
||||
elements: {
|
||||
order_details: true,
|
||||
continue_shopping_button: true,
|
||||
related_products: false,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
template: data?.pages?.thankyou?.template || defaultSettings.template,
|
||||
headerVisibility: data?.pages?.thankyou?.header_visibility || defaultSettings.header_visibility,
|
||||
footerVisibility: data?.pages?.thankyou?.footer_visibility || defaultSettings.footer_visibility,
|
||||
backgroundColor: data?.pages?.thankyou?.background_color || defaultSettings.background_color,
|
||||
customMessage: data?.pages?.thankyou?.custom_message || defaultSettings.custom_message,
|
||||
elements: { ...defaultSettings.elements, ...(data?.pages?.thankyou?.elements || {}) },
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAccountSettings() {
|
||||
const { data, isLoading } = useAppearanceSettings();
|
||||
|
||||
const defaultSettings = {
|
||||
layout: {
|
||||
navigation_style: 'sidebar' as string,
|
||||
},
|
||||
elements: {
|
||||
dashboard: true,
|
||||
orders: true,
|
||||
downloads: false,
|
||||
addresses: true,
|
||||
account_details: true,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
layout: { ...defaultSettings.layout, ...(data?.pages?.account?.layout || {}) },
|
||||
elements: { ...defaultSettings.elements, ...(data?.pages?.account?.elements || {}) },
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export function useHeaderSettings() {
|
||||
const { data, isLoading } = useAppearanceSettings();
|
||||
|
||||
return {
|
||||
style: data?.header?.style ?? 'classic',
|
||||
sticky: data?.header?.sticky ?? true,
|
||||
height: data?.header?.height ?? 'normal',
|
||||
mobile_menu: data?.header?.mobile_menu ?? 'hamburger',
|
||||
mobile_logo: data?.header?.mobile_logo ?? 'left',
|
||||
logo_width: data?.header?.logo_width ?? 'auto',
|
||||
logo_height: data?.header?.logo_height ?? '40px',
|
||||
elements: {
|
||||
logo: data?.header?.elements?.logo ?? true,
|
||||
navigation: data?.header?.elements?.navigation ?? true,
|
||||
search: data?.header?.elements?.search ?? true,
|
||||
account: data?.header?.elements?.account ?? true,
|
||||
cart: data?.header?.elements?.cart ?? true,
|
||||
wishlist: data?.header?.elements?.wishlist ?? false,
|
||||
},
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export function useFooterSettings() {
|
||||
const { data, isLoading } = useAppearanceSettings();
|
||||
|
||||
return {
|
||||
columns: data?.footer?.columns ?? '4',
|
||||
style: data?.footer?.style ?? 'detailed',
|
||||
copyright_text: data?.footer?.copyright_text ?? '© 2024 WooNooW. All rights reserved.',
|
||||
elements: {
|
||||
newsletter: data?.footer?.elements?.newsletter ?? true,
|
||||
social: data?.footer?.elements?.social ?? true,
|
||||
payment: data?.footer?.elements?.payment ?? true,
|
||||
copyright: data?.footer?.elements?.copyright ?? true,
|
||||
menu: data?.footer?.elements?.menu ?? true,
|
||||
contact: data?.footer?.elements?.contact ?? true,
|
||||
},
|
||||
social_links: data?.footer?.social_links ?? [],
|
||||
sections: data?.footer?.sections ?? [],
|
||||
contact_data: {
|
||||
email: data?.footer?.contact_data?.email ?? '',
|
||||
phone: data?.footer?.contact_data?.phone ?? '',
|
||||
address: data?.footer?.contact_data?.address ?? '',
|
||||
show_email: data?.footer?.contact_data?.show_email ?? true,
|
||||
show_phone: data?.footer?.contact_data?.show_phone ?? true,
|
||||
show_address: data?.footer?.contact_data?.show_address ?? true,
|
||||
},
|
||||
labels: {
|
||||
contact_title: data?.footer?.labels?.contact_title ?? 'Contact',
|
||||
menu_title: data?.footer?.labels?.menu_title ?? 'Quick Links',
|
||||
social_title: data?.footer?.labels?.social_title ?? 'Follow Us',
|
||||
newsletter_title: data?.footer?.labels?.newsletter_title ?? 'Newsletter',
|
||||
newsletter_description: data?.footer?.labels?.newsletter_description ?? 'Subscribe to get updates',
|
||||
},
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
34
customer-spa/src/hooks/usePageVisibility.ts
Normal file
34
customer-spa/src/hooks/usePageVisibility.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useCheckoutSettings, useThankYouSettings } from './useAppearanceSettings';
|
||||
|
||||
export function usePageVisibility() {
|
||||
const location = useLocation();
|
||||
const checkoutSettings = useCheckoutSettings();
|
||||
const thankYouSettings = useThankYouSettings();
|
||||
|
||||
// Default visibility
|
||||
let headerVisibility = 'show';
|
||||
let footerVisibility = 'show';
|
||||
let backgroundColor = '';
|
||||
|
||||
// Check current route and get visibility settings
|
||||
if (location.pathname === '/checkout') {
|
||||
headerVisibility = checkoutSettings.layout.header_visibility || 'minimal';
|
||||
footerVisibility = checkoutSettings.layout.footer_visibility || 'minimal';
|
||||
backgroundColor = checkoutSettings.layout.background_color || '';
|
||||
} else if (location.pathname.startsWith('/order-received/')) {
|
||||
headerVisibility = thankYouSettings.headerVisibility || 'show';
|
||||
footerVisibility = thankYouSettings.footerVisibility || 'minimal';
|
||||
backgroundColor = thankYouSettings.backgroundColor || '';
|
||||
}
|
||||
|
||||
return {
|
||||
headerVisibility,
|
||||
footerVisibility,
|
||||
backgroundColor,
|
||||
shouldShowHeader: headerVisibility !== 'hide',
|
||||
shouldShowFooter: footerVisibility !== 'hide',
|
||||
isMinimalHeader: headerVisibility === 'minimal',
|
||||
isMinimalFooter: footerVisibility === 'minimal',
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
/* Self-hosted fonts (GDPR-compliant) */
|
||||
@import './styles/fonts.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -67,6 +70,20 @@
|
||||
* { @apply border-border; }
|
||||
body { @apply bg-background text-foreground; }
|
||||
h1, h2, h3, h4, h5, h6 { @apply text-foreground; }
|
||||
|
||||
/* Override WordPress/WooCommerce link styles */
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.no-underline {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Radix UI Popper z-index fix */
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import React, { ReactNode, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ShoppingCart, User, Search, Menu, X } from 'lucide-react';
|
||||
import { useLayout } from '../contexts/ThemeContext';
|
||||
import { useCartStore } from '../lib/cart/store';
|
||||
import { useHeaderSettings, useFooterSettings } from '../hooks/useAppearanceSettings';
|
||||
import { SearchModal } from '../components/SearchModal';
|
||||
import { NewsletterForm } from '../components/NewsletterForm';
|
||||
import { LayoutWrapper } from './LayoutWrapper';
|
||||
|
||||
interface BaseLayoutProps {
|
||||
children: ReactNode;
|
||||
@@ -9,23 +15,24 @@ interface BaseLayoutProps {
|
||||
/**
|
||||
* Base Layout Component
|
||||
*
|
||||
* Renders the appropriate layout based on theme configuration
|
||||
* Renders the appropriate layout based on header style from appearance settings
|
||||
*/
|
||||
export function BaseLayout({ children }: BaseLayoutProps) {
|
||||
const { layout } = useLayout();
|
||||
const headerSettings = useHeaderSettings();
|
||||
|
||||
// Dynamically import and render the appropriate layout
|
||||
switch (layout) {
|
||||
// Map header styles to layouts
|
||||
// classic -> ClassicLayout, centered -> ModernLayout, minimal -> LaunchLayout, split -> BoutiqueLayout
|
||||
switch (headerSettings.style) {
|
||||
case 'classic':
|
||||
return <ClassicLayout>{children}</ClassicLayout>;
|
||||
case 'modern':
|
||||
case 'centered':
|
||||
return <ModernLayout>{children}</ModernLayout>;
|
||||
case 'boutique':
|
||||
return <BoutiqueLayout>{children}</BoutiqueLayout>;
|
||||
case 'launch':
|
||||
case 'minimal':
|
||||
return <LaunchLayout>{children}</LaunchLayout>;
|
||||
case 'split':
|
||||
return <BoutiqueLayout>{children}</BoutiqueLayout>;
|
||||
default:
|
||||
return <ModernLayout>{children}</ModernLayout>;
|
||||
return <ClassicLayout>{children}</ClassicLayout>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,77 +40,303 @@ export function BaseLayout({ children }: BaseLayoutProps) {
|
||||
* Classic Layout - Traditional ecommerce
|
||||
*/
|
||||
function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
return (
|
||||
<div className="classic-layout min-h-screen flex flex-col">
|
||||
<header className="classic-header bg-white border-b sticky top-0 z-50">
|
||||
const { cart } = 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';
|
||||
const user = (window as any).woonoowCustomer?.user;
|
||||
const headerSettings = useHeaderSettings();
|
||||
const footerSettings = useFooterSettings();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
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 footerColsClass: Record<string, string> = {
|
||||
'1': 'grid-cols-1',
|
||||
'2': 'grid-cols-1 md:grid-cols-2',
|
||||
'3': 'grid-cols-1 md:grid-cols-3',
|
||||
'4': 'grid-cols-1 md:grid-cols-4',
|
||||
};
|
||||
const footerGridClass = footerColsClass[footerSettings.columns] || 'grid-cols-1 md:grid-cols-4';
|
||||
|
||||
const headerContent = (
|
||||
<>
|
||||
<SearchModal isOpen={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||
<header className="classic-header bg-white border-b sticky top-0 z-50 shadow-sm">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-20">
|
||||
<div className={`flex items-center ${headerSettings.mobile_logo === 'center' ? 'max-md:justify-center' : 'justify-between'} ${heightClass}`}>
|
||||
{/* Logo */}
|
||||
<div className="flex-shrink-0">
|
||||
<Link to="/shop" className="text-2xl font-bold" style={{ color: 'var(--color-primary)' }}>
|
||||
{(window as any).woonoowCustomer?.siteTitle || 'Store Title'}
|
||||
{headerSettings.elements.logo && (
|
||||
<div className={`flex-shrink-0 ${headerSettings.mobile_logo === 'center' ? 'max-md:mx-auto' : ''}`}>
|
||||
<Link to="/shop" className="flex items-center gap-3 group">
|
||||
{storeLogo ? (
|
||||
<img
|
||||
src={storeLogo}
|
||||
alt={storeName}
|
||||
className="object-contain"
|
||||
style={{
|
||||
width: headerSettings.logo_width,
|
||||
height: headerSettings.logo_height,
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-10 h-10 bg-gray-900 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-xl">W</span>
|
||||
</div>
|
||||
<span className="text-2xl font-serif font-light text-gray-900 hidden sm:block group-hover:text-gray-600 transition-colors">
|
||||
{storeName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="hidden md:flex items-center space-x-8">
|
||||
<Link to="/shop" className="hover:text-primary transition-colors">Shop</Link>
|
||||
<a href="/about" className="hover:text-primary transition-colors">About</a>
|
||||
<a href="/contact" className="hover:text-primary transition-colors">Contact</a>
|
||||
</nav>
|
||||
{headerSettings.elements.navigation && (
|
||||
<nav className="hidden md:flex items-center space-x-8">
|
||||
<Link to="/shop" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
|
||||
<a href="/about" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">About</a>
|
||||
<a href="/contact" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Contact</a>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link to="/my-account" className="hover:text-primary transition-colors">Account</Link>
|
||||
<Link to="/cart" className="hover:text-primary transition-colors">Cart (0)</Link>
|
||||
</div>
|
||||
{/* Actions - Hidden on mobile when using bottom-nav */}
|
||||
{hasActions && (
|
||||
<div className={`flex items-center gap-3 ${headerSettings.mobile_menu === 'bottom-nav' ? 'max-md:hidden' : ''}`}>
|
||||
{/* Search */}
|
||||
{headerSettings.elements.search && (
|
||||
<button
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<Search className="h-5 w-5 text-gray-600" />
|
||||
</button>
|
||||
)}
|
||||
{/* Account */}
|
||||
{headerSettings.elements.account && (user?.isLoggedIn ? (
|
||||
<Link to="/my-account" className="no-underline">
|
||||
<button className="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<User className="h-5 w-5 text-gray-600" />
|
||||
<span className="hidden lg:block text-sm font-medium text-gray-700">Account</span>
|
||||
</button>
|
||||
</Link>
|
||||
) : (
|
||||
<a href="/wp-login.php" className="no-underline">
|
||||
<button className="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<User className="h-5 w-5 text-gray-600" />
|
||||
<span className="hidden lg:block text-sm font-medium text-gray-700">Account</span>
|
||||
</button>
|
||||
</a>
|
||||
))}
|
||||
|
||||
{/* Cart */}
|
||||
{headerSettings.elements.cart && (
|
||||
<Link to="/cart" className="no-underline">
|
||||
<button className="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors relative">
|
||||
<div className="relative">
|
||||
<ShoppingCart className="h-5 w-5 text-gray-600" />
|
||||
{itemCount > 0 && (
|
||||
<span className="absolute -top-2 -right-2 h-5 w-5 rounded-full bg-gray-900 text-white text-xs flex items-center justify-center font-medium">
|
||||
{itemCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="hidden lg:block text-sm font-medium text-gray-700">
|
||||
Cart ({itemCount})
|
||||
</span>
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu Toggle - Only for hamburger and slide-in */}
|
||||
{(headerSettings.mobile_menu === 'hamburger' || headerSettings.mobile_menu === 'slide-in') && (
|
||||
<button
|
||||
className="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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu - Hamburger Dropdown */}
|
||||
{headerSettings.mobile_menu === 'hamburger' && mobileMenuOpen && (
|
||||
<div className="md:hidden border-t py-4">
|
||||
{headerSettings.elements.navigation && (
|
||||
<nav className="flex flex-col space-y-2 mb-4">
|
||||
<Link to="/shop" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">Shop</Link>
|
||||
<a href="/about" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">About</a>
|
||||
<a href="/contact" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">Contact</a>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu - Slide-in Drawer */}
|
||||
{headerSettings.mobile_menu === 'slide-in' && mobileMenuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black/50 z-40 md:hidden" onClick={() => setMobileMenuOpen(false)} />
|
||||
<div className="fixed top-0 left-0 h-full w-64 bg-white shadow-xl z-50 md:hidden transform transition-transform">
|
||||
<div className="p-4 border-b flex justify-between items-center">
|
||||
<span className="font-semibold">Menu</span>
|
||||
<button onClick={() => setMobileMenuOpen(false)}>
|
||||
<X className="h-5 w-5 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
{headerSettings.elements.navigation && (
|
||||
<nav className="flex flex-col p-4">
|
||||
<Link to="/shop" onClick={() => setMobileMenuOpen(false)} className="px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded no-underline">Shop</Link>
|
||||
<a href="/about" onClick={() => setMobileMenuOpen(false)} className="px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded no-underline">About</a>
|
||||
<a href="/contact" onClick={() => setMobileMenuOpen(false)} className="px-4 py-3 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded no-underline">Contact</a>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="classic-main flex-1">
|
||||
{children}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
|
||||
const footerContent = (
|
||||
<>
|
||||
{/* Mobile Menu - Bottom Navigation */}
|
||||
{headerSettings.mobile_menu === 'bottom-nav' && (
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-white border-t shadow-lg z-50">
|
||||
<div className="flex justify-around items-center py-3">
|
||||
<Link to="/shop" 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">
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
<span>Shop</span>
|
||||
</Link>
|
||||
{headerSettings.elements.search && (
|
||||
<button
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className="flex flex-col items-center gap-1 px-4 py-2 text-xs font-medium text-gray-700 hover:text-gray-900"
|
||||
>
|
||||
<Search className="h-5 w-5" />
|
||||
<span>Search</span>
|
||||
</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">
|
||||
<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">
|
||||
{itemCount}
|
||||
</span>
|
||||
)}
|
||||
<span>Cart</span>
|
||||
</Link>
|
||||
)}
|
||||
{headerSettings.elements.account && (
|
||||
user?.isLoggedIn ? (
|
||||
<Link to="/my-account" 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">
|
||||
<User className="h-5 w-5" />
|
||||
<span>Account</span>
|
||||
</Link>
|
||||
) : (
|
||||
<a href="/wp-login.php" 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">
|
||||
<User className="h-5 w-5" />
|
||||
<span>Login</span>
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<footer className="classic-footer bg-gray-100 border-t mt-auto">
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">About</h3>
|
||||
<p className="text-sm text-gray-600">Your store description here.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">Quick Links</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li><a href="/shop" className="text-gray-600 hover:text-primary">Shop</a></li>
|
||||
<li><a href="/about" className="text-gray-600 hover:text-primary">About</a></li>
|
||||
<li><a href="/contact" className="text-gray-600 hover:text-primary">Contact</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">Customer Service</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li><a href="/shipping" className="text-gray-600 hover:text-primary">Shipping</a></li>
|
||||
<li><a href="/returns" className="text-gray-600 hover:text-primary">Returns</a></li>
|
||||
<li><a href="/faq" className="text-gray-600 hover:text-primary">FAQ</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">Newsletter</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">Subscribe to get updates</p>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
className="w-full px-4 py-2 border rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t mt-8 pt-8 text-center text-sm text-gray-600">
|
||||
© 2024 Your Store. All rights reserved.
|
||||
<div className={`grid ${footerGridClass} gap-8`}>
|
||||
{/* Render all sections dynamically */}
|
||||
{footerSettings.sections.filter((s: any) => s.visible).map((section: any) => (
|
||||
<div key={section.id}>
|
||||
<h3 className="font-semibold mb-4">{section.title}</h3>
|
||||
|
||||
{/* Contact Section */}
|
||||
{section.type === 'contact' && (
|
||||
<div className="space-y-1 text-sm text-gray-600">
|
||||
{footerSettings.contact_data.show_email && footerSettings.contact_data.email && (
|
||||
<p>Email: {footerSettings.contact_data.email}</p>
|
||||
)}
|
||||
{footerSettings.contact_data.show_phone && footerSettings.contact_data.phone && (
|
||||
<p>Phone: {footerSettings.contact_data.phone}</p>
|
||||
)}
|
||||
{footerSettings.contact_data.show_address && footerSettings.contact_data.address && (
|
||||
<p>{footerSettings.contact_data.address}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Menu Section */}
|
||||
{section.type === 'menu' && (
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li><Link to="/shop" className="text-gray-600 hover:text-primary no-underline">Shop</Link></li>
|
||||
<li><a href="/about" className="text-gray-600 hover:text-primary no-underline">About</a></li>
|
||||
<li><a href="/contact" className="text-gray-600 hover:text-primary no-underline">Contact</a></li>
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Social Section */}
|
||||
{section.type === 'social' && footerSettings.social_links.length > 0 && (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{footerSettings.social_links.map((link: any) => (
|
||||
<li key={link.id}>
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer" className="text-gray-600 hover:text-primary no-underline">
|
||||
{link.platform}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Newsletter Section */}
|
||||
{section.type === 'newsletter' && (
|
||||
<NewsletterForm description={footerSettings.labels.newsletter_description} />
|
||||
)}
|
||||
|
||||
{/* Custom HTML Section */}
|
||||
{section.type === 'custom' && (
|
||||
<div className="text-sm text-gray-600" dangerouslySetInnerHTML={{ __html: section.content }} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Payment Icons */}
|
||||
{footerSettings.elements.payment && (
|
||||
<div className="mt-8 pt-8 border-t">
|
||||
<p className="text-xs text-gray-500 text-center mb-4">We accept</p>
|
||||
<div className="flex justify-center gap-4 text-gray-400">
|
||||
<span className="text-xs">💳 Visa</span>
|
||||
<span className="text-xs">💳 Mastercard</span>
|
||||
<span className="text-xs">💳 PayPal</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Copyright */}
|
||||
{footerSettings.elements.copyright && (
|
||||
<div className="border-t mt-8 pt-8 text-center text-sm text-gray-600">
|
||||
{footerSettings.copyright_text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<LayoutWrapper header={headerContent} footer={footerContent}>
|
||||
{children}
|
||||
</LayoutWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -111,25 +344,102 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
||||
* Modern Layout - Minimalist, clean
|
||||
*/
|
||||
function ModernLayout({ children }: BaseLayoutProps) {
|
||||
const { cart } = 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';
|
||||
const user = (window as any).woonoowCustomer?.user;
|
||||
const headerSettings = useHeaderSettings();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="modern-layout min-h-screen flex flex-col">
|
||||
<SearchModal isOpen={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||
<header className="modern-header bg-white border-b sticky top-0 z-50">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex flex-col items-center py-6">
|
||||
<div className={`flex flex-col items-center ${paddingClass}`}>
|
||||
{/* Logo - Centered */}
|
||||
<Link to="/shop" className="text-3xl font-bold mb-4" style={{ color: 'var(--color-primary)' }}>
|
||||
{(window as any).woonoowCustomer?.siteTitle || 'Store Title'}
|
||||
</Link>
|
||||
{headerSettings.elements.logo && (
|
||||
<Link to="/shop" className="mb-4">
|
||||
{storeLogo ? (
|
||||
<img
|
||||
src={storeLogo}
|
||||
alt={storeName}
|
||||
className="object-contain"
|
||||
style={{
|
||||
width: headerSettings.logo_width,
|
||||
height: headerSettings.logo_height,
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-3xl font-bold text-gray-900">{storeName}</span>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Navigation - Centered */}
|
||||
<nav className="flex items-center space-x-8">
|
||||
<Link to="/shop" className="hover:text-primary transition-colors">Shop</Link>
|
||||
<a href="/about" className="hover:text-primary transition-colors">About</a>
|
||||
<a href="/contact" className="hover:text-primary transition-colors">Contact</a>
|
||||
<Link to="/my-account" className="hover:text-primary transition-colors">Account</Link>
|
||||
<Link to="/cart" className="hover:text-primary transition-colors">Cart</Link>
|
||||
</nav>
|
||||
{/* Navigation & Actions - Centered */}
|
||||
{(headerSettings.elements.navigation || hasActions) && (
|
||||
<nav className="hidden md:flex items-center space-x-8">
|
||||
{headerSettings.elements.navigation && (
|
||||
<>
|
||||
<Link to="/shop" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
|
||||
<a href="/about" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">About</a>
|
||||
<a href="/contact" className="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">Contact</a>
|
||||
</>
|
||||
)}
|
||||
{headerSettings.elements.search && (
|
||||
<button
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{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">
|
||||
<User className="h-4 w-4" /> Account
|
||||
</Link>
|
||||
) : (
|
||||
<a href="/wp-login.php" className="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||
<User className="h-4 w-4" /> Account
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
{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">
|
||||
<ShoppingCart className="h-4 w-4" /> Cart ({itemCount})
|
||||
</Link>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden border-t py-4">
|
||||
{headerSettings.elements.navigation && (
|
||||
<nav className="flex flex-col space-y-2 mb-4">
|
||||
<Link to="/shop" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">Shop</Link>
|
||||
<a href="/about" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">About</a>
|
||||
<a href="/contact" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">Contact</a>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -162,28 +472,99 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
||||
* Boutique Layout - Luxury, elegant
|
||||
*/
|
||||
function BoutiqueLayout({ children }: BaseLayoutProps) {
|
||||
const { cart } = 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';
|
||||
const user = (window as any).woonoowCustomer?.user;
|
||||
const headerSettings = useHeaderSettings();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="boutique-layout min-h-screen flex flex-col font-serif">
|
||||
<SearchModal isOpen={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||
<header className="boutique-header bg-white border-b sticky top-0 z-50">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-24">
|
||||
<div className={`flex items-center justify-between ${heightClass}`}>
|
||||
{/* Logo */}
|
||||
<div className="flex-1"></div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<Link to="/shop" className="text-3xl font-bold tracking-wide" style={{ color: 'var(--color-primary)' }}>
|
||||
{(window as any).woonoowCustomer?.siteTitle || 'BOUTIQUE'}
|
||||
{headerSettings.elements.logo && (
|
||||
<div className="flex-shrink-0">
|
||||
<Link to="/shop">
|
||||
{storeLogo ? (
|
||||
<img
|
||||
src={storeLogo}
|
||||
alt={storeName}
|
||||
className="object-contain"
|
||||
style={{
|
||||
width: headerSettings.logo_width,
|
||||
height: headerSettings.logo_height,
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-3xl font-bold tracking-wide text-gray-900">{storeName}</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex justify-end">
|
||||
<nav className="hidden md:flex items-center space-x-8">
|
||||
<Link to="/shop" className="text-sm uppercase tracking-wider hover:text-primary transition-colors">Shop</Link>
|
||||
<Link to="/my-account" className="text-sm uppercase tracking-wider hover:text-primary transition-colors">Account</Link>
|
||||
<Link to="/cart" className="text-sm uppercase tracking-wider hover:text-primary transition-colors">Cart</Link>
|
||||
</nav>
|
||||
{(headerSettings.elements.navigation || hasActions) && (
|
||||
<nav className="hidden md:flex items-center space-x-8">
|
||||
{headerSettings.elements.navigation && (
|
||||
<Link to="/shop" className="text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">Shop</Link>
|
||||
)}
|
||||
{headerSettings.elements.search && (
|
||||
<button
|
||||
onClick={() => setSearchOpen(true)}
|
||||
className="flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{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
|
||||
</Link>
|
||||
) : (
|
||||
<a href="/wp-login.php" 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
|
||||
</a>
|
||||
))}
|
||||
{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">
|
||||
<ShoppingCart className="h-4 w-4" /> Cart ({itemCount})
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu Toggle */}
|
||||
<button
|
||||
className="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>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden border-t py-4">
|
||||
{headerSettings.elements.navigation && (
|
||||
<nav className="flex flex-col space-y-2 mb-4">
|
||||
<Link to="/shop" className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 no-underline">Shop</Link>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -232,14 +613,35 @@ function LaunchLayout({ children }: BaseLayoutProps) {
|
||||
}
|
||||
|
||||
// For checkout flow: minimal header, no footer
|
||||
const storeLogo = (window as any).woonoowCustomer?.storeLogo;
|
||||
const storeName = (window as any).woonoowCustomer?.storeName || (window as any).woonoowCustomer?.siteTitle || 'Store Title';
|
||||
const headerSettings = useHeaderSettings();
|
||||
|
||||
const heightClass = headerSettings.height === 'compact' ? 'h-12' : headerSettings.height === 'tall' ? 'h-20' : 'h-16';
|
||||
|
||||
return (
|
||||
<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 h-16">
|
||||
<Link to="/shop" className="text-xl font-bold" style={{ color: 'var(--color-primary)' }}>
|
||||
{(window as any).woonoowCustomer?.siteTitle || 'Store Title'}
|
||||
</Link>
|
||||
<div className={`flex items-center justify-center ${heightClass}`}>
|
||||
{headerSettings.elements.logo && (
|
||||
<Link to="/shop">
|
||||
{storeLogo ? (
|
||||
<img
|
||||
src={storeLogo}
|
||||
alt={storeName}
|
||||
className="object-contain"
|
||||
style={{
|
||||
width: headerSettings.logo_width,
|
||||
height: headerSettings.logo_height,
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xl font-bold text-gray-900">{storeName}</span>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
56
customer-spa/src/layouts/LayoutWrapper.tsx
Normal file
56
customer-spa/src/layouts/LayoutWrapper.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useCheckoutSettings, useThankYouSettings } from '../hooks/useAppearanceSettings';
|
||||
import { MinimalHeader } from '../components/Layout/MinimalHeader';
|
||||
import { MinimalFooter } from '../components/Layout/MinimalFooter';
|
||||
|
||||
interface LayoutWrapperProps {
|
||||
children: ReactNode;
|
||||
header: ReactNode;
|
||||
footer: ReactNode;
|
||||
}
|
||||
|
||||
export function LayoutWrapper({ children, header, footer }: LayoutWrapperProps) {
|
||||
const location = useLocation();
|
||||
const checkoutSettings = useCheckoutSettings();
|
||||
const thankYouSettings = useThankYouSettings();
|
||||
|
||||
// Determine visibility settings based on current route
|
||||
let headerVisibility = 'show';
|
||||
let footerVisibility = 'show';
|
||||
let backgroundColor = '';
|
||||
|
||||
if (location.pathname === '/checkout') {
|
||||
headerVisibility = checkoutSettings.layout.header_visibility || 'minimal';
|
||||
footerVisibility = checkoutSettings.layout.footer_visibility || 'minimal';
|
||||
backgroundColor = checkoutSettings.layout.background_color || '';
|
||||
} else if (location.pathname.startsWith('/order-received/')) {
|
||||
headerVisibility = thankYouSettings.headerVisibility || 'show';
|
||||
footerVisibility = thankYouSettings.footerVisibility || 'minimal';
|
||||
backgroundColor = thankYouSettings.backgroundColor || '';
|
||||
}
|
||||
|
||||
// Render appropriate header
|
||||
const renderHeader = () => {
|
||||
if (headerVisibility === 'hide') return null;
|
||||
if (headerVisibility === 'minimal') return <MinimalHeader />;
|
||||
return header;
|
||||
};
|
||||
|
||||
// Render appropriate footer
|
||||
const renderFooter = () => {
|
||||
if (footerVisibility === 'hide') return null;
|
||||
if (footerVisibility === 'minimal') return <MinimalFooter />;
|
||||
return footer;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="layout-wrapper min-h-screen flex flex-col" style={backgroundColor ? { backgroundColor } : undefined}>
|
||||
{renderHeader()}
|
||||
<main className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
{renderFooter()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export interface CartItem {
|
||||
price: number;
|
||||
image?: string;
|
||||
attributes?: Record<string, string>;
|
||||
virtual?: boolean;
|
||||
downloadable?: boolean;
|
||||
}
|
||||
|
||||
export interface Cart {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import './styles/fonts.css';
|
||||
import './styles/theme.css';
|
||||
import App from './App';
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useCartStore, type CartItem } from '@/lib/cart/store';
|
||||
import { useCartSettings } from '@/hooks/useAppearanceSettings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -18,6 +19,7 @@ import { toast } from 'sonner';
|
||||
export default function Cart() {
|
||||
const navigate = useNavigate();
|
||||
const { cart, removeItem, updateQuantity, clearCart } = useCartStore();
|
||||
const { layout, elements } = useCartSettings();
|
||||
const [showClearDialog, setShowClearDialog] = useState(false);
|
||||
|
||||
// Calculate total from items
|
||||
@@ -60,7 +62,7 @@ export default function Cart() {
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="py-8">
|
||||
<div className={`py-8 ${layout.style === 'boxed' ? 'max-w-5xl mx-auto' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-3xl font-bold">Shopping Cart</h1>
|
||||
@@ -70,34 +72,52 @@ export default function Cart() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-8">
|
||||
<div className={`grid gap-8 ${layout.summary_position === 'bottom' ? 'grid-cols-1' : 'lg:grid-cols-3'}`}>
|
||||
{/* Cart Items */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className={`space-y-4 ${layout.summary_position === 'bottom' ? '' : 'lg:col-span-2'}`}>
|
||||
{cart.items.map((item: CartItem) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex gap-4 p-4 border rounded-lg bg-white"
|
||||
>
|
||||
{/* Product Image */}
|
||||
<div className="relative w-24 h-24 flex-shrink-0 rounded-lg overflow-hidden bg-gray-100">
|
||||
{item.image ? (
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
className="block w-full !h-full object-cover object-center"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full !h-full flex items-center justify-center text-gray-400 text-xs">
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{elements.product_images && (
|
||||
<div className="relative w-24 h-24 flex-shrink-0 rounded-lg overflow-hidden bg-gray-100">
|
||||
{item.image ? (
|
||||
<img
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
className="block w-full !h-full object-cover object-center"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full !h-full flex items-center justify-center text-gray-400 text-xs">
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-lg mb-1 truncate">
|
||||
{item.name}
|
||||
</h3>
|
||||
|
||||
{/* Variation Attributes */}
|
||||
{item.attributes && Object.keys(item.attributes).length > 0 && (
|
||||
<div className="text-sm text-gray-500 mb-1">
|
||||
{Object.entries(item.attributes).map(([key, value]) => {
|
||||
// Format attribute name: capitalize first letter
|
||||
const formattedKey = key.charAt(0).toUpperCase() + key.slice(1);
|
||||
return (
|
||||
<span key={key} className="mr-3">
|
||||
{formattedKey}: <span className="font-medium">{value}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-gray-600 mb-2">
|
||||
{formatPrice(item.price)}
|
||||
</p>
|
||||
@@ -149,6 +169,36 @@ export default function Cart() {
|
||||
<div className="border rounded-lg p-6 bg-white sticky top-4">
|
||||
<h2 className="text-xl font-bold mb-4">Cart Summary</h2>
|
||||
|
||||
{/* Coupon Field */}
|
||||
{elements.coupon_field && (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium mb-2">Coupon Code</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter coupon code"
|
||||
className="flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<Button variant="outline" size="sm">Apply</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shipping Calculator */}
|
||||
{elements.shipping_calculator && (
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="font-medium mb-3">Calculate Shipping</h3>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Postal Code"
|
||||
className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<Button variant="outline" size="sm" className="w-full">Calculate</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Subtotal</span>
|
||||
@@ -172,14 +222,16 @@ export default function Cart() {
|
||||
Proceed to Checkout
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => navigate('/shop')}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Continue Shopping
|
||||
</Button>
|
||||
{elements.continue_shopping_button && (
|
||||
<Button
|
||||
onClick={() => navigate('/shop')}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Continue Shopping
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCartStore } from '@/lib/cart/store';
|
||||
import { useCheckoutSettings } from '@/hooks/useAppearanceSettings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Container from '@/components/Layout/Container';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
import { ArrowLeft, ShoppingBag } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
|
||||
export default function Checkout() {
|
||||
const navigate = useNavigate();
|
||||
const { cart } = useCartStore();
|
||||
const { layout, elements } = useCheckoutSettings();
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const user = (window as any).woonoowCustomer?.user;
|
||||
|
||||
// Check if cart contains only virtual/downloadable products
|
||||
const isVirtualOnly = React.useMemo(() => {
|
||||
if (cart.items.length === 0) return false;
|
||||
return cart.items.every(item => item.virtual || item.downloadable);
|
||||
}, [cart.items]);
|
||||
|
||||
// Calculate totals
|
||||
const subtotal = cart.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
|
||||
const shipping = 0; // TODO: Calculate shipping
|
||||
const shipping = isVirtualOnly ? 0 : 0; // No shipping for virtual products
|
||||
const tax = 0; // TODO: Calculate tax
|
||||
const total = subtotal + shipping + tax;
|
||||
|
||||
@@ -43,20 +53,98 @@ export default function Checkout() {
|
||||
|
||||
const [shipToDifferentAddress, setShipToDifferentAddress] = useState(false);
|
||||
const [orderNotes, setOrderNotes] = useState('');
|
||||
const [paymentMethod, setPaymentMethod] = useState(isVirtualOnly ? 'bacs' : 'cod');
|
||||
|
||||
// Auto-fill form with user data if logged in
|
||||
useEffect(() => {
|
||||
if (user?.isLoggedIn && user?.billing) {
|
||||
setBillingData({
|
||||
firstName: user.billing.first_name || '',
|
||||
lastName: user.billing.last_name || '',
|
||||
email: user.billing.email || user.email || '',
|
||||
phone: user.billing.phone || '',
|
||||
address: user.billing.address_1 || '',
|
||||
city: user.billing.city || '',
|
||||
state: user.billing.state || '',
|
||||
postcode: user.billing.postcode || '',
|
||||
country: user.billing.country || '',
|
||||
});
|
||||
}
|
||||
|
||||
if (user?.isLoggedIn && user?.shipping) {
|
||||
setShippingData({
|
||||
firstName: user.shipping.first_name || '',
|
||||
lastName: user.shipping.last_name || '',
|
||||
address: user.shipping.address_1 || '',
|
||||
city: user.shipping.city || '',
|
||||
state: user.shipping.state || '',
|
||||
postcode: user.shipping.postcode || '',
|
||||
country: user.shipping.country || '',
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handlePlaceOrder = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
// TODO: Implement order placement API call
|
||||
await new Promise(resolve => setTimeout(resolve, 2000)); // Simulate API call
|
||||
// Prepare order data
|
||||
const orderData = {
|
||||
items: cart.items.map(item => ({
|
||||
product_id: item.product_id,
|
||||
variation_id: item.variation_id,
|
||||
qty: item.quantity,
|
||||
meta: item.attributes ? Object.entries(item.attributes).map(([key, value]) => ({
|
||||
key,
|
||||
value
|
||||
})) : []
|
||||
})),
|
||||
billing: {
|
||||
first_name: billingData.firstName,
|
||||
last_name: billingData.lastName,
|
||||
email: billingData.email,
|
||||
phone: billingData.phone,
|
||||
address_1: billingData.address,
|
||||
city: billingData.city,
|
||||
state: billingData.state,
|
||||
postcode: billingData.postcode,
|
||||
country: billingData.country,
|
||||
},
|
||||
shipping: shipToDifferentAddress ? {
|
||||
first_name: shippingData.firstName,
|
||||
last_name: shippingData.lastName,
|
||||
address_1: shippingData.address,
|
||||
city: shippingData.city,
|
||||
state: shippingData.state,
|
||||
postcode: shippingData.postcode,
|
||||
country: shippingData.country,
|
||||
ship_to_different: true,
|
||||
} : {
|
||||
ship_to_different: false,
|
||||
},
|
||||
payment_method: paymentMethod,
|
||||
customer_note: orderNotes,
|
||||
};
|
||||
|
||||
// Submit order
|
||||
const response = await apiClient.post('/checkout/submit', orderData);
|
||||
const data = (response as any).data || response;
|
||||
|
||||
toast.success('Order placed successfully!');
|
||||
navigate('/order-received/123'); // TODO: Use actual order ID
|
||||
} catch (error) {
|
||||
toast.error('Failed to place order');
|
||||
console.error(error);
|
||||
if (data.ok && data.order_id) {
|
||||
// Clear cart
|
||||
cart.items.forEach(item => {
|
||||
useCartStore.getState().removeItem(item.key);
|
||||
});
|
||||
|
||||
toast.success('Order placed successfully!');
|
||||
navigate(`/order-received/${data.order_id}`);
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to create order');
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to place order');
|
||||
console.error('Order creation error:', error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
@@ -92,9 +180,9 @@ export default function Checkout() {
|
||||
</div>
|
||||
|
||||
<form onSubmit={handlePlaceOrder}>
|
||||
<div className="grid lg:grid-cols-3 gap-8">
|
||||
<div className={`grid gap-8 ${layout.style === 'single-column' ? 'grid-cols-1' : layout.order_summary === 'top' ? 'grid-cols-1' : 'lg:grid-cols-3'}`}>
|
||||
{/* Billing & Shipping Forms */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className={`space-y-6 ${layout.style === 'single-column' || layout.order_summary === 'top' ? '' : 'lg:col-span-2'}`}>
|
||||
{/* Billing Details */}
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold mb-4">Billing Details</h2>
|
||||
@@ -139,60 +227,67 @@ export default function Checkout() {
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2">Street Address *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.address}
|
||||
onChange={(e) => setBillingData({ ...billingData, address: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">City *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.city}
|
||||
onChange={(e) => setBillingData({ ...billingData, city: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">State / Province *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.state}
|
||||
onChange={(e) => setBillingData({ ...billingData, state: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Postcode / ZIP *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.postcode}
|
||||
onChange={(e) => setBillingData({ ...billingData, postcode: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Country *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.country}
|
||||
onChange={(e) => setBillingData({ ...billingData, country: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Address fields - only for physical products */}
|
||||
{!isVirtualOnly && (
|
||||
<>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium mb-2">Street Address *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.address}
|
||||
onChange={(e) => setBillingData({ ...billingData, address: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">City *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.city}
|
||||
onChange={(e) => setBillingData({ ...billingData, city: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">State / Province *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.state}
|
||||
onChange={(e) => setBillingData({ ...billingData, state: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Postcode / ZIP *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.postcode}
|
||||
onChange={(e) => setBillingData({ ...billingData, postcode: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Country *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.country}
|
||||
onChange={(e) => setBillingData({ ...billingData, country: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ship to Different Address */}
|
||||
{/* Ship to Different Address - only for physical products */}
|
||||
{!isVirtualOnly && (
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<label className="flex items-center gap-2 mb-4">
|
||||
<input
|
||||
@@ -279,24 +374,42 @@ export default function Checkout() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Order Notes */}
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold mb-4">Order Notes (Optional)</h2>
|
||||
<textarea
|
||||
value={orderNotes}
|
||||
onChange={(e) => setOrderNotes(e.target.value)}
|
||||
placeholder="Notes about your order, e.g. special notes for delivery."
|
||||
className="w-full border rounded-lg px-4 py-2 h-32"
|
||||
/>
|
||||
</div>
|
||||
{elements.order_notes && (
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold mb-4">Order Notes (Optional)</h2>
|
||||
<textarea
|
||||
value={orderNotes}
|
||||
onChange={(e) => setOrderNotes(e.target.value)}
|
||||
placeholder="Notes about your order, e.g. special notes for delivery."
|
||||
className="w-full border rounded-lg px-4 py-2 h-32"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Order Summary */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className={`${layout.style === 'single-column' || layout.order_summary === 'top' ? 'order-first' : 'lg:col-span-1'}`}>
|
||||
<div className="bg-white border rounded-lg p-6 sticky top-4">
|
||||
<h2 className="text-xl font-bold mb-4">Your Order</h2>
|
||||
|
||||
{/* Coupon Field */}
|
||||
{elements.coupon_field && (
|
||||
<div className="mb-4 pb-4 border-b">
|
||||
<label className="block text-sm font-medium mb-2">Coupon Code</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter coupon code"
|
||||
className="flex-1 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm">Apply</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Order Items */}
|
||||
<div className="space-y-3 mb-4 pb-4 border-b">
|
||||
{cart.items.map((item) => (
|
||||
@@ -311,6 +424,29 @@ export default function Checkout() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Shipping Options */}
|
||||
{elements.shipping_options && (
|
||||
<div className="mb-4 pb-4 border-b">
|
||||
<h3 className="font-medium mb-3">Shipping Method</h3>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center justify-between p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="radio" name="shipping" value="free" defaultChecked className="w-4 h-4" />
|
||||
<span className="text-sm">Free Shipping</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">Free</span>
|
||||
</label>
|
||||
<label className="flex items-center justify-between p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="radio" name="shipping" value="express" className="w-4 h-4" />
|
||||
<span className="text-sm">Express Shipping</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">$15.00</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Totals */}
|
||||
<div className="space-y-2 mb-6">
|
||||
<div className="flex justify-between text-sm">
|
||||
@@ -337,29 +473,74 @@ export default function Checkout() {
|
||||
<div className="mb-6">
|
||||
<h3 className="font-medium mb-3">Payment Method</h3>
|
||||
<div className="space-y-2">
|
||||
{/* Hide COD for virtual-only products */}
|
||||
{!isVirtualOnly && (
|
||||
<label className="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
|
||||
<input
|
||||
type="radio"
|
||||
name="payment"
|
||||
value="cod"
|
||||
checked={paymentMethod === 'cod'}
|
||||
onChange={(e) => setPaymentMethod(e.target.value)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span>Cash on Delivery</span>
|
||||
</label>
|
||||
)}
|
||||
<label className="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
|
||||
<input type="radio" name="payment" value="cod" defaultChecked className="w-4 h-4" />
|
||||
<span>Cash on Delivery</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-3 border rounded-lg cursor-pointer hover:bg-gray-50">
|
||||
<input type="radio" name="payment" value="bank" className="w-4 h-4" />
|
||||
<input
|
||||
type="radio"
|
||||
name="payment"
|
||||
value="bacs"
|
||||
checked={paymentMethod === 'bacs'}
|
||||
onChange={(e) => setPaymentMethod(e.target.value)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span>Bank Transfer</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Payment Icons */}
|
||||
{elements.payment_icons && (
|
||||
<div className="flex items-center gap-2 mt-3 pt-3 border-t">
|
||||
<span className="text-xs text-gray-500">We accept:</span>
|
||||
<div className="flex gap-1">
|
||||
<div className="w-10 h-6 bg-gray-100 rounded flex items-center justify-center text-xs font-bold">VISA</div>
|
||||
<div className="w-10 h-6 bg-gray-100 rounded flex items-center justify-center text-xs font-bold">MC</div>
|
||||
<div className="w-10 h-6 bg-gray-100 rounded flex items-center justify-center text-xs font-bold">AMEX</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Place Order Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? 'Processing...' : 'Place Order'}
|
||||
</Button>
|
||||
{/* Place Order Button - Only show in sidebar layout */}
|
||||
{layout.order_summary !== 'top' && (
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? 'Processing...' : 'Place Order'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Place Order Button - Show at bottom when summary is on top */}
|
||||
{layout.order_summary === 'top' && (
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? 'Processing...' : 'Place Order'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -3,8 +3,10 @@ import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useCartStore } from '@/lib/cart/store';
|
||||
import { useProductSettings } from '@/hooks/useAppearanceSettings';
|
||||
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 { toast } from 'sonner';
|
||||
@@ -13,8 +15,9 @@ import type { Product as ProductType, ProductsResponse } from '@/types/product';
|
||||
export default function Product() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { layout, elements, related_products: relatedProductsSettings, reviews: reviewSettings } = useProductSettings();
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [activeTab, setActiveTab] = useState<'description' | 'additional' | 'reviews'>('description');
|
||||
const [activeTab, setActiveTab] = useState<'description' | 'additional' | 'reviews' | ''>('description');
|
||||
const [selectedImage, setSelectedImage] = useState<string | undefined>();
|
||||
const [selectedVariation, setSelectedVariation] = useState<any>(null);
|
||||
const [selectedAttributes, setSelectedAttributes] = useState<Record<string, string>>({});
|
||||
@@ -25,22 +28,57 @@ export default function Product() {
|
||||
const { data: product, isLoading, error } = useQuery<ProductType | null>({
|
||||
queryKey: ['product', slug],
|
||||
queryFn: async () => {
|
||||
if (!slug) return null;
|
||||
|
||||
const response = await apiClient.get<ProductsResponse>(apiClient.endpoints.shop.products, {
|
||||
slug,
|
||||
per_page: 1,
|
||||
});
|
||||
|
||||
if (response && response.products && response.products.length > 0) {
|
||||
return response.products[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
const response = await apiClient.get<ProductsResponse>(`/shop/products?slug=${slug}`);
|
||||
return response.products?.[0] || null;
|
||||
},
|
||||
enabled: !!slug,
|
||||
});
|
||||
|
||||
// Fetch related products
|
||||
const { data: relatedProducts } = useQuery<ProductType[]>({
|
||||
queryKey: ['related-products', product?.id],
|
||||
queryFn: async () => {
|
||||
if (!product) return [];
|
||||
|
||||
console.log('[Related Products] Fetching for product:', product.id);
|
||||
console.log('[Related Products] Categories:', product.categories);
|
||||
|
||||
try {
|
||||
if (product.related_ids && product.related_ids.length > 0) {
|
||||
const ids = product.related_ids.slice(0, 4).join(',');
|
||||
console.log('[Related Products] Using related_ids:', ids);
|
||||
const response = await apiClient.get<ProductsResponse>(`/shop/products?include=${ids}`);
|
||||
console.log('[Related Products] Response:', response);
|
||||
return response.products || [];
|
||||
}
|
||||
|
||||
const categoryId = product.categories?.[0]?.term_id || product.categories?.[0]?.id;
|
||||
if (categoryId) {
|
||||
console.log('[Related Products] Using category:', categoryId);
|
||||
const response = await apiClient.get<ProductsResponse>(`/shop/products?category=${categoryId}&per_page=4&exclude=${product.id}`);
|
||||
console.log('[Related Products] Response:', response.products?.length, 'products');
|
||||
return response.products || [];
|
||||
}
|
||||
|
||||
console.log('[Related Products] No category found');
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch related products:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled: !!product?.id && elements.related_products,
|
||||
});
|
||||
|
||||
// Debug logging
|
||||
console.log('[Related Products] Settings:', {
|
||||
enabled: elements.related_products,
|
||||
hasProduct: !!product?.id,
|
||||
queryEnabled: !!product?.id && elements.related_products,
|
||||
relatedProductsData: relatedProducts,
|
||||
relatedProductsLength: relatedProducts?.length
|
||||
});
|
||||
|
||||
// Set initial image when product loads
|
||||
useEffect(() => {
|
||||
if (product && !selectedImage) {
|
||||
@@ -48,16 +86,55 @@ export default function Product() {
|
||||
}
|
||||
}, [product]);
|
||||
|
||||
// AUTO-SELECT FIRST VARIATION (Issue #2 from report)
|
||||
useEffect(() => {
|
||||
if (product?.type === 'variable' && product.attributes && Object.keys(selectedAttributes).length === 0) {
|
||||
const initialAttributes: Record<string, string> = {};
|
||||
|
||||
product.attributes.forEach((attr: any) => {
|
||||
if (attr.variation && attr.options && attr.options.length > 0) {
|
||||
initialAttributes[attr.name] = attr.options[0];
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(initialAttributes).length > 0) {
|
||||
setSelectedAttributes(initialAttributes);
|
||||
}
|
||||
}
|
||||
}, [product]);
|
||||
|
||||
// Find matching variation when attributes change
|
||||
useEffect(() => {
|
||||
if (product?.type === 'variable' && product.variations && Object.keys(selectedAttributes).length > 0) {
|
||||
const variation = (product.variations as any[]).find(v => {
|
||||
return Object.entries(selectedAttributes).every(([key, value]) => {
|
||||
const attrKey = `attribute_${key.toLowerCase()}`;
|
||||
return v.attributes[attrKey] === value.toLowerCase();
|
||||
if (!v.attributes) return false;
|
||||
|
||||
return Object.entries(selectedAttributes).every(([attrName, attrValue]) => {
|
||||
const normalizedValue = attrValue.toLowerCase().trim();
|
||||
|
||||
// Check all attribute keys in variation (case-insensitive)
|
||||
for (const [vKey, vValue] of Object.entries(v.attributes)) {
|
||||
const vKeyLower = vKey.toLowerCase();
|
||||
const attrNameLower = attrName.toLowerCase();
|
||||
|
||||
if (vKeyLower === `attribute_${attrNameLower}` ||
|
||||
vKeyLower === `attribute_pa_${attrNameLower}` ||
|
||||
vKeyLower === attrNameLower) {
|
||||
|
||||
const varValueNormalized = String(vValue).toLowerCase().trim();
|
||||
if (varValueNormalized === normalizedValue) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
setSelectedVariation(variation || null);
|
||||
} else if (product?.type !== 'variable') {
|
||||
setSelectedVariation(null);
|
||||
}
|
||||
}, [selectedAttributes, product]);
|
||||
|
||||
@@ -68,6 +145,25 @@ export default function Product() {
|
||||
}
|
||||
}, [selectedVariation]);
|
||||
|
||||
// Build complete image gallery including variation images (BEFORE early returns)
|
||||
const allImages = React.useMemo(() => {
|
||||
if (!product) return [];
|
||||
|
||||
const images = [...(product.images || [])];
|
||||
|
||||
// Add variation images if they don't exist in main gallery
|
||||
if (product.type === 'variable' && product.variations) {
|
||||
(product.variations as any[]).forEach(variation => {
|
||||
if (variation.image && !images.includes(variation.image)) {
|
||||
images.push(variation.image);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out any falsy values (false, null, undefined, empty strings)
|
||||
return images.filter(img => img && typeof img === 'string' && img.trim() !== '');
|
||||
}, [product]);
|
||||
|
||||
// Scroll thumbnails
|
||||
const scrollThumbnails = (direction: 'left' | 'right') => {
|
||||
if (thumbnailsRef.current) {
|
||||
@@ -107,10 +203,17 @@ export default function Product() {
|
||||
addItem({
|
||||
key: `${product.id}${selectedVariation ? `-${selectedVariation.id}` : ''}`,
|
||||
product_id: product.id,
|
||||
variation_id: selectedVariation?.id,
|
||||
name: product.name,
|
||||
price: parseFloat(selectedVariation?.price || product.price),
|
||||
quantity,
|
||||
image: selectedImage || product.image,
|
||||
virtual: product.virtual,
|
||||
downloadable: product.downloadable,
|
||||
// Use selectedAttributes from state (user's selections) for variable products
|
||||
attributes: product.type === 'variable' && Object.keys(selectedAttributes).length > 0
|
||||
? selectedAttributes
|
||||
: undefined,
|
||||
});
|
||||
|
||||
toast.success(`${product.name} added to cart!`, {
|
||||
@@ -159,86 +262,120 @@ export default function Product() {
|
||||
);
|
||||
}
|
||||
|
||||
// Price calculation - FIXED
|
||||
const currentPrice = selectedVariation?.price || product.price;
|
||||
const regularPrice = selectedVariation?.regular_price || product.regular_price;
|
||||
const isOnSale = selectedVariation ? parseFloat(selectedVariation.sale_price || '0') > 0 : product.on_sale;
|
||||
const isOnSale = regularPrice && currentPrice && parseFloat(currentPrice) < parseFloat(regularPrice);
|
||||
const stockStatus = selectedVariation?.in_stock !== undefined ? (selectedVariation.in_stock ? 'instock' : 'outofstock') : product.stock_status;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="max-w-6xl mx-auto py-8">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-6 text-sm">
|
||||
<Link to="/shop" className="text-gray-600 hover:text-gray-900">
|
||||
Shop
|
||||
</Link>
|
||||
<span className="mx-2 text-gray-400">/</span>
|
||||
<span className="text-gray-900">{product.name}</span>
|
||||
</nav>
|
||||
{elements.breadcrumbs && (
|
||||
<nav className="mb-6 text-sm">
|
||||
<Link to="/shop" className="text-gray-600 hover:text-gray-900">
|
||||
Shop
|
||||
</Link>
|
||||
<span className="mx-2 text-gray-400">/</span>
|
||||
<span className="text-gray-900">{product.name}</span>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8 lg:gap-12">
|
||||
<div className={`grid gap-6 lg:gap-12 ${layout.image_position === 'right' ? 'lg:grid-cols-[42%_58%]' : 'lg:grid-cols-[58%_42%]'}`}>
|
||||
{/* Product Images */}
|
||||
<div>
|
||||
{/* Main Image */}
|
||||
<div className="relative w-full aspect-square rounded-lg overflow-hidden bg-gray-100 mb-4">
|
||||
<div className={`lg:sticky lg:top-8 lg:self-start ${layout.image_position === 'right' ? 'lg:order-2' : ''}`}>
|
||||
{/* Main Image - ENHANCED */}
|
||||
<div className="relative w-full aspect-square rounded-2xl overflow-hidden bg-gray-50 mb-6">
|
||||
{selectedImage ? (
|
||||
<img
|
||||
src={selectedImage}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover"
|
||||
className="w-full !h-full object-contain p-8"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
||||
No image
|
||||
<div className="!h-full flex items-center justify-center text-gray-400">
|
||||
<div className="text-center">
|
||||
<svg className="w-24 h-24 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p className="text-sm">No image available</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Sale Badge on Image */}
|
||||
{isOnSale && (
|
||||
<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>
|
||||
|
||||
{/* Thumbnail Slider */}
|
||||
{product.images && product.images.length > 1 && (
|
||||
<div className="relative">
|
||||
{/* Dots Navigation - Show based on gallery_style */}
|
||||
{allImages && allImages.length > 1 && layout.gallery_style === 'dots' && (
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
<div className="flex gap-2">
|
||||
{allImages.map((img, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
className={`w-2 h-2 rounded-full transition-all ${
|
||||
selectedImage === img
|
||||
? 'bg-primary w-6'
|
||||
: 'bg-gray-300 hover:bg-gray-400'
|
||||
}`}
|
||||
aria-label={`View image ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thumbnail Slider - Show based on gallery_style */}
|
||||
{allImages && allImages.length > 1 && layout.gallery_style === 'thumbnails' && (
|
||||
<div className="relative w-full overflow-hidden">
|
||||
{/* Left Arrow */}
|
||||
{product.images.length > 4 && (
|
||||
{allImages.length > 4 && (
|
||||
<button
|
||||
onClick={() => scrollThumbnails('left')}
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 bg-white shadow-lg rounded-full p-2 hover:bg-gray-100 transition-colors"
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 bg-white shadow-xl rounded-full p-2.5 hover:bg-gray-50 transition-all border-2 border-gray-200"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Scrollable Thumbnails */}
|
||||
<div
|
||||
ref={thumbnailsRef}
|
||||
className="flex gap-2 overflow-x-auto scroll-smooth scrollbar-hide px-8"
|
||||
className="flex gap-3 overflow-x-auto scroll-smooth scrollbar-hide px-10"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{product.images.map((img, index) => (
|
||||
{allImages.map((img, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
className={`flex-shrink-0 w-20 h-20 rounded-lg overflow-hidden border-2 transition-all ${
|
||||
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-2 ring-primary ring-offset-2'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
? '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"
|
||||
className="w-full !h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right Arrow */}
|
||||
{product.images.length > 4 && (
|
||||
{allImages.length > 4 && (
|
||||
<button
|
||||
onClick={() => scrollThumbnails('right')}
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 bg-white shadow-lg rounded-full p-2 hover:bg-gray-100 transition-colors"
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 bg-white shadow-xl rounded-full p-2.5 hover:bg-gray-50 transition-all border-2 border-gray-200"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -247,69 +384,80 @@ export default function Product() {
|
||||
|
||||
{/* Product Info */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-4">{product.name}</h1>
|
||||
{/* Product Title - PRIMARY HIERARCHY - SERIF FONT */}
|
||||
<h1 className="text-2xl md:text-3xl lg:text-4xl font-serif font-light mb-4 leading-tight text-gray-900">{product.name}</h1>
|
||||
|
||||
{/* Price */}
|
||||
{/* Price - SECONDARY (per UI/UX Guide) */}
|
||||
<div className="mb-6">
|
||||
{isOnSale && regularPrice ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-3xl font-bold text-red-600">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-3xl font-bold text-gray-900">
|
||||
{formatPrice(currentPrice)}
|
||||
</span>
|
||||
<span className="text-xl text-gray-400 line-through">
|
||||
<span className="text-xl text-gray-400 line-through ml-3">
|
||||
{formatPrice(regularPrice)}
|
||||
</span>
|
||||
<span className="bg-red-100 text-red-600 px-2 py-1 rounded text-sm font-semibold">
|
||||
SALE
|
||||
<span className="inline-block bg-red-50 text-red-600 px-3 py-1 rounded-md text-sm font-semibold ml-3">
|
||||
Save {Math.round((1 - parseFloat(currentPrice) / parseFloat(regularPrice)) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-3xl font-bold">{formatPrice(currentPrice)}</span>
|
||||
<span className="text-3xl font-bold text-gray-900">{formatPrice(currentPrice)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stock Status */}
|
||||
{/* Stock Status Badge */}
|
||||
<div className="mb-6">
|
||||
{stockStatus === 'instock' ? (
|
||||
<span className="text-green-600 font-medium flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-green-600 rounded-full"></span>
|
||||
In Stock
|
||||
</span>
|
||||
<div className="inline-flex items-center gap-2 text-green-700 text-sm font-medium">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>In Stock • Ships Today</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-red-600 font-medium flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-red-600 rounded-full"></span>
|
||||
Out of Stock
|
||||
</span>
|
||||
<div className="inline-flex items-center gap-2 text-red-700 text-sm font-medium">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Out of Stock</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Short Description */}
|
||||
{product.short_description && (
|
||||
<div
|
||||
className="prose prose-sm mb-6 text-gray-600"
|
||||
className="prose prose-sm text-gray-600 leading-relaxed mb-6 border-l-4 border-gray-200 pl-4"
|
||||
dangerouslySetInnerHTML={{ __html: product.short_description }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Variation Selector */}
|
||||
{/* Variation Selector - PILLS (per UI/UX Guide) */}
|
||||
{product.type === 'variable' && product.attributes && product.attributes.length > 0 && (
|
||||
<div className="mb-6 space-y-4">
|
||||
{product.attributes.map((attr: any, index: number) => (
|
||||
attr.variation && (
|
||||
<div key={index}>
|
||||
<label className="block font-medium mb-2 text-sm">{attr.name}:</label>
|
||||
<select
|
||||
value={selectedAttributes[attr.name] || ''}
|
||||
onChange={(e) => handleAttributeChange(attr.name, e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2.5 focus:ring-2 focus:ring-primary focus:border-primary"
|
||||
>
|
||||
<option value="">Choose {attr.name}</option>
|
||||
{attr.options && attr.options.map((option: string, optIndex: number) => (
|
||||
<option key={optIndex} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="block font-medium mb-3 text-sm text-gray-700 uppercase tracking-wider">{attr.name}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{attr.options && attr.options.map((option: string, optIndex: number) => {
|
||||
const isSelected = selectedAttributes[attr.name] === option;
|
||||
return (
|
||||
<button
|
||||
key={optIndex}
|
||||
onClick={() => handleAttributeChange(attr.name, option)}
|
||||
className={`min-w-[48px] min-h-[48px] px-5 py-3 rounded-xl border-2 font-medium transition-all ${
|
||||
isSelected
|
||||
? 'bg-gray-900 text-white border-gray-900 shadow-lg'
|
||||
: 'bg-white text-gray-700 border-gray-200 hover:border-gray-400 hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
@@ -318,116 +466,168 @@ export default function Product() {
|
||||
|
||||
{/* Quantity & Add to Cart */}
|
||||
{stockStatus === 'instock' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 mb-6">
|
||||
{/* Quantity Selector */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="font-medium text-sm">Quantity:</label>
|
||||
<div className="flex items-center border border-gray-300 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-gray-700 uppercase tracking-wider">Quantity</span>
|
||||
<div className="flex items-center border-2 border-gray-200 rounded-xl">
|
||||
<button
|
||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
||||
className="p-2.5 hover:bg-gray-100 transition-colors"
|
||||
className="p-2.5 hover:bg-gray-100 transition-colors rounded-l-md"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-16 text-center border-x border-gray-300 py-2 focus:outline-none"
|
||||
min="1"
|
||||
className="w-14 text-center border-x-2 border-gray-200 focus:outline-none font-semibold"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setQuantity(quantity + 1)}
|
||||
className="p-2.5 hover:bg-gray-100 transition-colors"
|
||||
className="p-2.5 hover:bg-gray-100 transition-colors rounded-r-md"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
size="lg"
|
||||
className="flex-1 h-12 text-base"
|
||||
>
|
||||
<ShoppingCart className="mr-2 h-5 w-5" />
|
||||
Add to Cart
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="h-12 px-4"
|
||||
>
|
||||
<Heart className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Action Buttons - PROMINENT */}
|
||||
{/* Add to Cart Button */}
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className="w-full h-14 flex items-center justify-center gap-2 bg-gray-900 text-white rounded-xl font-semibold text-base hover:bg-gray-800 transition-all shadow-lg hover:shadow-xl"
|
||||
>
|
||||
<ShoppingCart className="h-5 w-5" />
|
||||
Add to Cart
|
||||
</button>
|
||||
<button className="w-full h-14 flex items-center justify-center gap-2 bg-white text-gray-900 rounded-xl font-semibold text-base border-2 border-gray-200 hover:border-gray-400 transition-all">
|
||||
<Heart className="h-5 w-5" />
|
||||
Add to Wishlist
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product Meta */}
|
||||
<div className="mt-8 pt-8 border-t border-gray-200 space-y-2 text-sm">
|
||||
{product.sku && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-600">SKU:</span>
|
||||
<span className="font-medium">{product.sku}</span>
|
||||
{/* Trust Badges - REDESIGNED */}
|
||||
<div className="grid grid-cols-3 gap-4 py-6 border-y border-gray-200">
|
||||
{/* Free Shipping */}
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-green-50 flex items-center justify-center mb-2">
|
||||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{product.categories && product.categories.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-600">Categories:</span>
|
||||
<span className="font-medium">
|
||||
{product.categories.map((cat: any) => cat.name).join(', ')}
|
||||
</span>
|
||||
<p className="font-medium text-sm text-gray-900">Free Shipping</p>
|
||||
<p className="text-xs text-gray-500 mt-1">On orders over $50</p>
|
||||
</div>
|
||||
|
||||
{/* Returns */}
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center mb-2">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<p className="font-medium text-sm text-gray-900">Easy Returns</p>
|
||||
<p className="text-xs text-gray-500 mt-1">30-day guarantee</p>
|
||||
</div>
|
||||
|
||||
{/* Secure */}
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-purple-50 flex items-center justify-center mb-2">
|
||||
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-medium text-sm text-gray-900">Secure Payment</p>
|
||||
<p className="text-xs text-gray-500 mt-1">SSL encrypted</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Meta */}
|
||||
{elements.product_meta && (
|
||||
<div className="space-y-2 text-sm border-t pt-4 border-gray-200">
|
||||
{product.sku && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-600">SKU:</span>
|
||||
<span className="font-medium">{product.sku}</span>
|
||||
</div>
|
||||
)}
|
||||
{product.categories && product.categories.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-gray-600">Categories:</span>
|
||||
<span className="font-medium">
|
||||
{product.categories.map((cat: any) => cat.name).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Share Buttons */}
|
||||
{elements.share_buttons && (
|
||||
<div className="flex items-center gap-3 pt-4 border-t border-gray-200">
|
||||
<span className="text-sm text-gray-600 font-medium">Share:</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = encodeURIComponent(window.location.href);
|
||||
window.open(`https://www.facebook.com/sharer/sharer.php?u=${url}`, '_blank', 'width=600,height=400');
|
||||
}}
|
||||
className="w-9 h-9 rounded-full bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center transition-colors"
|
||||
title="Share on Facebook"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = encodeURIComponent(window.location.href);
|
||||
const text = encodeURIComponent(product.name);
|
||||
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank', 'width=600,height=400');
|
||||
}}
|
||||
className="w-9 h-9 rounded-full bg-sky-500 hover:bg-sky-600 text-white flex items-center justify-center transition-colors"
|
||||
title="Share on Twitter"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = encodeURIComponent(window.location.href);
|
||||
const text = encodeURIComponent(product.name);
|
||||
window.open(`https://wa.me/?text=${text}%20${url}`, '_blank');
|
||||
}}
|
||||
className="w-9 h-9 rounded-full bg-green-600 hover:bg-green-700 text-white flex items-center justify-center transition-colors"
|
||||
title="Share on WhatsApp"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Tabs */}
|
||||
<div className="mt-12">
|
||||
{/* Tab Headers */}
|
||||
<div className="border-b border-gray-200">
|
||||
<div className="flex gap-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('description')}
|
||||
className={`pb-4 px-1 border-b-2 font-medium transition-colors ${
|
||||
activeTab === 'description'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
{/* Product Information - VERTICAL SECTIONS (Research: 27% overlook tabs) */}
|
||||
<div className="mt-12 space-y-6">
|
||||
{/* Description Section */}
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setActiveTab(activeTab === 'description' ? '' : 'description')}
|
||||
className="w-full flex items-center justify-between p-5 bg-gray-50 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<h2 className="text-xl font-bold text-gray-900">Product Description</h2>
|
||||
<svg
|
||||
className={`w-6 h-6 transition-transform ${activeTab === 'description' ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
Description
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('additional')}
|
||||
className={`pb-4 px-1 border-b-2 font-medium transition-colors ${
|
||||
activeTab === 'additional'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
Additional Information
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('reviews')}
|
||||
className={`pb-4 px-1 border-b-2 font-medium transition-colors ${
|
||||
activeTab === 'reviews'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
Reviews
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="py-8">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{activeTab === 'description' && (
|
||||
<div>
|
||||
<div className="p-6 bg-white">
|
||||
{product.description ? (
|
||||
<div
|
||||
className="prose prose-sm max-w-none"
|
||||
@@ -438,18 +638,35 @@ export default function Product() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Specifications Section - SCANNABLE TABLE */}
|
||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setActiveTab(activeTab === 'additional' ? '' : 'additional')}
|
||||
className="w-full flex items-center justify-between p-5 bg-gray-50 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<h2 className="text-xl font-bold text-gray-900">Specifications</h2>
|
||||
<svg
|
||||
className={`w-6 h-6 transition-transform ${activeTab === 'additional' ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{activeTab === 'additional' && (
|
||||
<div>
|
||||
<div className="bg-white">
|
||||
{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">
|
||||
<td className="py-3 pr-4 font-medium text-gray-900 w-1/3">
|
||||
<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">
|
||||
{attr.name}
|
||||
</td>
|
||||
<td className="py-3 text-gray-600">
|
||||
<td className="py-4 px-6 text-gray-700">
|
||||
{Array.isArray(attr.options) ? attr.options.join(', ') : attr.options}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -457,19 +674,214 @@ export default function Product() {
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="text-gray-600">No additional information available.</p>
|
||||
<p className="p-6 text-gray-600">No specifications available.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reviews Section - HYBRID APPROACH */}
|
||||
{elements.reviews && reviewSettings.placement === 'product_page' && (
|
||||
// Show reviews only if: 1) not hiding when empty, OR 2) has reviews
|
||||
(!reviewSettings.hide_if_empty || (product.review_count && product.review_count > 0)) && (
|
||||
<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"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-xl font-bold text-gray-900">Customer Reviews</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<svg key={star} className={`w-5 h-5 ${star <= (product.average_rating || 0) ? 'text-yellow-400' : 'text-gray-300'} fill-current`} viewBox="0 0 20 20">
|
||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-gray-600 font-medium">
|
||||
{product.average_rating || 0} ({product.review_count || 0} reviews)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-6 h-6 transition-transform ${activeTab === 'reviews' ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{activeTab === 'reviews' && (
|
||||
<div>
|
||||
<p className="text-gray-600">Reviews coming soon...</p>
|
||||
<div className="p-6 bg-white space-y-6">
|
||||
{/* Review Summary */}
|
||||
<div className="flex items-start gap-8 pb-6 border-b">
|
||||
<div className="text-center">
|
||||
<div className="text-5xl font-bold text-gray-900 mb-2">5.0</div>
|
||||
<div className="flex mb-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<svg key={star} className="w-5 h-5 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Based on 128 reviews</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
{[5, 4, 3, 2, 1].map((rating) => (
|
||||
<div key={rating} className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-600 w-8">{rating} ★</span>
|
||||
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-yellow-400"
|
||||
style={{ width: rating === 5 ? '95%' : rating === 4 ? '4%' : '1%' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600 w-12">{rating === 5 ? '122' : rating === 4 ? '5' : '1'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sample Reviews */}
|
||||
<div className="space-y-6">
|
||||
{/* Review 1 */}
|
||||
<div className="border-b pb-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-semibold">
|
||||
JD
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-semibold text-gray-900">John Doe</span>
|
||||
<span className="text-sm text-gray-500">• 2 days ago</span>
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Verified Purchase</span>
|
||||
</div>
|
||||
<div className="flex mb-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<svg key={star} className="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-gray-700 leading-relaxed mb-3">
|
||||
Absolutely love this product! The quality exceeded my expectations and it arrived quickly.
|
||||
The packaging was also very professional. Highly recommend!
|
||||
</p>
|
||||
<button className="text-sm text-gray-600 hover:text-gray-900">Helpful (24)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Review 2 */}
|
||||
<div className="border-b pb-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-semibold">
|
||||
SM
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-semibold text-gray-900">Sarah Miller</span>
|
||||
<span className="text-sm text-gray-500">• 1 week ago</span>
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Verified Purchase</span>
|
||||
</div>
|
||||
<div className="flex mb-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<svg key={star} className="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-gray-700 leading-relaxed mb-3">
|
||||
Great value for money. Works exactly as described. Customer service was also very responsive
|
||||
when I had questions before purchasing.
|
||||
</p>
|
||||
<button className="text-sm text-gray-600 hover:text-gray-900">Helpful (18)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Review 3 */}
|
||||
<div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-semibold">
|
||||
MJ
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-semibold text-gray-900">Michael Johnson</span>
|
||||
<span className="text-sm text-gray-500">• 2 weeks ago</span>
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Verified Purchase</span>
|
||||
</div>
|
||||
<div className="flex mb-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<svg key={star} className="w-4 h-4 text-yellow-400 fill-current" viewBox="0 0 20 20">
|
||||
<path d="M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-gray-700 leading-relaxed mb-3">
|
||||
Perfect! This is my third purchase and I keep coming back. The consistency in quality is impressive.
|
||||
Will definitely buy again.
|
||||
</p>
|
||||
<button className="text-sm text-gray-600 hover:text-gray-900">Helpful (32)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="w-full py-3 border-2 border-gray-200 rounded-xl font-semibold text-gray-900 hover:border-gray-400 transition-all">
|
||||
Load More Reviews
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
{relatedProducts.map((relatedProduct) => (
|
||||
<ProductCard key={relatedProduct.id} product={relatedProduct} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
{/* 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>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, Filter } from 'lucide-react';
|
||||
import { Search, Filter, X } from 'lucide-react';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useCartStore } from '@/lib/cart/store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -9,16 +9,37 @@ import Container from '@/components/Layout/Container';
|
||||
import { ProductCard } from '@/components/ProductCard';
|
||||
import { toast } from 'sonner';
|
||||
import { useTheme, useLayout } from '@/contexts/ThemeContext';
|
||||
import { useShopSettings } from '@/hooks/useAppearanceSettings';
|
||||
import type { ProductsResponse, ProductCategory, Product } from '@/types/product';
|
||||
|
||||
export default function Shop() {
|
||||
const navigate = useNavigate();
|
||||
const { config } = useTheme();
|
||||
const { layout } = useLayout();
|
||||
const { layout: shopLayout, elements } = useShopSettings();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [sortBy, setSortBy] = useState('');
|
||||
const { addItem } = useCartStore();
|
||||
|
||||
// Map grid columns setting to Tailwind classes
|
||||
const gridColsClass = {
|
||||
'2': 'grid-cols-1 sm:grid-cols-2',
|
||||
'3': 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
||||
'4': 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
|
||||
'5': 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5',
|
||||
'6': 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6',
|
||||
}[shopLayout.grid_columns] || 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4';
|
||||
|
||||
// Masonry column classes (CSS columns)
|
||||
const masonryColsClass = {
|
||||
'2': 'columns-1 sm:columns-2',
|
||||
'3': 'columns-1 sm:columns-2 lg:columns-3',
|
||||
'4': 'columns-1 sm:columns-2 lg:columns-3 xl:columns-4',
|
||||
}[shopLayout.grid_columns] || 'columns-1 sm:columns-2 lg:columns-3';
|
||||
|
||||
const isMasonry = shopLayout.grid_style === 'masonry';
|
||||
|
||||
// Fetch products
|
||||
const { data: productsData, isLoading: productsLoading } = useQuery<ProductsResponse>({
|
||||
@@ -52,6 +73,8 @@ export default function Shop() {
|
||||
price: parseFloat(product.price),
|
||||
quantity: 1,
|
||||
image: product.image,
|
||||
virtual: product.virtual,
|
||||
downloadable: product.downloadable,
|
||||
});
|
||||
|
||||
toast.success(`${product.name} added to cart!`, {
|
||||
@@ -75,42 +98,72 @@ export default function Shop() {
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-8">
|
||||
{/* Search */}
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search products..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
{(elements.search_bar || elements.category_filter) && (
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-8">
|
||||
{/* Search */}
|
||||
{elements.search_bar && (
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search products..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-10 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-100 rounded-full transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category Filter */}
|
||||
{categories && categories.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((cat: any) => (
|
||||
<option key={cat.id} value={cat.slug}>
|
||||
{cat.name} ({cat.count})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Category Filter */}
|
||||
{elements.category_filter && categories && categories.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((cat: any) => (
|
||||
<option key={cat.id} value={cat.slug}>
|
||||
{cat.name} ({cat.count})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sort Dropdown */}
|
||||
{elements.sort_dropdown && (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="">Default sorting</option>
|
||||
<option value="popularity">Sort by popularity</option>
|
||||
<option value="rating">Sort by average rating</option>
|
||||
<option value="date">Sort by latest</option>
|
||||
<option value="price">Sort by price: low to high</option>
|
||||
<option value="price-desc">Sort by price: high to low</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Products Grid */}
|
||||
{productsLoading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<div className={`grid ${gridColsClass} gap-6`}>
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse">
|
||||
<div className="bg-gray-200 aspect-square rounded-lg mb-4" />
|
||||
@@ -121,13 +174,14 @@ export default function Shop() {
|
||||
</div>
|
||||
) : productsData?.products && productsData.products.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<div className={isMasonry ? `${masonryColsClass} gap-6` : `grid ${gridColsClass} gap-6`}>
|
||||
{productsData.products.map((product: any) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onAddToCart={handleAddToCart}
|
||||
/>
|
||||
<div key={product.id} className={isMasonry ? 'mb-6 break-inside-avoid' : ''}>
|
||||
<ProductCard
|
||||
product={product}
|
||||
onAddToCart={handleAddToCart}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
472
customer-spa/src/pages/ThankYou/index.tsx
Normal file
472
customer-spa/src/pages/ThankYou/index.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useThankYouSettings } from '@/hooks/useAppearanceSettings';
|
||||
import Container from '@/components/Layout/Container';
|
||||
import { CheckCircle, ShoppingBag, Package, Truck } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
|
||||
export default function ThankYou() {
|
||||
const { orderId } = useParams<{ orderId: string }>();
|
||||
const { template, headerVisibility, footerVisibility, backgroundColor, customMessage, elements, isLoading: settingsLoading } = useThankYouSettings();
|
||||
const [order, setOrder] = useState<any>(null);
|
||||
const [relatedProducts, setRelatedProducts] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOrderData = async () => {
|
||||
if (!orderId) return;
|
||||
|
||||
try {
|
||||
const orderData = await apiClient.get(`/orders/${orderId}`) as any;
|
||||
setOrder(orderData);
|
||||
|
||||
// Fetch related products from first order item
|
||||
if (orderData.items && orderData.items.length > 0) {
|
||||
const firstProductId = orderData.items[0].product_id;
|
||||
const productData = await apiClient.get(`/shop/products/${firstProductId}`) as any;
|
||||
if (productData.related_products && productData.related_products.length > 0) {
|
||||
setRelatedProducts(productData.related_products.slice(0, 4));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch order data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchOrderData();
|
||||
}, [orderId]);
|
||||
|
||||
if (loading || settingsLoading || !order) {
|
||||
return (
|
||||
<Container>
|
||||
<div className="py-20 text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 mx-auto"></div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const statusMap: Record<string, string> = {
|
||||
'pending': 'Pending Payment',
|
||||
'processing': 'Processing',
|
||||
'on-hold': 'On Hold',
|
||||
'completed': 'Completed',
|
||||
'cancelled': 'Cancelled',
|
||||
'refunded': 'Refunded',
|
||||
'failed': 'Failed',
|
||||
};
|
||||
return statusMap[status] || status.charAt(0).toUpperCase() + status.slice(1);
|
||||
};
|
||||
|
||||
// Render receipt style template
|
||||
if (template === 'receipt') {
|
||||
return (
|
||||
<div style={{ backgroundColor }}>
|
||||
<Container>
|
||||
<div className="py-12 max-w-2xl mx-auto">
|
||||
{/* Receipt Container */}
|
||||
<div className="bg-white shadow-lg" style={{ fontFamily: 'monospace' }}>
|
||||
{/* Receipt Header */}
|
||||
<div className="border-b-2 border-dashed border-gray-400 p-8 text-center">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-green-100 rounded-full mb-4">
|
||||
<CheckCircle className="w-10 h-10 text-green-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold mb-2">PAYMENT RECEIPT</h1>
|
||||
<p className="text-gray-600">Order #{order.number}</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{new Date().toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Custom Message */}
|
||||
<div className="px-8 py-4 bg-gray-50 border-b border-dashed border-gray-300">
|
||||
<p className="text-sm text-center text-gray-700">{customMessage}</p>
|
||||
</div>
|
||||
|
||||
{/* Order Items */}
|
||||
{elements.order_details && (
|
||||
<div className="p-8">
|
||||
<div className="border-b-2 border-gray-900 pb-2 mb-4">
|
||||
<div className="flex justify-between text-sm font-bold">
|
||||
<span>ITEM</span>
|
||||
<span>AMOUNT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{order.items.map((item: any) => (
|
||||
<div key={item.id}>
|
||||
<div className="flex justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{item.name}</div>
|
||||
<div className="text-sm text-gray-600">Qty: {item.qty}</div>
|
||||
</div>
|
||||
<div className="text-right font-mono">
|
||||
{formatPrice(item.total)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
<div className="border-t-2 border-dashed border-gray-400 mt-6 pt-4 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>SUBTOTAL:</span>
|
||||
<span className="font-mono">{formatPrice(parseFloat(order.subtotal || 0))}</span>
|
||||
</div>
|
||||
{parseFloat(order.shipping_total || 0) > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>SHIPPING:</span>
|
||||
<span className="font-mono">{formatPrice(parseFloat(order.shipping_total))}</span>
|
||||
</div>
|
||||
)}
|
||||
{parseFloat(order.tax_total || 0) > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>TAX:</span>
|
||||
<span className="font-mono">{formatPrice(parseFloat(order.tax_total))}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-lg font-bold border-t-2 border-gray-900 pt-2 mt-2">
|
||||
<span>TOTAL:</span>
|
||||
<span className="font-mono">{formatPrice(parseFloat(order.total || 0))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment & Status Info */}
|
||||
<div className="border-t-2 border-dashed border-gray-400 mt-6 pt-4 space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Payment Method:</span>
|
||||
<span className="font-medium uppercase">{order.payment_method || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Status:</span>
|
||||
<span className="font-medium uppercase">{getStatusLabel(order.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer Info */}
|
||||
<div className="border-t-2 border-dashed border-gray-400 mt-6 pt-4">
|
||||
<div className="text-xs text-gray-600 uppercase mb-2">Bill To:</div>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
{order.billing?.first_name} {order.billing?.last_name}
|
||||
</div>
|
||||
<div className="text-gray-600">{order.billing?.email}</div>
|
||||
{order.billing?.phone && (
|
||||
<div className="text-gray-600">{order.billing.phone}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Receipt Footer */}
|
||||
<div className="border-t-2 border-dashed border-gray-400 p-8 text-center bg-gray-50">
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
{order.status === 'pending'
|
||||
? 'Awaiting payment confirmation'
|
||||
: 'Thank you for your business!'}
|
||||
</p>
|
||||
|
||||
{elements.continue_shopping_button && (
|
||||
<Link to="/shop">
|
||||
<Button size="lg" className="gap-2">
|
||||
<ShoppingBag className="w-5 h-5" />
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Related Products */}
|
||||
{elements.related_products && relatedProducts.length > 0 && (
|
||||
<div className="mt-12">
|
||||
<h2 className="text-2xl font-bold mb-6">You May Also Like</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{relatedProducts.map((product: any) => (
|
||||
<Link key={product.id} to={`/product/${product.slug}`} className="group no-underline">
|
||||
<div className="bg-white border rounded-lg overflow-hidden hover:shadow-lg transition-shadow">
|
||||
<div className="aspect-square bg-gray-100 flex items-center justify-center">
|
||||
{product.image ? (
|
||||
<img src={product.image} alt={product.name} className="w-full !h-full object-cover" />
|
||||
) : (
|
||||
<Package className="w-12 h-12 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<h3 className="font-medium text-sm text-gray-900 group-hover:text-blue-600 transition-colors line-clamp-2">
|
||||
{product.name}
|
||||
</h3>
|
||||
<p className="text-sm font-bold text-gray-900 mt-1">
|
||||
{formatPrice(parseFloat(product.price || 0))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
<div className="border-t-2 border-dashed border-gray-400 mt-6 pt-4 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>SUBTOTAL:</span>
|
||||
<span className="font-mono">{formatPrice(parseFloat(order.subtotal || 0))}</span>
|
||||
</div>
|
||||
{parseFloat(order.shipping_total || 0) > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>SHIPPING:</span>
|
||||
<span className="font-mono">{formatPrice(parseFloat(order.shipping_total))}</span>
|
||||
</div>
|
||||
)}
|
||||
{parseFloat(order.tax_total || 0) > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>TAX:</span>
|
||||
<span className="font-mono">{formatPrice(parseFloat(order.tax_total))}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-lg font-bold border-t-2 border-gray-900 pt-2 mt-2">
|
||||
<span>TOTAL:</span>
|
||||
<span className="font-mono">{formatPrice(parseFloat(order.total || 0))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment & Status Info */}
|
||||
<div className="border-t-2 border-dashed border-gray-400 mt-6 pt-4 space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Payment Method:</span>
|
||||
<span className="font-medium uppercase">{order.payment_method || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Status:</span>
|
||||
<span className="font-medium uppercase">{getStatusLabel(order.status)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer Info */}
|
||||
<div className="border-t-2 border-dashed border-gray-400 mt-6 pt-4">
|
||||
<div className="text-xs text-gray-600 uppercase mb-2">Bill To:</div>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
{order.billing?.first_name} {order.billing?.last_name}
|
||||
</div>
|
||||
<div className="text-gray-600">{order.billing?.email}</div>
|
||||
{order.billing?.phone && (
|
||||
<div className="text-gray-600">{order.billing.phone}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Receipt Footer */}
|
||||
<div className="border-t-2 border-dashed border-gray-400 p-8 text-center bg-gray-50">
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
{order.status === 'pending'
|
||||
? 'Awaiting payment confirmation'
|
||||
: 'Thank you for your business!'}
|
||||
</p>
|
||||
|
||||
{elements.continue_shopping_button && (
|
||||
<Link to="/shop">
|
||||
<Button size="lg" className="gap-2">
|
||||
<ShoppingBag className="w-5 h-5" />
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Related Products */}
|
||||
{elements.related_products && relatedProducts.length > 0 && (
|
||||
<div className="mt-12">
|
||||
<h2 className="text-2xl font-bold mb-6">You May Also Like</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{relatedProducts.map((product: any) => (
|
||||
<Link key={product.id} to={`/product/${product.slug}`} className="group no-underline">
|
||||
<div className="bg-white border rounded-lg overflow-hidden hover:shadow-lg transition-shadow">
|
||||
<div className="aspect-square bg-gray-100 flex items-center justify-center">
|
||||
{product.image ? (
|
||||
<img src={product.image} alt={product.name} className="w-full !h-full object-cover" />
|
||||
) : (
|
||||
<Package className="w-12 h-12 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<h3 className="font-medium text-sm text-gray-900 group-hover:text-blue-600 transition-colors line-clamp-2">
|
||||
{product.name}
|
||||
</h3>
|
||||
<p className="text-sm font-bold text-gray-900 mt-1">
|
||||
{formatPrice(parseFloat(product.price || 0))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render basic style template (default)
|
||||
return (
|
||||
<div style={{ backgroundColor }}>
|
||||
<Container>
|
||||
<div className="py-12 max-w-3xl mx-auto">
|
||||
{/* Success Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4">
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Order Confirmed!</h1>
|
||||
<p className="text-gray-600">Order #{order.number}</p>
|
||||
</div>
|
||||
|
||||
{/* Custom Message */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
|
||||
<p className="text-gray-800 text-center">{customMessage}</p>
|
||||
</div>
|
||||
|
||||
{/* Order Details */}
|
||||
{elements.order_details && (
|
||||
<div className="bg-white border rounded-lg p-6 mb-6">
|
||||
<h2 className="text-xl font-bold mb-4">Order Details</h2>
|
||||
|
||||
{/* Order Items */}
|
||||
<div className="space-y-4 mb-6">
|
||||
{order.items.map((item: any) => (
|
||||
<div key={item.id} className="flex items-center gap-4 pb-4 border-b last:border-0">
|
||||
<div className="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
{item.image && typeof item.image === 'string' ? (
|
||||
<img src={item.image} alt={item.name} className="w-full !h-full object-cover rounded-lg" />
|
||||
) : (
|
||||
<Package className="w-8 h-8 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-gray-900">{item.name}</h3>
|
||||
<p className="text-sm text-gray-500">Quantity: {item.qty}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium text-gray-900">{formatPrice(item.total)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Order Summary */}
|
||||
<div className="border-t pt-4 space-y-2">
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Subtotal</span>
|
||||
<span>{formatPrice(parseFloat(order.subtotal || 0))}</span>
|
||||
</div>
|
||||
{parseFloat(order.shipping_total || 0) > 0 && (
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Shipping</span>
|
||||
<span>{formatPrice(parseFloat(order.shipping_total))}</span>
|
||||
</div>
|
||||
)}
|
||||
{parseFloat(order.tax_total || 0) > 0 && (
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Tax</span>
|
||||
<span>{formatPrice(parseFloat(order.tax_total))}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between font-bold text-lg text-gray-900 pt-2 border-t">
|
||||
<span>Total</span>
|
||||
<span>{formatPrice(parseFloat(order.total || 0))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer Info */}
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<h3 className="font-medium text-gray-900 mb-3">Customer Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-500 mb-1">Email</p>
|
||||
<p className="text-gray-900">{order.billing?.email || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500 mb-1">Phone</p>
|
||||
<p className="text-gray-900">{order.billing?.phone || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Status */}
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="flex items-center gap-3">
|
||||
<Truck className="w-5 h-5 text-blue-600" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Order Status: {getStatusLabel(order.status)}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{order.status === 'pending' ? 'Awaiting payment confirmation' : "We'll send you shipping updates via email"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Continue Shopping Button */}
|
||||
{elements.continue_shopping_button && (
|
||||
<div className="text-center">
|
||||
<Link to="/shop">
|
||||
<Button size="lg" className="gap-2">
|
||||
<ShoppingBag className="w-5 h-5" />
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Related Products */}
|
||||
{elements.related_products && relatedProducts.length > 0 && (
|
||||
<div className="mt-12">
|
||||
<h2 className="text-2xl font-bold mb-6">You May Also Like</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{relatedProducts.map((product: any) => (
|
||||
<Link key={product.id} to={`/product/${product.slug}`} className="group no-underline">
|
||||
<div className="bg-white border rounded-lg overflow-hidden hover:shadow-lg transition-shadow">
|
||||
<div className="aspect-square bg-gray-100 flex items-center justify-center">
|
||||
{product.image ? (
|
||||
<img src={product.image} alt={product.name} className="w-full !h-full object-cover" />
|
||||
) : (
|
||||
<Package className="w-12 h-12 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<h3 className="font-medium text-sm text-gray-900 group-hover:text-blue-600 transition-colors line-clamp-2">
|
||||
{product.name}
|
||||
</h3>
|
||||
<p className="text-sm font-bold text-gray-900 mt-1">
|
||||
{formatPrice(parseFloat(product.price || 0))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
235
customer-spa/src/styles/fonts.css
Normal file
235
customer-spa/src/styles/fonts.css
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* WooNooW Self-Hosted Fonts
|
||||
* GDPR-compliant, no external requests
|
||||
*/
|
||||
|
||||
/* Inter - Modern & Clean */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./fonts/inter/inter-v20-latin-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('./fonts/inter/inter-v20-latin-500.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('./fonts/inter/inter-v20-latin-600.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('./fonts/inter/inter-v20-latin-700.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Playfair Display - Editorial Heading */
|
||||
@font-face {
|
||||
font-family: 'Playfair Display';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./fonts/playfair-display/playfair-display-v40-latin-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Playfair Display';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('./fonts/playfair-display/playfair-display-v40-latin-500.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Playfair Display';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('./fonts/playfair-display/playfair-display-v40-latin-600.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Playfair Display';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('./fonts/playfair-display/playfair-display-v40-latin-700.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Source Sans 3 - Editorial Body */
|
||||
@font-face {
|
||||
font-family: 'Source Sans 3';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./fonts/source-sans-3/source-sans-3-v19-latin-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Source Sans 3';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('./fonts/source-sans-3/source-sans-3-v19-latin-500.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Source Sans 3';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('./fonts/source-sans-3/source-sans-3-v19-latin-600.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Source Sans 3';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('./fonts/source-sans-3/source-sans-3-v19-latin-700.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Poppins - Friendly Heading */
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./fonts/poppins/poppins-v24-latin-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('./fonts/poppins/poppins-v24-latin-500.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('./fonts/poppins/poppins-v24-latin-600.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('./fonts/poppins/poppins-v24-latin-700.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Open Sans - Friendly Body */
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./fonts/open-sans/open-sans-v44-latin-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('./fonts/open-sans/open-sans-v44-latin-500.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('./fonts/open-sans/open-sans-v44-latin-600.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('./fonts/open-sans/open-sans-v44-latin-700.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Cormorant Garamond - Elegant Heading */
|
||||
@font-face {
|
||||
font-family: 'Cormorant Garamond';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./fonts/cormorant-garamond/cormorant-garamond-v21-latin-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Cormorant Garamond';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('./fonts/cormorant-garamond/cormorant-garamond-v21-latin-500.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Cormorant Garamond';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('./fonts/cormorant-garamond/cormorant-garamond-v21-latin-600.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Cormorant Garamond';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('./fonts/cormorant-garamond/cormorant-garamond-v21-latin-700.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Lato - Elegant Body */
|
||||
@font-face {
|
||||
font-family: 'Lato';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./fonts/lato/lato-v25-latin-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Lato';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('./fonts/lato/lato-v25-latin-regular.woff2') format('woff2'); /* Fallback to 400 */
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Lato';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('./fonts/lato/lato-v25-latin-700.woff2') format('woff2'); /* Fallback to 700 */
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Lato';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('./fonts/lato/lato-v25-latin-700.woff2') format('woff2');
|
||||
}
|
||||
Reference in New Issue
Block a user