feat: Add product images support with WP Media Library integration
- Add WP Media Library integration for product and variation images - Support images array (URLs) conversion to attachment IDs - Add images array to API responses (Admin & Customer SPA) - Implement drag-and-drop sortable images in Admin product form - Add image gallery thumbnails in Customer SPA product page - Initialize WooCommerce session for guest cart operations - Fix product variations and attributes display in Customer SPA - Add variation image field in Admin SPA Changes: - includes/Api/ProductsController.php: Handle images array, add to responses - includes/Frontend/ShopController.php: Add images array for customer SPA - includes/Frontend/CartController.php: Initialize WC session for guests - admin-spa/src/lib/wp-media.ts: Add openWPMediaGallery function - admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx: WP Media + sortable images - admin-spa/src/routes/Products/partials/tabs/VariationsTab.tsx: Add variation image field - customer-spa/src/pages/Product/index.tsx: Add gallery thumbnails display
This commit is contained in:
18
customer-spa/package-lock.json
generated
18
customer-spa/package-lock.json
generated
@@ -38,6 +38,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
||||
@@ -2742,6 +2743,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz",
|
||||
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -7253,6 +7264,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { HashRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Toaster } from 'sonner';
|
||||
|
||||
// Pages (will be created)
|
||||
// Theme
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { BaseLayout } from './layouts/BaseLayout';
|
||||
|
||||
// Pages
|
||||
import Shop from './pages/Shop';
|
||||
import Product from './pages/Product';
|
||||
import Cart from './pages/Cart';
|
||||
@@ -21,29 +25,56 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
// Get theme config from window (injected by PHP)
|
||||
const getThemeConfig = () => {
|
||||
const config = (window as any).woonoowCustomer?.theme;
|
||||
|
||||
// Default config if not provided
|
||||
return config || {
|
||||
mode: 'full',
|
||||
layout: 'modern',
|
||||
colors: {
|
||||
primary: '#3B82F6',
|
||||
secondary: '#8B5CF6',
|
||||
accent: '#10B981',
|
||||
},
|
||||
typography: {
|
||||
preset: 'professional',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
function App() {
|
||||
const themeConfig = getThemeConfig();
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter basename="/shop">
|
||||
<Routes>
|
||||
{/* Shop Routes */}
|
||||
<Route path="/" element={<Shop />} />
|
||||
<Route path="/product/:id" element={<Product />} />
|
||||
|
||||
{/* Cart & Checkout */}
|
||||
<Route path="/cart" element={<Cart />} />
|
||||
<Route path="/checkout" element={<Checkout />} />
|
||||
|
||||
{/* My Account */}
|
||||
<Route path="/account/*" element={<Account />} />
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
{/* Toast notifications */}
|
||||
<Toaster position="top-right" richColors />
|
||||
<ThemeProvider config={themeConfig}>
|
||||
<HashRouter>
|
||||
<BaseLayout>
|
||||
<Routes>
|
||||
{/* Shop Routes */}
|
||||
<Route path="/" element={<Shop />} />
|
||||
<Route path="/shop" element={<Shop />} />
|
||||
<Route path="/product/:slug" element={<Product />} />
|
||||
|
||||
{/* Cart & Checkout */}
|
||||
<Route path="/cart" element={<Cart />} />
|
||||
<Route path="/checkout" element={<Checkout />} />
|
||||
<Route path="/order-received/:orderId" element={<div>Thank You Page</div>} />
|
||||
|
||||
{/* My Account */}
|
||||
<Route path="/my-account/*" element={<Account />} />
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/shop" replace />} />
|
||||
</Routes>
|
||||
</BaseLayout>
|
||||
</HashRouter>
|
||||
|
||||
{/* Toast notifications */}
|
||||
<Toaster position="top-right" richColors />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
15
customer-spa/src/components/Layout/Container.tsx
Normal file
15
customer-spa/src/components/Layout/Container.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ContainerProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Container({ children, className }: ContainerProps) {
|
||||
return (
|
||||
<div className={cn('container-safe py-8', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
customer-spa/src/components/Layout/Footer.tsx
Normal file
93
customer-spa/src/components/Layout/Footer.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function Footer() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
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
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
Shipping Info
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
Returns
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</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>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
77
customer-spa/src/components/Layout/Header.tsx
Normal file
77
customer-spa/src/components/Layout/Header.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
19
customer-spa/src/components/Layout/Layout.tsx
Normal file
19
customer-spa/src/components/Layout/Layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Header />
|
||||
<main className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
273
customer-spa/src/components/ProductCard.tsx
Normal file
273
customer-spa/src/components/ProductCard.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ShoppingCart, Heart } from 'lucide-react';
|
||||
import { formatPrice, formatDiscount } from '@/lib/currency';
|
||||
import { Button } from './ui/button';
|
||||
import { useLayout } from '@/contexts/ThemeContext';
|
||||
|
||||
interface ProductCardProps {
|
||||
product: {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
price: string;
|
||||
regular_price?: string;
|
||||
sale_price?: string;
|
||||
image?: string;
|
||||
on_sale?: boolean;
|
||||
stock_status?: string;
|
||||
};
|
||||
onAddToCart?: (product: any) => void;
|
||||
}
|
||||
|
||||
export function ProductCard({ product, onAddToCart }: ProductCardProps) {
|
||||
const { isClassic, isModern, isBoutique, isLaunch } = useLayout();
|
||||
|
||||
const handleAddToCart = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onAddToCart?.(product);
|
||||
};
|
||||
|
||||
// Calculate discount if on sale
|
||||
const discount = product.on_sale && product.regular_price && product.sale_price
|
||||
? formatDiscount(parseFloat(product.regular_price), parseFloat(product.sale_price))
|
||||
: null;
|
||||
|
||||
// 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">
|
||||
{/* Image */}
|
||||
<div className="relative w-full h-64 overflow-hidden bg-gray-100" 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-300"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full !h-full flex items-center justify-center text-gray-400" style={{ fontSize: '1rem' }}>
|
||||
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">
|
||||
{discount}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="absolute top-2 left-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button className="p-2 bg-white rounded-full shadow-md hover:bg-gray-50 flex items-center justify-center">
|
||||
<Heart className="w-4 h-4 block" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
<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">
|
||||
{product.on_sale && product.regular_price ? (
|
||||
<>
|
||||
<span className="text-lg font-bold" style={{ color: 'var(--color-primary)' }}>
|
||||
{formatPrice(product.sale_price || product.price)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 line-through">
|
||||
{formatPrice(product.regular_price)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-lg font-bold text-gray-900">
|
||||
{formatPrice(product.price)}
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Modern Layout - Minimalist, clean
|
||||
if (isModern) {
|
||||
return (
|
||||
<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 }}>
|
||||
{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"
|
||||
/>
|
||||
) : (
|
||||
<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">
|
||||
{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>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="text-center">
|
||||
<h3 className="font-medium text-gray-900 mb-2 group-hover:text-primary transition-colors">
|
||||
{product.name}
|
||||
</h3>
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{product.on_sale && product.regular_price ? (
|
||||
<>
|
||||
<span className="font-semibold" style={{ color: 'var(--color-primary)' }}>
|
||||
{formatPrice(product.sale_price || product.price)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-400 line-through">
|
||||
{formatPrice(product.regular_price)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="font-semibold text-gray-900">
|
||||
{formatPrice(product.price)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Boutique Layout - Luxury, elegant
|
||||
if (isBoutique) {
|
||||
return (
|
||||
<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 }}>
|
||||
{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"
|
||||
/>
|
||||
) : (
|
||||
<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">
|
||||
{discount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="text-center font-serif">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-3 tracking-wide group-hover:text-primary transition-colors">
|
||||
{product.name}
|
||||
</h3>
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
{product.on_sale && product.regular_price ? (
|
||||
<>
|
||||
<span className="text-xl font-medium" style={{ color: 'var(--color-primary)' }}>
|
||||
{formatPrice(product.sale_price || product.price)}
|
||||
</span>
|
||||
<span className="text-gray-400 line-through">
|
||||
{formatPrice(product.regular_price)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xl font-medium text-gray-900">
|
||||
{formatPrice(product.price)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add to Cart Button */}
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
variant="outline"
|
||||
className="w-full font-serif tracking-wider"
|
||||
disabled={product.stock_status === 'outofstock'}
|
||||
>
|
||||
{product.stock_status === 'outofstock' ? 'OUT OF STOCK' : 'ADD TO CART'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Launch Layout - Funnel optimized (shouldn't show product grid, but just in case)
|
||||
return (
|
||||
<Link to={`/product/${product.slug}`} className="group">
|
||||
<div className="border rounded-lg overflow-hidden hover:shadow-md transition-shadow bg-white">
|
||||
<div className="relative w-full h-64 overflow-hidden bg-gray-100" style={{ fontSize: 0 }}>
|
||||
{product.image ? (
|
||||
<img
|
||||
src={product.image}
|
||||
alt={product.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" style={{ fontSize: '1rem' }}>
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 text-center">
|
||||
<h3 className="font-semibold text-gray-900 mb-2">{product.name}</h3>
|
||||
<div className="text-xl font-bold mb-3" style={{ color: 'var(--color-primary)' }}>
|
||||
{formatPrice(product.price)}
|
||||
</div>
|
||||
<Button onClick={handleAddToCart} className="w-full" size="lg">
|
||||
Buy Now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
106
customer-spa/src/components/WooCommerceHooks.tsx
Normal file
106
customer-spa/src/components/WooCommerceHooks.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
|
||||
interface WooCommerceHooksProps {
|
||||
context: 'product' | 'shop' | 'cart' | 'checkout';
|
||||
hookName: string;
|
||||
productId?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* WooCommerce Hook Bridge Component
|
||||
* Renders content from WooCommerce action hooks
|
||||
* Allows compatibility with WooCommerce plugins
|
||||
*/
|
||||
export function WooCommerceHooks({ context, hookName, productId, className }: WooCommerceHooksProps) {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['wc-hooks', context, productId],
|
||||
queryFn: async () => {
|
||||
const params: Record<string, any> = {};
|
||||
if (productId) {
|
||||
params.product_id = productId;
|
||||
}
|
||||
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
context: string;
|
||||
hooks: Record<string, string>;
|
||||
}>(`/hooks/${context}`, params);
|
||||
|
||||
return response;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
if (isLoading || !data?.hooks?.[hookName]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
dangerouslySetInnerHTML={{ __html: data.hooks[hookName] }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get all hooks for a context
|
||||
*/
|
||||
export function useWooCommerceHooks(context: 'product' | 'shop' | 'cart' | 'checkout', productId?: number) {
|
||||
return useQuery({
|
||||
queryKey: ['wc-hooks', context, productId],
|
||||
queryFn: async () => {
|
||||
const params: Record<string, any> = {};
|
||||
if (productId) {
|
||||
params.product_id = productId;
|
||||
}
|
||||
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
context: string;
|
||||
hooks: Record<string, string>;
|
||||
}>(`/hooks/${context}`, params);
|
||||
|
||||
return response.hooks || {};
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render multiple hooks in sequence
|
||||
*/
|
||||
interface HookSequenceProps {
|
||||
context: 'product' | 'shop' | 'cart' | 'checkout';
|
||||
hooks: string[];
|
||||
productId?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function HookSequence({ context, hooks, productId, className }: HookSequenceProps) {
|
||||
const { data: allHooks } = useWooCommerceHooks(context, productId);
|
||||
|
||||
if (!allHooks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hooks.map((hookName) => {
|
||||
const content = allHooks[hookName];
|
||||
if (!content) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={hookName}
|
||||
className={className}
|
||||
data-hook={hookName}
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
56
customer-spa/src/components/ui/button.tsx
Normal file
56
customer-spa/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
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",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
// Simplified: always render as button (asChild not supported for now)
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
120
customer-spa/src/components/ui/dialog.tsx
Normal file
120
customer-spa/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-[99999] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-[99999] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
207
customer-spa/src/contexts/ThemeContext.tsx
Normal file
207
customer-spa/src/contexts/ThemeContext.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import React, { createContext, useContext, useEffect, ReactNode } from 'react';
|
||||
|
||||
interface ThemeColors {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
accent: string;
|
||||
background?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface ThemeTypography {
|
||||
preset: 'professional' | 'modern' | 'elegant' | 'tech' | 'custom';
|
||||
customFonts?: {
|
||||
heading: string;
|
||||
body: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ThemeConfig {
|
||||
mode: 'disabled' | 'full' | 'checkout_only';
|
||||
layout: 'classic' | 'modern' | 'boutique' | 'launch';
|
||||
colors: ThemeColors;
|
||||
typography: ThemeTypography;
|
||||
}
|
||||
|
||||
interface ThemeContextValue {
|
||||
config: ThemeConfig;
|
||||
isFullSPA: boolean;
|
||||
isCheckoutOnly: boolean;
|
||||
isLaunchLayout: boolean;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
const TYPOGRAPHY_PRESETS = {
|
||||
professional: {
|
||||
heading: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
body: "'Lora', Georgia, serif",
|
||||
headingWeight: 700,
|
||||
bodyWeight: 400,
|
||||
},
|
||||
modern: {
|
||||
heading: "'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
body: "'Roboto', -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",
|
||||
headingWeight: 700,
|
||||
bodyWeight: 400,
|
||||
},
|
||||
tech: {
|
||||
heading: "'Space Grotesk', monospace",
|
||||
body: "'IBM Plex Mono', monospace",
|
||||
headingWeight: 700,
|
||||
bodyWeight: 400,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Load Google Fonts for typography preset
|
||||
*/
|
||||
function loadTypography(preset: string, customFonts?: { heading: string; body: string }) {
|
||||
// Remove existing font link if any
|
||||
const existingLink = document.getElementById('woonoow-fonts');
|
||||
if (existingLink) {
|
||||
existingLink.remove();
|
||||
}
|
||||
|
||||
if (preset === 'custom' && customFonts) {
|
||||
// TODO: Handle custom fonts
|
||||
return;
|
||||
}
|
||||
|
||||
const fontMap: Record<string, string[]> = {
|
||||
professional: ['Inter:400,600,700', 'Lora:400,700'],
|
||||
modern: ['Poppins:400,600,700', 'Roboto:400,700'],
|
||||
elegant: ['Playfair+Display:400,700', 'Source+Sans+Pro:400,700'],
|
||||
tech: ['Space+Grotesk:400,700', 'IBM+Plex+Mono:400,700'],
|
||||
};
|
||||
|
||||
const fonts = fontMap[preset];
|
||||
if (!fonts) return;
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.id = 'woonoow-fonts';
|
||||
link.href = `https://fonts.googleapis.com/css2?family=${fonts.join('&family=')}&display=swap`;
|
||||
link.rel = 'stylesheet';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate color shades from base color
|
||||
*/
|
||||
function generateColorShades(baseColor: string): Record<number, string> {
|
||||
// For now, just return the base color
|
||||
// TODO: Implement proper color shade generation
|
||||
return {
|
||||
50: baseColor,
|
||||
100: baseColor,
|
||||
200: baseColor,
|
||||
300: baseColor,
|
||||
400: baseColor,
|
||||
500: baseColor,
|
||||
600: baseColor,
|
||||
700: baseColor,
|
||||
800: baseColor,
|
||||
900: baseColor,
|
||||
};
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
config,
|
||||
children
|
||||
}: {
|
||||
config: ThemeConfig;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Inject color CSS variables
|
||||
root.style.setProperty('--color-primary', config.colors.primary);
|
||||
root.style.setProperty('--color-secondary', config.colors.secondary);
|
||||
root.style.setProperty('--color-accent', config.colors.accent);
|
||||
|
||||
if (config.colors.background) {
|
||||
root.style.setProperty('--color-background', config.colors.background);
|
||||
}
|
||||
if (config.colors.text) {
|
||||
root.style.setProperty('--color-text', config.colors.text);
|
||||
}
|
||||
|
||||
// Inject typography CSS variables
|
||||
const typoPreset = TYPOGRAPHY_PRESETS[config.typography.preset as keyof typeof TYPOGRAPHY_PRESETS];
|
||||
if (typoPreset) {
|
||||
root.style.setProperty('--font-heading', typoPreset.heading);
|
||||
root.style.setProperty('--font-body', typoPreset.body);
|
||||
root.style.setProperty('--font-weight-heading', typoPreset.headingWeight.toString());
|
||||
root.style.setProperty('--font-weight-body', typoPreset.bodyWeight.toString());
|
||||
}
|
||||
|
||||
// Load Google Fonts
|
||||
loadTypography(config.typography.preset, config.typography.customFonts);
|
||||
|
||||
// Add layout class to body
|
||||
document.body.classList.remove('layout-classic', 'layout-modern', 'layout-boutique', 'layout-launch');
|
||||
document.body.classList.add(`layout-${config.layout}`);
|
||||
|
||||
// Add mode class to body
|
||||
document.body.classList.remove('mode-disabled', 'mode-full', 'mode-checkout-only');
|
||||
document.body.classList.add(`mode-${config.mode}`);
|
||||
}, [config]);
|
||||
|
||||
const contextValue: ThemeContextValue = {
|
||||
config,
|
||||
isFullSPA: config.mode === 'full',
|
||||
isCheckoutOnly: config.mode === 'checkout_only',
|
||||
isLaunchLayout: config.layout === 'launch',
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access theme configuration
|
||||
*/
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if we're in a specific layout
|
||||
*/
|
||||
export function useLayout() {
|
||||
const { config } = useTheme();
|
||||
return {
|
||||
isClassic: config.layout === 'classic',
|
||||
isModern: config.layout === 'modern',
|
||||
isBoutique: config.layout === 'boutique',
|
||||
isLaunch: config.layout === 'launch',
|
||||
layout: config.layout,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check current mode
|
||||
*/
|
||||
export function useMode() {
|
||||
const { config, isFullSPA, isCheckoutOnly } = useTheme();
|
||||
return {
|
||||
isFullSPA,
|
||||
isCheckoutOnly,
|
||||
isDisabled: config.mode === 'disabled',
|
||||
mode: config.mode,
|
||||
};
|
||||
}
|
||||
261
customer-spa/src/layouts/BaseLayout.tsx
Normal file
261
customer-spa/src/layouts/BaseLayout.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useLayout } from '../contexts/ThemeContext';
|
||||
|
||||
interface BaseLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base Layout Component
|
||||
*
|
||||
* Renders the appropriate layout based on theme configuration
|
||||
*/
|
||||
export function BaseLayout({ children }: BaseLayoutProps) {
|
||||
const { layout } = useLayout();
|
||||
|
||||
// Dynamically import and render the appropriate layout
|
||||
switch (layout) {
|
||||
case 'classic':
|
||||
return <ClassicLayout>{children}</ClassicLayout>;
|
||||
case 'modern':
|
||||
return <ModernLayout>{children}</ModernLayout>;
|
||||
case 'boutique':
|
||||
return <BoutiqueLayout>{children}</BoutiqueLayout>;
|
||||
case 'launch':
|
||||
return <LaunchLayout>{children}</LaunchLayout>;
|
||||
default:
|
||||
return <ModernLayout>{children}</ModernLayout>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between h-20">
|
||||
{/* 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'}
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="classic-main flex-1">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Modern Layout - Minimalist, clean
|
||||
*/
|
||||
function ModernLayout({ children }: BaseLayoutProps) {
|
||||
return (
|
||||
<div className="modern-layout min-h-screen flex flex-col">
|
||||
<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">
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="modern-main flex-1">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<footer className="modern-footer bg-white border-t mt-auto">
|
||||
<div className="container mx-auto px-4 py-12 text-center">
|
||||
<div className="mb-6">
|
||||
<a href="/" className="text-2xl font-bold" style={{ color: 'var(--color-primary)' }}>
|
||||
Store Logo
|
||||
</a>
|
||||
</div>
|
||||
<nav className="flex justify-center space-x-6 mb-6">
|
||||
<a href="/shop" className="text-sm text-gray-600 hover:text-primary">Shop</a>
|
||||
<a href="/about" className="text-sm text-gray-600 hover:text-primary">About</a>
|
||||
<a href="/contact" className="text-sm text-gray-600 hover:text-primary">Contact</a>
|
||||
</nav>
|
||||
<p className="text-sm text-gray-600">
|
||||
© 2024 Your Store. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Boutique Layout - Luxury, elegant
|
||||
*/
|
||||
function BoutiqueLayout({ children }: BaseLayoutProps) {
|
||||
return (
|
||||
<div className="boutique-layout min-h-screen flex flex-col font-serif">
|
||||
<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">
|
||||
{/* 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'}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="boutique-main flex-1">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<footer className="boutique-footer bg-gray-50 border-t mt-auto">
|
||||
<div className="container mx-auto px-4 py-16 text-center">
|
||||
<div className="mb-8">
|
||||
<a href="/" className="text-3xl font-bold tracking-wide" style={{ color: 'var(--color-primary)' }}>
|
||||
BOUTIQUE
|
||||
</a>
|
||||
</div>
|
||||
<nav className="flex justify-center space-x-8 mb-8">
|
||||
<a href="/shop" className="text-sm uppercase tracking-wider text-gray-600 hover:text-primary">Shop</a>
|
||||
<a href="/about" className="text-sm uppercase tracking-wider text-gray-600 hover:text-primary">About</a>
|
||||
<a href="/contact" className="text-sm uppercase tracking-wider text-gray-600 hover:text-primary">Contact</a>
|
||||
</nav>
|
||||
<p className="text-sm text-gray-600 tracking-wide">
|
||||
© 2024 BOUTIQUE. ALL RIGHTS RESERVED.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch Layout - Single product funnel
|
||||
* Note: Landing page is custom (user's page builder)
|
||||
* WooNooW only takes over from checkout onwards
|
||||
*/
|
||||
function LaunchLayout({ children }: BaseLayoutProps) {
|
||||
const isCheckoutFlow = window.location.pathname.includes('/checkout') ||
|
||||
window.location.pathname.includes('/my-account') ||
|
||||
window.location.pathname.includes('/order-received');
|
||||
|
||||
if (!isCheckoutFlow) {
|
||||
// For non-checkout pages, use minimal layout
|
||||
return (
|
||||
<div className="launch-layout min-h-screen flex flex-col">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For checkout flow: minimal header, no footer
|
||||
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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="launch-main flex-1 py-8">
|
||||
<div className="container mx-auto px-4 max-w-2xl">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Minimal footer for checkout */}
|
||||
<footer className="launch-footer bg-white border-t py-4">
|
||||
<div className="container mx-auto px-4 text-center text-sm text-gray-600">
|
||||
© 2024 Your Store. Secure Checkout.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -88,35 +88,44 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const api = new ApiClient();
|
||||
|
||||
// Export API endpoints
|
||||
export const endpoints = {
|
||||
// Shop
|
||||
products: '/shop/products',
|
||||
product: (id: number) => `/shop/products/${id}`,
|
||||
categories: '/shop/categories',
|
||||
search: '/shop/search',
|
||||
|
||||
// Cart
|
||||
cart: '/cart',
|
||||
cartAdd: '/cart/add',
|
||||
cartUpdate: '/cart/update',
|
||||
cartRemove: '/cart/remove',
|
||||
cartCoupon: '/cart/apply-coupon',
|
||||
|
||||
// Checkout
|
||||
checkoutCalculate: '/checkout/calculate',
|
||||
checkoutCreate: '/checkout/create-order',
|
||||
paymentMethods: '/checkout/payment-methods',
|
||||
shippingMethods: '/checkout/shipping-methods',
|
||||
|
||||
// Account
|
||||
orders: '/account/orders',
|
||||
order: (id: number) => `/account/orders/${id}`,
|
||||
downloads: '/account/downloads',
|
||||
profile: '/account/profile',
|
||||
password: '/account/password',
|
||||
addresses: '/account/addresses',
|
||||
// API endpoints
|
||||
const endpoints = {
|
||||
shop: {
|
||||
products: '/shop/products',
|
||||
product: (id: number) => `/shop/products/${id}`,
|
||||
categories: '/shop/categories',
|
||||
search: '/shop/search',
|
||||
},
|
||||
cart: {
|
||||
get: '/cart',
|
||||
add: '/cart/add',
|
||||
update: '/cart/update',
|
||||
remove: '/cart/remove',
|
||||
applyCoupon: '/cart/apply-coupon',
|
||||
removeCoupon: '/cart/remove-coupon',
|
||||
},
|
||||
checkout: {
|
||||
calculate: '/checkout/calculate',
|
||||
create: '/checkout/create-order',
|
||||
paymentMethods: '/checkout/payment-methods',
|
||||
shippingMethods: '/checkout/shipping-methods',
|
||||
},
|
||||
account: {
|
||||
orders: '/account/orders',
|
||||
order: (id: number) => `/account/orders/${id}`,
|
||||
downloads: '/account/downloads',
|
||||
profile: '/account/profile',
|
||||
password: '/account/password',
|
||||
addresses: '/account/addresses',
|
||||
},
|
||||
};
|
||||
|
||||
// Create singleton instance with endpoints
|
||||
const client = new ApiClient();
|
||||
|
||||
// Export as apiClient with endpoints attached
|
||||
export const apiClient = Object.assign(client, { endpoints });
|
||||
|
||||
// Also export individual pieces for convenience
|
||||
export const api = client;
|
||||
export { endpoints };
|
||||
|
||||
190
customer-spa/src/lib/currency.ts
Normal file
190
customer-spa/src/lib/currency.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Currency Formatting Utilities
|
||||
*
|
||||
* Uses WooCommerce currency settings from window.woonoowCustomer.currency
|
||||
*/
|
||||
|
||||
interface CurrencySettings {
|
||||
code: string;
|
||||
symbol: string;
|
||||
position: 'left' | 'right' | 'left_space' | 'right_space';
|
||||
thousandSeparator: string;
|
||||
decimalSeparator: string;
|
||||
decimals: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get currency settings from window
|
||||
*/
|
||||
function getCurrencySettings(): CurrencySettings {
|
||||
const settings = (window as any).woonoowCustomer?.currency;
|
||||
|
||||
// Default to USD if not available
|
||||
return settings || {
|
||||
code: 'USD',
|
||||
symbol: '$',
|
||||
position: 'left',
|
||||
thousandSeparator: ',',
|
||||
decimalSeparator: '.',
|
||||
decimals: 2,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number with thousand and decimal separators
|
||||
*/
|
||||
function formatNumber(
|
||||
value: number,
|
||||
decimals: number,
|
||||
decimalSeparator: string,
|
||||
thousandSeparator: string
|
||||
): string {
|
||||
// Round to specified decimals
|
||||
const rounded = value.toFixed(decimals);
|
||||
|
||||
// Split into integer and decimal parts
|
||||
const [integerPart, decimalPart] = rounded.split('.');
|
||||
|
||||
// Add thousand separators to integer part
|
||||
const formattedInteger = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, thousandSeparator);
|
||||
|
||||
// Combine with decimal part if decimals > 0
|
||||
if (decimals > 0 && decimalPart) {
|
||||
return `${formattedInteger}${decimalSeparator}${decimalPart}`;
|
||||
}
|
||||
|
||||
return formattedInteger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a price using WooCommerce currency settings
|
||||
*
|
||||
* @param price - The price value (number or string)
|
||||
* @param options - Optional overrides for currency settings
|
||||
* @returns Formatted price string with currency symbol
|
||||
*
|
||||
* @example
|
||||
* formatPrice(1234.56) // "$1,234.56"
|
||||
* formatPrice(1234.56, { symbol: '€', position: 'right_space' }) // "1.234,56 €"
|
||||
*/
|
||||
export function formatPrice(
|
||||
price: number | string,
|
||||
options?: Partial<CurrencySettings>
|
||||
): string {
|
||||
const settings = { ...getCurrencySettings(), ...options };
|
||||
const numericPrice = typeof price === 'string' ? parseFloat(price) : price;
|
||||
|
||||
// Handle invalid prices
|
||||
if (isNaN(numericPrice)) {
|
||||
return settings.symbol + '0' + settings.decimalSeparator + '00';
|
||||
}
|
||||
|
||||
// Format the number
|
||||
const formattedNumber = formatNumber(
|
||||
numericPrice,
|
||||
settings.decimals,
|
||||
settings.decimalSeparator,
|
||||
settings.thousandSeparator
|
||||
);
|
||||
|
||||
// Apply currency symbol based on position
|
||||
switch (settings.position) {
|
||||
case 'left':
|
||||
return `${settings.symbol}${formattedNumber}`;
|
||||
case 'right':
|
||||
return `${formattedNumber}${settings.symbol}`;
|
||||
case 'left_space':
|
||||
return `${settings.symbol} ${formattedNumber}`;
|
||||
case 'right_space':
|
||||
return `${formattedNumber} ${settings.symbol}`;
|
||||
default:
|
||||
return `${settings.symbol}${formattedNumber}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format price without currency symbol
|
||||
*/
|
||||
export function formatPriceValue(
|
||||
price: number | string,
|
||||
decimals?: number
|
||||
): string {
|
||||
const settings = getCurrencySettings();
|
||||
const numericPrice = typeof price === 'string' ? parseFloat(price) : price;
|
||||
|
||||
if (isNaN(numericPrice)) {
|
||||
return '0' + settings.decimalSeparator + '00';
|
||||
}
|
||||
|
||||
return formatNumber(
|
||||
numericPrice,
|
||||
decimals ?? settings.decimals,
|
||||
settings.decimalSeparator,
|
||||
settings.thousandSeparator
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get currency symbol
|
||||
*/
|
||||
export function getCurrencySymbol(): string {
|
||||
return getCurrencySettings().symbol;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get currency code (e.g., 'USD', 'EUR')
|
||||
*/
|
||||
export function getCurrencyCode(): string {
|
||||
return getCurrencySettings().code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a formatted price string back to a number
|
||||
*/
|
||||
export function parsePrice(formattedPrice: string): number {
|
||||
const settings = getCurrencySettings();
|
||||
|
||||
// Remove currency symbol and spaces
|
||||
let cleaned = formattedPrice.replace(settings.symbol, '').trim();
|
||||
|
||||
// Remove thousand separators
|
||||
cleaned = cleaned.replace(new RegExp(`\\${settings.thousandSeparator}`, 'g'), '');
|
||||
|
||||
// Replace decimal separator with dot
|
||||
cleaned = cleaned.replace(settings.decimalSeparator, '.');
|
||||
|
||||
return parseFloat(cleaned) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a price range
|
||||
*/
|
||||
export function formatPriceRange(minPrice: number, maxPrice: number): string {
|
||||
if (minPrice === maxPrice) {
|
||||
return formatPrice(minPrice);
|
||||
}
|
||||
|
||||
return `${formatPrice(minPrice)} - ${formatPrice(maxPrice)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate and format a discount percentage
|
||||
*/
|
||||
export function formatDiscount(regularPrice: number, salePrice: number): string {
|
||||
if (regularPrice <= salePrice) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const discount = ((regularPrice - salePrice) / regularPrice) * 100;
|
||||
return `-${Math.round(discount)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a price with tax label
|
||||
*/
|
||||
export function formatPriceWithTax(
|
||||
price: number,
|
||||
taxLabel: string = 'incl. tax'
|
||||
): string {
|
||||
return `${formatPrice(price)} (${taxLabel})`;
|
||||
}
|
||||
54
customer-spa/src/lib/utils.ts
Normal file
54
customer-spa/src/lib/utils.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
/**
|
||||
* Merge Tailwind classes with clsx
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Format price
|
||||
*/
|
||||
export function formatPrice(price: number | string, currency: string = 'USD'): string {
|
||||
const numPrice = typeof price === 'string' ? parseFloat(price) : price;
|
||||
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
}).format(numPrice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date
|
||||
*/
|
||||
export function formatDate(date: string | Date): string {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(dateObj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce function
|
||||
*/
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
return function executedFunction(...args: Parameters<T>) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import './styles/theme.css';
|
||||
import App from './App';
|
||||
|
||||
const el = document.getElementById('woonoow-customer-app');
|
||||
|
||||
@@ -1,10 +1,209 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useCartStore, type CartItem } from '@/lib/cart/store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import Container from '@/components/Layout/Container';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
import { Trash2, Plus, Minus, ShoppingBag, ArrowLeft } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function Cart() {
|
||||
const navigate = useNavigate();
|
||||
const { cart, removeItem, updateQuantity, clearCart } = useCartStore();
|
||||
const [showClearDialog, setShowClearDialog] = useState(false);
|
||||
|
||||
// Calculate total from items
|
||||
const total = cart.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
|
||||
|
||||
const handleUpdateQuantity = (key: string, newQuantity: number) => {
|
||||
if (newQuantity < 1) {
|
||||
handleRemoveItem(key);
|
||||
return;
|
||||
}
|
||||
updateQuantity(key, newQuantity);
|
||||
};
|
||||
|
||||
const handleRemoveItem = (key: string) => {
|
||||
removeItem(key);
|
||||
toast.success('Item removed from cart');
|
||||
};
|
||||
|
||||
const handleClearCart = () => {
|
||||
clearCart();
|
||||
setShowClearDialog(false);
|
||||
toast.success('Cart cleared');
|
||||
};
|
||||
|
||||
if (cart.items.length === 0) {
|
||||
return (
|
||||
<Container>
|
||||
<div className="text-center py-16">
|
||||
<ShoppingBag className="mx-auto h-16 w-16 text-gray-400 mb-4" />
|
||||
<h2 className="text-2xl font-bold mb-2">Your cart is empty</h2>
|
||||
<p className="text-gray-600 mb-6">Add some products to get started!</p>
|
||||
<Button onClick={() => navigate('/shop')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-safe py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Shopping Cart</h1>
|
||||
<p className="text-muted-foreground">Cart coming soon...</p>
|
||||
</div>
|
||||
<Container>
|
||||
<div className="py-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-3xl font-bold">Shopping Cart</h1>
|
||||
<Button variant="outline" onClick={() => setShowClearDialog(true)}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Clear Cart
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-8">
|
||||
{/* Cart Items */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{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>
|
||||
|
||||
{/* Product Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-lg mb-1 truncate">
|
||||
{item.name}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-2">
|
||||
{formatPrice(item.price)}
|
||||
</p>
|
||||
|
||||
{/* Quantity Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleUpdateQuantity(item.key, item.quantity - 1)}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
handleUpdateQuantity(item.key, parseInt(e.target.value) || 1)
|
||||
}
|
||||
className="w-16 text-center border rounded py-1"
|
||||
min="1"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleUpdateQuantity(item.key, item.quantity + 1)}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Item Total & Remove */}
|
||||
<div className="flex flex-col items-end justify-between">
|
||||
<button
|
||||
onClick={() => handleRemoveItem(item.key)}
|
||||
className="text-red-600 hover:text-red-700 p-2"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
<p className="font-bold text-lg">
|
||||
{formatPrice(item.price * item.quantity)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cart Summary */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="border rounded-lg p-6 bg-white sticky top-4">
|
||||
<h2 className="text-xl font-bold mb-4">Cart Summary</h2>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Subtotal</span>
|
||||
<span>{formatPrice(total)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Shipping</span>
|
||||
<span>Calculated at checkout</span>
|
||||
</div>
|
||||
<div className="border-t pt-3 flex justify-between text-lg font-bold">
|
||||
<span>Total</span>
|
||||
<span>{formatPrice(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => navigate('/checkout')}
|
||||
size="lg"
|
||||
className="w-full mb-3"
|
||||
>
|
||||
Proceed to Checkout
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => navigate('/shop')}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear Cart Confirmation Dialog */}
|
||||
<Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Clear Cart?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to remove all items from your cart? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowClearDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleClearCart}>
|
||||
Clear Cart
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,367 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCartStore } from '@/lib/cart/store';
|
||||
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';
|
||||
|
||||
export default function Checkout() {
|
||||
const navigate = useNavigate();
|
||||
const { cart } = useCartStore();
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
// Calculate totals
|
||||
const subtotal = cart.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
|
||||
const shipping = 0; // TODO: Calculate shipping
|
||||
const tax = 0; // TODO: Calculate tax
|
||||
const total = subtotal + shipping + tax;
|
||||
|
||||
// Form state
|
||||
const [billingData, setBillingData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
});
|
||||
|
||||
const [shippingData, setShippingData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postcode: '',
|
||||
country: '',
|
||||
});
|
||||
|
||||
const [shipToDifferentAddress, setShipToDifferentAddress] = useState(false);
|
||||
const [orderNotes, setOrderNotes] = useState('');
|
||||
|
||||
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
|
||||
|
||||
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);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Empty cart redirect
|
||||
if (cart.items.length === 0) {
|
||||
return (
|
||||
<Container>
|
||||
<div className="text-center py-16">
|
||||
<ShoppingBag className="mx-auto h-16 w-16 text-gray-400 mb-4" />
|
||||
<h2 className="text-2xl font-bold mb-2">Your cart is empty</h2>
|
||||
<p className="text-gray-600 mb-6">Add some products before checking out!</p>
|
||||
<Button onClick={() => navigate('/shop')}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container-safe py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Checkout</h1>
|
||||
<p className="text-muted-foreground">Checkout coming soon...</p>
|
||||
</div>
|
||||
<Container>
|
||||
<div className="py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Button variant="ghost" onClick={() => navigate('/cart')} className="mb-4">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Cart
|
||||
</Button>
|
||||
<h1 className="text-3xl font-bold">Checkout</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handlePlaceOrder}>
|
||||
<div className="grid lg:grid-cols-3 gap-8">
|
||||
{/* Billing & Shipping Forms */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Billing Details */}
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold mb-4">Billing Details</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">First Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.firstName}
|
||||
onChange={(e) => setBillingData({ ...billingData, firstName: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Last Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={billingData.lastName}
|
||||
onChange={(e) => setBillingData({ ...billingData, lastName: e.target.value })}
|
||||
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">Email Address *</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={billingData.email}
|
||||
onChange={(e) => setBillingData({ ...billingData, email: e.target.value })}
|
||||
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">Phone *</label>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
value={billingData.phone}
|
||||
onChange={(e) => setBillingData({ ...billingData, phone: e.target.value })}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ship to Different Address */}
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<label className="flex items-center gap-2 mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shipToDifferentAddress}
|
||||
onChange={(e) => setShipToDifferentAddress(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="font-medium">Ship to a different address?</span>
|
||||
</label>
|
||||
|
||||
{shipToDifferentAddress && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">First Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.firstName}
|
||||
onChange={(e) => setShippingData({ ...shippingData, firstName: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Last Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={shippingData.lastName}
|
||||
onChange={(e) => setShippingData({ ...shippingData, lastName: e.target.value })}
|
||||
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={shippingData.address}
|
||||
onChange={(e) => setShippingData({ ...shippingData, 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={shippingData.city}
|
||||
onChange={(e) => setShippingData({ ...shippingData, 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={shippingData.state}
|
||||
onChange={(e) => setShippingData({ ...shippingData, 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={shippingData.postcode}
|
||||
onChange={(e) => setShippingData({ ...shippingData, 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={shippingData.country}
|
||||
onChange={(e) => setShippingData({ ...shippingData, country: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* Order Summary */}
|
||||
<div className="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>
|
||||
|
||||
{/* Order Items */}
|
||||
<div className="space-y-3 mb-4 pb-4 border-b">
|
||||
{cart.items.map((item) => (
|
||||
<div key={item.key} className="flex justify-between text-sm">
|
||||
<span>
|
||||
{item.name} × {item.quantity}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatPrice(item.price * item.quantity)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
<div className="space-y-2 mb-6">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Subtotal</span>
|
||||
<span>{formatPrice(subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Shipping</span>
|
||||
<span>{shipping === 0 ? 'Free' : formatPrice(shipping)}</span>
|
||||
</div>
|
||||
{tax > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Tax</span>
|
||||
<span>{formatPrice(tax)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t pt-2 flex justify-between font-bold text-lg">
|
||||
<span>Total</span>
|
||||
<span>{formatPrice(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
<div className="mb-6">
|
||||
<h3 className="font-medium mb-3">Payment Method</h3>
|
||||
<div className="space-y-2">
|
||||
<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" />
|
||||
<span>Bank Transfer</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Place Order Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? 'Processing...' : 'Place Order'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,176 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, Filter } from 'lucide-react';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useCartStore } from '@/lib/cart/store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Container from '@/components/Layout/Container';
|
||||
import { ProductCard } from '@/components/ProductCard';
|
||||
import { toast } from 'sonner';
|
||||
import { useTheme, useLayout } from '@/contexts/ThemeContext';
|
||||
import type { ProductsResponse, ProductCategory, Product } from '@/types/product';
|
||||
|
||||
export default function Shop() {
|
||||
const navigate = useNavigate();
|
||||
const { config } = useTheme();
|
||||
const { layout } = useLayout();
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const { addItem } = useCartStore();
|
||||
|
||||
// Fetch products
|
||||
const { data: productsData, isLoading: productsLoading } = useQuery<ProductsResponse>({
|
||||
queryKey: ['products', page, search, category],
|
||||
queryFn: () => apiClient.get(apiClient.endpoints.shop.products, {
|
||||
page,
|
||||
per_page: 12,
|
||||
search,
|
||||
category,
|
||||
}),
|
||||
});
|
||||
|
||||
// Fetch categories
|
||||
const { data: categories } = useQuery<ProductCategory[]>({
|
||||
queryKey: ['categories'],
|
||||
queryFn: () => apiClient.get(apiClient.endpoints.shop.categories),
|
||||
});
|
||||
|
||||
const handleAddToCart = async (product: any) => {
|
||||
try {
|
||||
const response = await apiClient.post(apiClient.endpoints.cart.add, {
|
||||
product_id: product.id,
|
||||
quantity: 1,
|
||||
});
|
||||
|
||||
// Add to local cart store
|
||||
addItem({
|
||||
key: `${product.id}`,
|
||||
product_id: product.id,
|
||||
name: product.name,
|
||||
price: parseFloat(product.price),
|
||||
quantity: 1,
|
||||
image: product.image,
|
||||
});
|
||||
|
||||
toast.success(`${product.name} added to cart!`, {
|
||||
action: {
|
||||
label: 'View Cart',
|
||||
onClick: () => navigate('/cart'),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error('Failed to add to cart');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container-safe py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Shop</h1>
|
||||
<p className="text-muted-foreground">Product listing coming soon...</p>
|
||||
</div>
|
||||
<Container>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold mb-2">Shop</h1>
|
||||
<p className="text-muted-foreground">Browse our collection of products</p>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Products Grid */}
|
||||
{productsLoading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div key={i} 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>
|
||||
))}
|
||||
</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">
|
||||
{productsData.products.map((product: any) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onAddToCart={handleAddToCart}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{productsData.total_pages > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="flex items-center px-4">
|
||||
Page {page} of {productsData.total_pages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPage(p => Math.min(productsData.total_pages, p + 1))}
|
||||
disabled={page === productsData.total_pages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground text-lg">No products found</p>
|
||||
{(search || category) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSearch('');
|
||||
setCategory('');
|
||||
}}
|
||||
className="mt-4"
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
299
customer-spa/src/styles/theme.css
Normal file
299
customer-spa/src/styles/theme.css
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* WooNooW Customer SPA - Design Tokens
|
||||
*
|
||||
* All styling is controlled via CSS custom properties (design tokens).
|
||||
* These values are injected from PHP settings via ThemeProvider.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ========================================
|
||||
* COLORS
|
||||
* ======================================== */
|
||||
|
||||
/* Brand Colors (injected from settings) */
|
||||
--color-primary: #3B82F6;
|
||||
--color-secondary: #8B5CF6;
|
||||
--color-accent: #10B981;
|
||||
--color-background: #FFFFFF;
|
||||
--color-text: #1F2937;
|
||||
|
||||
/* Color Shades (auto-generated) */
|
||||
--color-primary-50: #EFF6FF;
|
||||
--color-primary-100: #DBEAFE;
|
||||
--color-primary-200: #BFDBFE;
|
||||
--color-primary-300: #93C5FD;
|
||||
--color-primary-400: #60A5FA;
|
||||
--color-primary-500: var(--color-primary);
|
||||
--color-primary-600: #2563EB;
|
||||
--color-primary-700: #1D4ED8;
|
||||
--color-primary-800: #1E40AF;
|
||||
--color-primary-900: #1E3A8A;
|
||||
|
||||
/* Semantic Colors */
|
||||
--color-success: #10B981;
|
||||
--color-warning: #F59E0B;
|
||||
--color-error: #EF4444;
|
||||
--color-info: #3B82F6;
|
||||
|
||||
/* Neutral Colors */
|
||||
--color-gray-50: #F9FAFB;
|
||||
--color-gray-100: #F3F4F6;
|
||||
--color-gray-200: #E5E7EB;
|
||||
--color-gray-300: #D1D5DB;
|
||||
--color-gray-400: #9CA3AF;
|
||||
--color-gray-500: #6B7280;
|
||||
--color-gray-600: #4B5563;
|
||||
--color-gray-700: #374151;
|
||||
--color-gray-800: #1F2937;
|
||||
--color-gray-900: #111827;
|
||||
|
||||
/* ========================================
|
||||
* TYPOGRAPHY
|
||||
* ======================================== */
|
||||
|
||||
/* Font Families (injected from settings) */
|
||||
--font-heading: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-body: 'Lora', Georgia, serif;
|
||||
--font-mono: 'IBM Plex Mono', 'Courier New', monospace;
|
||||
|
||||
/* Font Weights */
|
||||
--font-weight-heading: 700;
|
||||
--font-weight-body: 400;
|
||||
--font-weight-light: 300;
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
/* Font Sizes (8px base scale) */
|
||||
--text-xs: 0.75rem; /* 12px */
|
||||
--text-sm: 0.875rem; /* 14px */
|
||||
--text-base: 1rem; /* 16px */
|
||||
--text-lg: 1.125rem; /* 18px */
|
||||
--text-xl: 1.25rem; /* 20px */
|
||||
--text-2xl: 1.5rem; /* 24px */
|
||||
--text-3xl: 1.875rem; /* 30px */
|
||||
--text-4xl: 2.25rem; /* 36px */
|
||||
--text-5xl: 3rem; /* 48px */
|
||||
--text-6xl: 3.75rem; /* 60px */
|
||||
|
||||
/* Line Heights */
|
||||
--line-height-none: 1;
|
||||
--line-height-tight: 1.25;
|
||||
--line-height-snug: 1.375;
|
||||
--line-height-normal: 1.5;
|
||||
--line-height-relaxed: 1.625;
|
||||
--line-height-loose: 2;
|
||||
|
||||
/* ========================================
|
||||
* SPACING (8px grid system)
|
||||
* ======================================== */
|
||||
|
||||
--space-0: 0;
|
||||
--space-1: 0.5rem; /* 8px */
|
||||
--space-2: 1rem; /* 16px */
|
||||
--space-3: 1.5rem; /* 24px */
|
||||
--space-4: 2rem; /* 32px */
|
||||
--space-5: 2.5rem; /* 40px */
|
||||
--space-6: 3rem; /* 48px */
|
||||
--space-8: 4rem; /* 64px */
|
||||
--space-10: 5rem; /* 80px */
|
||||
--space-12: 6rem; /* 96px */
|
||||
--space-16: 8rem; /* 128px */
|
||||
--space-20: 10rem; /* 160px */
|
||||
--space-24: 12rem; /* 192px */
|
||||
|
||||
/* ========================================
|
||||
* BORDER RADIUS
|
||||
* ======================================== */
|
||||
|
||||
--radius-none: 0;
|
||||
--radius-sm: 0.25rem; /* 4px */
|
||||
--radius-md: 0.5rem; /* 8px */
|
||||
--radius-lg: 1rem; /* 16px */
|
||||
--radius-xl: 1.5rem; /* 24px */
|
||||
--radius-2xl: 2rem; /* 32px */
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* ========================================
|
||||
* SHADOWS
|
||||
* ======================================== */
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
|
||||
--shadow-inner: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);
|
||||
|
||||
/* ========================================
|
||||
* TRANSITIONS
|
||||
* ======================================== */
|
||||
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 350ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ========================================
|
||||
* BREAKPOINTS (for reference in JS)
|
||||
* ======================================== */
|
||||
|
||||
--breakpoint-sm: 640px;
|
||||
--breakpoint-md: 768px;
|
||||
--breakpoint-lg: 1024px;
|
||||
--breakpoint-xl: 1280px;
|
||||
--breakpoint-2xl: 1536px;
|
||||
|
||||
/* ========================================
|
||||
* Z-INDEX SCALE
|
||||
* ======================================== */
|
||||
|
||||
--z-base: 0;
|
||||
--z-dropdown: 1000;
|
||||
--z-sticky: 1020;
|
||||
--z-fixed: 1030;
|
||||
--z-modal-backdrop: 1040;
|
||||
--z-modal: 1050;
|
||||
--z-popover: 1060;
|
||||
--z-tooltip: 1070;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
* DARK MODE
|
||||
* ======================================== */
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-background: #1F2937;
|
||||
--color-text: #F9FAFB;
|
||||
|
||||
/* Invert gray scale for dark mode */
|
||||
--color-gray-50: #111827;
|
||||
--color-gray-100: #1F2937;
|
||||
--color-gray-200: #374151;
|
||||
--color-gray-300: #4B5563;
|
||||
--color-gray-400: #6B7280;
|
||||
--color-gray-500: #9CA3AF;
|
||||
--color-gray-600: #D1D5DB;
|
||||
--color-gray-700: #E5E7EB;
|
||||
--color-gray-800: #F3F4F6;
|
||||
--color-gray-900: #F9FAFB;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
* BASE STYLES
|
||||
* ======================================== */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--line-height-normal);
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: var(--font-weight-heading);
|
||||
line-height: var(--line-height-tight);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-5xl);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--text-4xl);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--text-3xl);
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--color-primary-600);
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: var(--font-heading);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
* UTILITY CLASSES
|
||||
* ======================================== */
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding-left: var(--space-4);
|
||||
padding-right: var(--space-4);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.container {
|
||||
max-width: 640px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
max-width: 768px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.container {
|
||||
max-width: 1024px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
.container {
|
||||
max-width: 1536px;
|
||||
}
|
||||
}
|
||||
45
customer-spa/src/types/product.ts
Normal file
45
customer-spa/src/types/product.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Product Types
|
||||
*/
|
||||
|
||||
export interface ProductCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
type: string;
|
||||
status: string;
|
||||
price: string;
|
||||
regular_price?: string;
|
||||
sale_price?: string;
|
||||
on_sale: boolean;
|
||||
stock_status: 'instock' | 'outofstock' | 'onbackorder';
|
||||
stock_quantity?: number;
|
||||
image?: string;
|
||||
images?: string[];
|
||||
short_description?: string;
|
||||
description?: string;
|
||||
sku?: string;
|
||||
categories?: ProductCategory[];
|
||||
tags?: any[];
|
||||
attributes?: any[];
|
||||
variations?: number[];
|
||||
permalink?: string;
|
||||
}
|
||||
|
||||
export interface ProductsResponse {
|
||||
products: Product[];
|
||||
total: number;
|
||||
total_pages: number;
|
||||
current_page: number;
|
||||
}
|
||||
|
||||
export interface CategoriesResponse {
|
||||
categories: ProductCategory[];
|
||||
}
|
||||
@@ -1,22 +1,43 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const key = fs.readFileSync(path.resolve(__dirname, '../admin-spa/.cert/woonoow.local-key.pem'));
|
||||
const cert = fs.readFileSync(path.resolve(__dirname, '../admin-spa/.cert/woonoow.local-cert.pem'));
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const key = readFileSync(resolve(__dirname, '../admin-spa/.cert/woonoow.local-key.pem'));
|
||||
const cert = readFileSync(resolve(__dirname, '../admin-spa/.cert/woonoow.local-cert.pem'));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: { alias: { '@': path.resolve(__dirname, './src') } },
|
||||
base: '/',
|
||||
plugins: [
|
||||
react({
|
||||
jsxRuntime: 'automatic',
|
||||
})
|
||||
],
|
||||
resolve: { alias: { '@': resolve(__dirname, './src') } },
|
||||
server: {
|
||||
host: 'woonoow.local',
|
||||
port: 5174,
|
||||
strictPort: true,
|
||||
https: { key, cert },
|
||||
cors: true,
|
||||
origin: 'https://woonoow.local:5174',
|
||||
hmr: { protocol: 'wss', host: 'woonoow.local', port: 5174 }
|
||||
cors: {
|
||||
origin: ['https://woonoow.local', 'https://woonoow.local:5174'],
|
||||
credentials: true,
|
||||
},
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization',
|
||||
},
|
||||
hmr: {
|
||||
protocol: 'wss',
|
||||
host: 'woonoow.local',
|
||||
port: 5174,
|
||||
clientPort: 5174,
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
|
||||
Reference in New Issue
Block a user