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:
@@ -5,7 +5,7 @@
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "vite --host woonoow.local --port 5174 --strictPort",
|
||||
"build": "vite build",
|
||||
"build": "vite build && cp -r public/fonts dist/",
|
||||
"preview": "vite preview --port 5174",
|
||||
"lint": "ESLINT_USE_FLAT_CONFIG=false eslint . --ext ts,tsx --report-unused-disable-directives"
|
||||
},
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
customer-spa/public/fonts/inter/inter-v20-latin-500.woff2
Normal file
BIN
customer-spa/public/fonts/inter/inter-v20-latin-500.woff2
Normal file
Binary file not shown.
BIN
customer-spa/public/fonts/inter/inter-v20-latin-600.woff2
Normal file
BIN
customer-spa/public/fonts/inter/inter-v20-latin-600.woff2
Normal file
Binary file not shown.
BIN
customer-spa/public/fonts/inter/inter-v20-latin-700.woff2
Normal file
BIN
customer-spa/public/fonts/inter/inter-v20-latin-700.woff2
Normal file
Binary file not shown.
BIN
customer-spa/public/fonts/inter/inter-v20-latin-regular.woff2
Normal file
BIN
customer-spa/public/fonts/inter/inter-v20-latin-regular.woff2
Normal file
Binary file not shown.
BIN
customer-spa/public/fonts/lato/lato-v25-latin-700.woff2
Normal file
BIN
customer-spa/public/fonts/lato/lato-v25-latin-700.woff2
Normal file
Binary file not shown.
BIN
customer-spa/public/fonts/lato/lato-v25-latin-regular.woff2
Normal file
BIN
customer-spa/public/fonts/lato/lato-v25-latin-regular.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
customer-spa/public/fonts/poppins/poppins-v24-latin-500.woff2
Normal file
BIN
customer-spa/public/fonts/poppins/poppins-v24-latin-500.woff2
Normal file
Binary file not shown.
BIN
customer-spa/public/fonts/poppins/poppins-v24-latin-600.woff2
Normal file
BIN
customer-spa/public/fonts/poppins/poppins-v24-latin-600.woff2
Normal file
Binary file not shown.
BIN
customer-spa/public/fonts/poppins/poppins-v24-latin-700.woff2
Normal file
BIN
customer-spa/public/fonts/poppins/poppins-v24-latin-700.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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');
|
||||
}
|
||||
@@ -5,6 +5,10 @@ module.exports = {
|
||||
theme: {
|
||||
container: { center: true, padding: "1rem" },
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
serif: ['Playfair Display', 'Georgia', 'serif'],
|
||||
},
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
|
||||
@@ -11,7 +11,8 @@ const key = readFileSync(resolve(__dirname, '../admin-spa/.cert/woonoow.local-ke
|
||||
const cert = readFileSync(resolve(__dirname, '../admin-spa/.cert/woonoow.local-cert.pem'));
|
||||
|
||||
export default defineConfig({
|
||||
base: '/',
|
||||
base: './',
|
||||
publicDir: 'public',
|
||||
plugins: [
|
||||
react({
|
||||
jsxRuntime: 'automatic',
|
||||
|
||||
Reference in New Issue
Block a user