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:
Dwindi Ramadhana
2025-11-26 16:18:43 +07:00
parent 909bddb23d
commit f397ef850f
69 changed files with 12481 additions and 156 deletions

View File

@@ -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",

View File

@@ -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",

View File

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

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

View 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>&copy; {currentYear} WooNooW. All rights reserved.</p>
</div>
</div>
</footer>
);
}

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

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

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

View 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 }}
/>
);
})}
</>
);
}

View 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 }

View 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,
}

View 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,
};
}

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

View File

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

View 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})`;
}

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

View File

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

View File

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

View File

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

View File

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

View 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;
}
}

View 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[];
}

View File

@@ -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',