fix: WP-Admin CSS conflicts and add-to-cart redirect

- Fix CSS conflicts between WP-Admin and SPA (radio buttons, chart text)
- Add Tailwind important selector scoped to #woonoow-admin-app
- Remove overly aggressive inline SVG styles from Assets.php
- Add targeted WordPress admin CSS overrides in index.css
- Fix add-to-cart redirect to use woocommerce_add_to_cart_redirect filter
- Let WooCommerce handle cart operations natively for proper session management
- Remove duplicate tailwind.config.cjs
This commit is contained in:
Dwindi Ramadhana
2025-12-31 14:06:04 +07:00
parent 93523a74ac
commit 82399d4ddf
20 changed files with 1272 additions and 571 deletions

View File

@@ -76,6 +76,43 @@
} }
} }
/* ============================================
WordPress Admin Override Fixes
These rules use high specificity + !important
to override WordPress admin CSS conflicts
============================================ */
/* Fix SVG icon styling - WordPress sets fill:currentColor on all SVGs */
#woonoow-admin-app svg {
fill: none !important;
}
/* But allow explicit fill-current class to work for filled icons */
#woonoow-admin-app svg.fill-current,
#woonoow-admin-app .fill-current svg,
#woonoow-admin-app [class*="fill-"] svg {
fill: currentColor !important;
}
/* Fix radio button indicator - WordPress overrides circle fill */
#woonoow-admin-app [data-radix-radio-group-item] svg,
#woonoow-admin-app [role="radio"] svg {
fill: currentColor !important;
}
/* Fix font-weight inheritance - prevent WordPress bold overrides */
#woonoow-admin-app text,
#woonoow-admin-app tspan {
font-weight: inherit !important;
}
/* Reset form element styling that WordPress overrides */
#woonoow-admin-app input[type="radio"],
#woonoow-admin-app input[type="checkbox"] {
appearance: none !important;
-webkit-appearance: none !important;
}
/* Command palette input: remove native borders/shadows to match shadcn */ /* Command palette input: remove native borders/shadows to match shadcn */
.command-palette-search { .command-palette-search {
border: none !important; border: none !important;

View File

@@ -127,6 +127,7 @@ export default function ProductEdit() {
onSubmit={handleSubmit} onSubmit={handleSubmit}
formRef={formRef} formRef={formRef}
hideSubmitButton={true} hideSubmitButton={true}
productId={product.id}
/> />
{/* Level 1 compatibility: Custom meta fields from plugins */} {/* Level 1 compatibility: Custom meta fields from plugins */}

View File

@@ -0,0 +1,215 @@
import React, { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Copy, Check, ExternalLink } from 'lucide-react';
import { toast } from 'sonner';
interface DirectCartLinksProps {
productId: number;
productType: 'simple' | 'variable';
variations?: Array<{
id: number;
name: string;
attributes: Record<string, string>;
}>;
}
export function DirectCartLinks({ productId, productType, variations = [] }: DirectCartLinksProps) {
const [quantity, setQuantity] = useState(1);
const [copiedLink, setCopiedLink] = useState<string | null>(null);
const siteUrl = window.location.origin;
const spaPagePath = '/store'; // This should ideally come from settings
const generateLink = (variationId?: number, redirect: 'cart' | 'checkout' = 'cart') => {
const params = new URLSearchParams();
params.set('add-to-cart', productId.toString());
if (variationId) {
params.set('variation_id', variationId.toString());
}
if (quantity > 1) {
params.set('quantity', quantity.toString());
}
params.set('redirect', redirect);
return `${siteUrl}${spaPagePath}?${params.toString()}`;
};
const copyToClipboard = async (link: string, label: string) => {
try {
await navigator.clipboard.writeText(link);
setCopiedLink(link);
toast.success(`${label} link copied!`);
setTimeout(() => setCopiedLink(null), 2000);
} catch (err) {
toast.error('Failed to copy link');
}
};
const LinkRow = ({
label,
link,
description
}: {
label: string;
link: string;
description?: string;
}) => {
const isCopied = copiedLink === link;
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">{label}</Label>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => copyToClipboard(link, label)}
>
{isCopied ? (
<>
<Check className="h-4 w-4 mr-1" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
Copy
</>
)}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => window.open(link, '_blank')}
>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</div>
<Input
value={link}
readOnly
className="font-mono text-xs"
onClick={(e) => e.currentTarget.select()}
/>
{description && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
</div>
);
};
return (
<Card>
<CardHeader>
<CardTitle>Direct-to-Cart Links</CardTitle>
<CardDescription>
Generate copyable links that add this product to cart and redirect to cart or checkout page.
Perfect for landing pages, email campaigns, and social media.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Quantity Selector */}
<div className="space-y-2">
<Label htmlFor="link-quantity">Default Quantity</Label>
<Input
id="link-quantity"
type="number"
min="1"
value={quantity}
onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
className="w-32"
/>
<p className="text-xs text-muted-foreground">
Set quantity to 1 to exclude from URL (cleaner links)
</p>
</div>
{/* Simple Product Links */}
{productType === 'simple' && (
<div className="space-y-4">
<div className="border-b pb-2">
<h4 className="font-medium">Simple Product Links</h4>
</div>
<LinkRow
label="Add to Cart"
link={generateLink(undefined, 'cart')}
description="Adds product to cart and shows cart page"
/>
<LinkRow
label="Direct to Checkout"
link={generateLink(undefined, 'checkout')}
description="Adds product to cart and goes directly to checkout"
/>
</div>
)}
{/* Variable Product Links */}
{productType === 'variable' && variations.length > 0 && (
<div className="space-y-4">
<div className="border-b pb-2">
<h4 className="font-medium">Variable Product Links</h4>
<p className="text-xs text-muted-foreground mt-1">
{variations.length} variation(s) - Select a variation to generate links
</p>
</div>
<div className="space-y-2">
{variations.map((variation, index) => (
<details key={variation.id} className="group border rounded-lg">
<summary className="cursor-pointer p-3 hover:bg-muted/50 flex items-center justify-between">
<div className="flex-1">
<span className="font-medium text-sm">{variation.name}</span>
<span className="text-xs text-muted-foreground ml-2">
(ID: {variation.id})
</span>
</div>
<svg
className="w-4 h-4 transition-transform group-open:rotate-180"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div className="p-4 pt-0 space-y-3 border-t">
<LinkRow
label="Add to Cart"
link={generateLink(variation.id, 'cart')}
/>
<LinkRow
label="Direct to Checkout"
link={generateLink(variation.id, 'checkout')}
/>
</div>
</details>
))}
</div>
</div>
)}
{/* URL Parameters Reference */}
<div className="mt-6 p-4 bg-muted rounded-lg">
<h4 className="font-medium text-sm mb-2">URL Parameters Reference</h4>
<div className="space-y-1 text-xs text-muted-foreground">
<div><code className="bg-background px-1 py-0.5 rounded">add-to-cart</code> - Product ID (required)</div>
<div><code className="bg-background px-1 py-0.5 rounded">variation_id</code> - Variation ID (for variable products)</div>
<div><code className="bg-background px-1 py-0.5 rounded">quantity</code> - Quantity (default: 1)</div>
<div><code className="bg-background px-1 py-0.5 rounded">redirect</code> - Destination: <code>cart</code> or <code>checkout</code></div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -41,6 +41,7 @@ type Props = {
className?: string; className?: string;
formRef?: React.RefObject<HTMLFormElement>; formRef?: React.RefObject<HTMLFormElement>;
hideSubmitButton?: boolean; hideSubmitButton?: boolean;
productId?: number;
}; };
export function ProductFormTabbed({ export function ProductFormTabbed({
@@ -50,6 +51,7 @@ export function ProductFormTabbed({
className, className,
formRef, formRef,
hideSubmitButton = false, hideSubmitButton = false,
productId,
}: Props) { }: Props) {
// Form state // Form state
const [name, setName] = useState(initial?.name || ''); const [name, setName] = useState(initial?.name || '');
@@ -225,6 +227,7 @@ export function ProductFormTabbed({
variations={variations} variations={variations}
setVariations={setVariations} setVariations={setVariations}
regularPrice={regularPrice} regularPrice={regularPrice}
productId={productId}
/> />
</FormSection> </FormSection>
)} )}

View File

@@ -7,7 +7,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Plus, X, Layers, Image as ImageIcon } from 'lucide-react'; import { Plus, X, Layers, Image as ImageIcon, Copy, Check, ExternalLink } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getStoreCurrency } from '@/lib/currency'; import { getStoreCurrency } from '@/lib/currency';
import { openWPMediaImage } from '@/lib/wp-media'; import { openWPMediaImage } from '@/lib/wp-media';
@@ -30,6 +30,7 @@ type VariationsTabProps = {
variations: ProductVariant[]; variations: ProductVariant[];
setVariations: (value: ProductVariant[]) => void; setVariations: (value: ProductVariant[]) => void;
regularPrice: string; regularPrice: string;
productId?: number;
}; };
export function VariationsTab({ export function VariationsTab({
@@ -38,8 +39,33 @@ export function VariationsTab({
variations, variations,
setVariations, setVariations,
regularPrice, regularPrice,
productId,
}: VariationsTabProps) { }: VariationsTabProps) {
const store = getStoreCurrency(); const store = getStoreCurrency();
const [copiedLink, setCopiedLink] = useState<string | null>(null);
const siteUrl = window.location.origin;
const spaPagePath = '/store';
const generateLink = (variationId: number, redirect: 'cart' | 'checkout' = 'cart') => {
if (!productId) return '';
const params = new URLSearchParams();
params.set('add-to-cart', productId.toString());
params.set('variation_id', variationId.toString());
params.set('redirect', redirect);
return `${siteUrl}${spaPagePath}?${params.toString()}`;
};
const copyToClipboard = async (link: string, label: string) => {
try {
await navigator.clipboard.writeText(link);
setCopiedLink(link);
toast.success(`${label} link copied!`);
setTimeout(() => setCopiedLink(null), 2000);
} catch (err) {
toast.error('Failed to copy link');
}
};
const addAttribute = () => { const addAttribute = () => {
setAttributes([...attributes, { name: '', options: [], variation: false }]); setAttributes([...attributes, { name: '', options: [], variation: false }]);
@@ -305,6 +331,45 @@ export function VariationsTab({
}} }}
/> />
</div> </div>
{/* Direct Cart Links */}
{productId && variation.id && (
<div className="mt-4 pt-4 border-t space-y-2">
<Label className="text-xs font-medium text-muted-foreground">
{__('Direct-to-Cart Links')}
</Label>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => copyToClipboard(generateLink(variation.id!, 'cart'), 'Cart')}
className="flex-1"
>
{copiedLink === generateLink(variation.id!, 'cart') ? (
<Check className="h-3 w-3 mr-1" />
) : (
<Copy className="h-3 w-3 mr-1" />
)}
{__('Copy Cart Link')}
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => copyToClipboard(generateLink(variation.id!, 'checkout'), 'Checkout')}
className="flex-1"
>
{copiedLink === generateLink(variation.id!, 'checkout') ? (
<Check className="h-3 w-3 mr-1" />
) : (
<Copy className="h-3 w-3 mr-1" />
)}
{__('Copy Checkout Link')}
</Button>
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
))} ))}

View File

@@ -1,61 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: ["./src/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
theme: {
container: {
center: true,
padding: '1rem'
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
}
}
},
plugins: [require("tailwindcss-animate")]
};

View File

@@ -1,6 +1,7 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
module.exports = { module.exports = {
darkMode: ["class"], darkMode: ["class"],
important: '#woonoow-admin-app',
content: ["./src/**/*.{ts,tsx,css}", "./components/**/*.{ts,tsx,css}"], content: ["./src/**/*.{ts,tsx,css}", "./components/**/*.{ts,tsx,css}"],
theme: { theme: {
container: { center: true, padding: "1rem" }, container: { center: true, padding: "1rem" },

20
composer.lock generated Normal file
View File

@@ -0,0 +1,20 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "c8dfaf9b12dfc28774a5f4e2e71e84af",
"packages": [],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
"php": "^8.1"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"
}

View File

@@ -6,7 +6,6 @@ import { Toaster } from 'sonner';
// Theme // Theme
import { ThemeProvider } from './contexts/ThemeContext'; import { ThemeProvider } from './contexts/ThemeContext';
import { BaseLayout } from './layouts/BaseLayout'; import { BaseLayout } from './layouts/BaseLayout';
import { useAddToCartFromUrl } from './hooks/useAddToCartFromUrl';
// Pages // Pages
import Shop from './pages/Shop'; import Shop from './pages/Shop';
@@ -52,55 +51,59 @@ const getAppearanceSettings = () => {
return (window as any).woonoowCustomer?.appearanceSettings || {}; return (window as any).woonoowCustomer?.appearanceSettings || {};
}; };
// Get initial route from data attribute (set by PHP based on SPA mode)
const getInitialRoute = () => {
const appEl = document.getElementById('woonoow-customer-app');
const initialRoute = appEl?.getAttribute('data-initial-route');
console.log('[WooNooW Customer] Initial route from data attribute:', initialRoute);
console.log('[WooNooW Customer] App element:', appEl);
console.log('[WooNooW Customer] All data attributes:', appEl?.dataset);
return initialRoute || '/shop'; // Default to shop if not specified
};
// Router wrapper component that uses hooks requiring Router context
function AppRoutes() {
const initialRoute = getInitialRoute();
console.log('[WooNooW Customer] Using initial route:', initialRoute);
return (
<BaseLayout>
<Routes>
{/* Root route redirects to initial route based on SPA mode */}
<Route path="/" element={<Navigate to={initialRoute} replace />} />
{/* Shop Routes */}
<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={<ThankYou />} />
{/* Wishlist - Public route accessible to guests */}
<Route path="/wishlist" element={<Wishlist />} />
{/* My Account */}
<Route path="/my-account/*" element={<Account />} />
{/* Fallback to initial route */}
<Route path="*" element={<Navigate to={initialRoute} replace />} />
</Routes>
</BaseLayout>
);
}
function App() { function App() {
const themeConfig = getThemeConfig(); const themeConfig = getThemeConfig();
const appearanceSettings = getAppearanceSettings(); const appearanceSettings = getAppearanceSettings();
const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any; const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any;
// Handle add-to-cart from URL parameters
useAddToCartFromUrl();
// Get initial route from data attribute (set by PHP based on SPA mode)
const getInitialRoute = () => {
const appEl = document.getElementById('woonoow-customer-app');
const initialRoute = appEl?.getAttribute('data-initial-route');
console.log('[WooNooW Customer] Initial route from data attribute:', initialRoute);
console.log('[WooNooW Customer] App element:', appEl);
console.log('[WooNooW Customer] All data attributes:', appEl?.dataset);
return initialRoute || '/shop'; // Default to shop if not specified
};
const initialRoute = getInitialRoute();
console.log('[WooNooW Customer] Using initial route:', initialRoute);
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ThemeProvider config={themeConfig}> <ThemeProvider config={themeConfig}>
<HashRouter> <HashRouter>
<BaseLayout> <AppRoutes />
<Routes>
{/* Root route redirects to initial route based on SPA mode */}
<Route path="/" element={<Navigate to={initialRoute} replace />} />
{/* Shop Routes */}
<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={<ThankYou />} />
{/* Wishlist - Public route accessible to guests */}
<Route path="/wishlist" element={<Wishlist />} />
{/* My Account */}
<Route path="/my-account/*" element={<Account />} />
{/* Fallback to initial route */}
<Route path="*" element={<Navigate to={initialRoute} replace />} />
</Routes>
</BaseLayout>
</HashRouter> </HashRouter>
{/* Toast notifications - position from settings */} {/* Toast notifications - position from settings */}

View File

@@ -1,6 +1,7 @@
import { useEffect } from 'react'; import { useEffect, useRef } from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useCartStore } from '@/lib/cart/store';
/** /**
* Hook to handle add-to-cart from URL parameters * Hook to handle add-to-cart from URL parameters
@@ -10,51 +11,81 @@ import { toast } from 'sonner';
* - Simple product: ?add-to-cart=123 * - Simple product: ?add-to-cart=123
* - Variable product: ?add-to-cart=123&variation_id=456 * - Variable product: ?add-to-cart=123&variation_id=456
* - With quantity: ?add-to-cart=123&quantity=2 * - With quantity: ?add-to-cart=123&quantity=2
* - Direct to checkout: ?add-to-cart=123&redirect=checkout
* - Stay on cart (default): ?add-to-cart=123&redirect=cart
*/ */
export function useAddToCartFromUrl() { export function useAddToCartFromUrl() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { setCart } = useCartStore();
const processedRef = useRef<Set<string>>(new Set());
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search); // Check hash route for add-to-cart parameters
const productId = params.get('add-to-cart'); const hash = window.location.hash;
const hashParams = new URLSearchParams(hash.split('?')[1] || '');
const productId = hashParams.get('add-to-cart');
if (!productId) return; if (!productId) return;
const variationId = params.get('variation_id'); const variationId = hashParams.get('variation_id');
const quantity = parseInt(params.get('quantity') || '1', 10); const quantity = parseInt(hashParams.get('quantity') || '1', 10);
const redirect = hashParams.get('redirect') || 'cart';
// Create unique key for this add-to-cart request
const requestKey = `${productId}-${variationId || 'none'}-${quantity}`;
// Skip if already processed
if (processedRef.current.has(requestKey)) {
console.log('[WooNooW] Skipping duplicate add-to-cart:', requestKey);
return;
}
console.log('[WooNooW] Add to cart from URL:', { console.log('[WooNooW] Add to cart from URL:', {
productId, productId,
variationId, variationId,
quantity, quantity,
redirect,
fullUrl: window.location.href, fullUrl: window.location.href,
requestKey,
}); });
// Add product to cart // Mark as processed
addToCart(productId, variationId, quantity) processedRef.current.add(requestKey);
.then(() => {
// Remove URL parameters after adding to cart
const cleanUrl = window.location.pathname + window.location.hash;
window.history.replaceState({}, '', cleanUrl);
// Navigate to cart if not already there addToCart(productId, variationId, quantity)
if (!location.pathname.includes('/cart')) { .then((cartData) => {
navigate('/cart'); // Update cart store with fresh data from API
if (cartData) {
setCart(cartData);
console.log('[WooNooW] Cart updated with fresh data:', cartData);
}
// Remove URL parameters after adding to cart
const currentPath = window.location.hash.split('?')[0];
window.location.hash = currentPath;
// Navigate based on redirect parameter
const targetPage = redirect === 'checkout' ? '/checkout' : '/cart';
if (!location.pathname.includes(targetPage)) {
console.log(`[WooNooW] Navigating to ${targetPage}`);
navigate(targetPage);
} }
}) })
.catch((error) => { .catch((error) => {
console.error('[WooNooW] Failed to add product to cart:', error); console.error('[WooNooW] Failed to add product to cart:', error);
toast.error('Failed to add product to cart'); toast.error('Failed to add product to cart');
// Remove from processed set on error so it can be retried
processedRef.current.delete(requestKey);
}); });
}, []); // Run once on mount }, [location.hash, navigate, setCart]); // Include all dependencies
} }
async function addToCart( async function addToCart(
productId: string, productId: string,
variationId: string | null, variationId: string | null,
quantity: number quantity: number
): Promise<void> { ): Promise<any> {
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1'; const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
const nonce = (window as any).woonoowCustomer?.nonce || ''; const nonce = (window as any).woonoowCustomer?.nonce || '';
@@ -85,11 +116,13 @@ async function addToCart(
} }
const data = await response.json(); const data = await response.json();
console.log('[WooNooW] Product added to cart:', data);
if (!data.success) { // API returns {message, cart_item_key, cart} on success
if (data.cart_item_key && data.cart) {
toast.success(data.message || 'Product added to cart');
return data.cart; // Return cart data to update store
} else {
throw new Error(data.message || 'Failed to add to cart'); throw new Error(data.message || 'Failed to add to cart');
} }
console.log('[WooNooW] Product added to cart:', data);
toast.success('Product added to cart');
} }

View File

@@ -0,0 +1,111 @@
import { Cart } from './store';
const getApiConfig = () => {
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
const nonce = (window as any).woonoowCustomer?.nonce || '';
return { apiRoot, nonce };
};
/**
* Update cart item quantity via API
*/
export async function updateCartItemQuantity(
cartItemKey: string,
quantity: number
): Promise<Cart> {
const { apiRoot, nonce } = getApiConfig();
const response = await fetch(`${apiRoot}/cart/update`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': nonce,
},
credentials: 'include',
body: JSON.stringify({
cart_item_key: cartItemKey,
quantity,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Failed to update cart');
}
const data = await response.json();
return data.cart;
}
/**
* Remove item from cart via API
*/
export async function removeCartItem(cartItemKey: string): Promise<Cart> {
const { apiRoot, nonce } = getApiConfig();
const response = await fetch(`${apiRoot}/cart/remove`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': nonce,
},
credentials: 'include',
body: JSON.stringify({
cart_item_key: cartItemKey,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Failed to remove item');
}
const data = await response.json();
return data.cart;
}
/**
* Clear entire cart via API
*/
export async function clearCartAPI(): Promise<Cart> {
const { apiRoot, nonce } = getApiConfig();
const response = await fetch(`${apiRoot}/cart/clear`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': nonce,
},
credentials: 'include',
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Failed to clear cart');
}
const data = await response.json();
return data.cart;
}
/**
* Fetch current cart from API
*/
export async function fetchCart(): Promise<Cart> {
const { apiRoot, nonce } = getApiConfig();
const response = await fetch(`${apiRoot}/cart`, {
method: 'GET',
headers: {
'X-WP-Nonce': nonce,
},
credentials: 'include',
});
if (!response.ok) {
throw new Error('Failed to fetch cart');
}
const data = await response.json();
return data;
}

View File

@@ -1,7 +1,8 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useCartStore, type CartItem } from '@/lib/cart/store'; import { useCartStore, type CartItem } from '@/lib/cart/store';
import { useCartSettings } from '@/hooks/useAppearanceSettings'; import { useCartSettings } from '@/hooks/useAppearanceSettings';
import { updateCartItemQuantity, removeCartItem, clearCartAPI, fetchCart } from '@/lib/cart/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
@@ -13,37 +14,96 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import Container from '@/components/Layout/Container'; import Container from '@/components/Layout/Container';
import { formatPrice } from '@/lib/currency'; import { formatPrice } from '@/lib/currency';
import { Trash2, Plus, Minus, ShoppingBag, ArrowLeft } from 'lucide-react'; import { Trash2, Plus, Minus, ShoppingBag, ArrowLeft, Loader2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
export default function Cart() { export default function Cart() {
const navigate = useNavigate(); const navigate = useNavigate();
const { cart, removeItem, updateQuantity, clearCart } = useCartStore(); const { cart, setCart } = useCartStore();
const { layout, elements } = useCartSettings(); const { layout, elements } = useCartSettings();
const [showClearDialog, setShowClearDialog] = useState(false); const [showClearDialog, setShowClearDialog] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// Fetch cart from server on mount to sync with WooCommerce
useEffect(() => {
const loadCart = async () => {
try {
const serverCart = await fetchCart();
setCart(serverCart);
} catch (error) {
console.error('Failed to fetch cart:', error);
} finally {
setIsLoading(false);
}
};
loadCart();
}, [setCart]);
// Calculate total from items // Calculate total from items
const total = cart.items.reduce((sum, item) => sum + (item.price * item.quantity), 0); const total = cart.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
const handleUpdateQuantity = (key: string, newQuantity: number) => { const handleUpdateQuantity = async (key: string, newQuantity: number) => {
if (newQuantity < 1) { if (newQuantity < 1) {
handleRemoveItem(key); handleRemoveItem(key);
return; return;
} }
updateQuantity(key, newQuantity);
setIsUpdating(true);
try {
const updatedCart = await updateCartItemQuantity(key, newQuantity);
setCart(updatedCart);
} catch (error) {
console.error('Failed to update quantity:', error);
toast.error('Failed to update quantity');
} finally {
setIsUpdating(false);
}
}; };
const handleRemoveItem = (key: string) => { const handleRemoveItem = async (key: string) => {
removeItem(key); setIsUpdating(true);
toast.success('Item removed from cart'); try {
const updatedCart = await removeCartItem(key);
setCart(updatedCart);
toast.success('Item removed from cart');
} catch (error) {
console.error('Failed to remove item:', error);
toast.error('Failed to remove item');
} finally {
setIsUpdating(false);
}
}; };
const handleClearCart = () => { const handleClearCart = async () => {
clearCart(); setIsUpdating(true);
setShowClearDialog(false); try {
toast.success('Cart cleared'); const updatedCart = await clearCartAPI();
setCart(updatedCart);
setShowClearDialog(false);
toast.success('Cart cleared');
} catch (error) {
console.error('Failed to clear cart:', error);
toast.error('Failed to clear cart');
setShowClearDialog(false);
} finally {
setIsUpdating(false);
}
}; };
// Show loading state while fetching cart
if (isLoading) {
return (
<Container>
<div className="text-center py-16">
<Loader2 className="mx-auto h-16 w-16 text-gray-400 mb-4 animate-spin" />
<p className="text-gray-600">Loading cart...</p>
</div>
</Container>
);
}
if (cart.items.length === 0) { if (cart.items.length === 0) {
return ( return (
<Container> <Container>

View File

@@ -6,12 +6,15 @@ use WooNooW\Compat\AddonRegistry;
use WooNooW\Compat\RouteRegistry; use WooNooW\Compat\RouteRegistry;
use WooNooW\Compat\NavigationRegistry; use WooNooW\Compat\NavigationRegistry;
class Assets { class Assets
public static function init() { {
public static function init()
{
add_action('admin_enqueue_scripts', [__CLASS__, 'enqueue']); add_action('admin_enqueue_scripts', [__CLASS__, 'enqueue']);
} }
public static function enqueue($hook) { public static function enqueue($hook)
{
// Debug logging // Debug logging
if (defined('WP_DEBUG') && WP_DEBUG) { if (defined('WP_DEBUG') && WP_DEBUG) {
error_log('[WooNooW Assets] Hook: ' . $hook); error_log('[WooNooW Assets] Hook: ' . $hook);
@@ -42,7 +45,8 @@ class Assets {
/** ---------------------------------------- /** ----------------------------------------
* DEV MODE (Vite dev server) * DEV MODE (Vite dev server)
* -------------------------------------- */ * -------------------------------------- */
private static function enqueue_dev(): void { private static function enqueue_dev(): void
{
$dev_url = self::dev_server_url(); // e.g. http://localhost:5173 $dev_url = self::dev_server_url(); // e.g. http://localhost:5173
// 1) Create a small handle to attach config (window.WNW_API) // 1) Create a small handle to attach config (window.WNW_API)
@@ -53,38 +57,38 @@ class Assets {
// Attach runtime config (before module loader runs) // Attach runtime config (before module loader runs)
// If you prefer, keep using self::localize_runtime($handle) // If you prefer, keep using self::localize_runtime($handle)
wp_localize_script($handle, 'WNW_API', [ wp_localize_script($handle, 'WNW_API', [
'root' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))), 'root' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
'nonce' => wp_create_nonce('wp_rest'), 'nonce' => wp_create_nonce('wp_rest'),
'isDev' => true, 'isDev' => true,
'devServer' => $dev_url, 'devServer' => $dev_url,
'adminScreen' => 'woonoow', 'adminScreen' => 'woonoow',
'adminUrl' => admin_url('admin.php'), 'adminUrl' => admin_url('admin.php'),
]); ]);
wp_add_inline_script($handle, 'window.WNW_API = window.WNW_API || WNW_API;', 'after'); wp_add_inline_script($handle, 'window.WNW_API = window.WNW_API || WNW_API;', 'after');
// WNW_CONFIG for compatibility with standalone mode code // WNW_CONFIG for compatibility with standalone mode code
wp_localize_script($handle, 'WNW_CONFIG', [ wp_localize_script($handle, 'WNW_CONFIG', [
'restUrl' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))), 'restUrl' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
'nonce' => wp_create_nonce('wp_rest'), 'nonce' => wp_create_nonce('wp_rest'),
'standaloneMode' => false, 'standaloneMode' => false,
'wpAdminUrl' => admin_url('admin.php?page=woonoow'), 'wpAdminUrl' => admin_url('admin.php?page=woonoow'),
'isAuthenticated' => is_user_logged_in(), 'isAuthenticated' => is_user_logged_in(),
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))), 'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
]); ]);
wp_add_inline_script($handle, 'window.WNW_CONFIG = window.WNW_CONFIG || WNW_CONFIG;', 'after'); wp_add_inline_script($handle, 'window.WNW_CONFIG = window.WNW_CONFIG || WNW_CONFIG;', 'after');
// WordPress REST API settings (for media upload compatibility) // WordPress REST API settings (for media upload compatibility)
wp_localize_script($handle, 'wpApiSettings', [ wp_localize_script($handle, 'wpApiSettings', [
'root' => untrailingslashit(esc_url_raw(rest_url())), 'root' => untrailingslashit(esc_url_raw(rest_url())),
'nonce' => wp_create_nonce('wp_rest'), 'nonce' => wp_create_nonce('wp_rest'),
]); ]);
wp_add_inline_script($handle, 'window.wpApiSettings = window.wpApiSettings || wpApiSettings;', 'after'); wp_add_inline_script($handle, 'window.wpApiSettings = window.wpApiSettings || wpApiSettings;', 'after');
// Also expose compact global for convenience // Also expose compact global for convenience
wp_localize_script($handle, 'wnw', [ wp_localize_script($handle, 'wnw', [
'isDev' => true, 'isDev' => true,
'devServer' => $dev_url, 'devServer' => $dev_url,
'adminUrl' => admin_url('admin.php'), 'adminUrl' => admin_url('admin.php'),
'siteTitle' => get_bloginfo('name') ?: 'WooNooW', 'siteTitle' => get_bloginfo('name') ?: 'WooNooW',
]); ]);
wp_add_inline_script($handle, 'window.wnw = window.wnw || wnw;', 'after'); wp_add_inline_script($handle, 'window.wnw = window.wnw || wnw;', 'after');
@@ -117,11 +121,11 @@ class Assets {
// 1) React Refresh preamble (required by @vitejs/plugin-react) // 1) React Refresh preamble (required by @vitejs/plugin-react)
?> ?>
<script type="module"> <script type="module">
import RefreshRuntime from "<?php echo esc_url( $dev_url ); ?>/@react-refresh"; import RefreshRuntime from "<?php echo esc_url($dev_url); ?>/@react-refresh";
RefreshRuntime.injectIntoGlobalHook(window); RefreshRuntime.injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {}; window.$RefreshReg$ = () => { };
window.$RefreshSig$ = () => (type) => type; window.$RefreshSig$ = () => (type) => type;
window.__vite_plugin_react_preamble_installed__ = true; window.__vite_plugin_react_preamble_installed__ = true;
</script> </script>
<?php <?php
@@ -136,17 +140,18 @@ class Assets {
/** ---------------------------------------- /** ----------------------------------------
* PROD MODE (built assets in admin-spa/dist) * PROD MODE (built assets in admin-spa/dist)
* -------------------------------------- */ * -------------------------------------- */
private static function enqueue_prod(): void { private static function enqueue_prod(): void
{
// Get plugin root directory (2 levels up from includes/Admin/) // Get plugin root directory (2 levels up from includes/Admin/)
$plugin_dir = dirname(dirname(__DIR__)); $plugin_dir = dirname(dirname(__DIR__));
$dist_dir = $plugin_dir . '/admin-spa/dist/'; $dist_dir = $plugin_dir . '/admin-spa/dist/';
$base_url = plugins_url('admin-spa/dist/', $plugin_dir . '/woonoow.php'); $base_url = plugins_url('admin-spa/dist/', $plugin_dir . '/woonoow.php');
$css = 'app.css'; $css = 'app.css';
$js = 'app.js'; $js = 'app.js';
$ver_css = file_exists($dist_dir . $css) ? (string) filemtime($dist_dir . $css) : self::asset_version(); $ver_css = file_exists($dist_dir . $css) ? (string) filemtime($dist_dir . $css) : self::asset_version();
$ver_js = file_exists($dist_dir . $js) ? (string) filemtime($dist_dir . $js) : self::asset_version(); $ver_js = file_exists($dist_dir . $js) ? (string) filemtime($dist_dir . $js) : self::asset_version();
// Debug logging // Debug logging
if (defined('WP_DEBUG') && WP_DEBUG) { if (defined('WP_DEBUG') && WP_DEBUG) {
@@ -159,26 +164,14 @@ class Assets {
if (file_exists($dist_dir . $css)) { if (file_exists($dist_dir . $css)) {
wp_enqueue_style('wnw-admin', $base_url . $css, [], $ver_css); wp_enqueue_style('wnw-admin', $base_url . $css, [], $ver_css);
// Note: Icon fixes are now in index.css with proper specificity
// Fix icon rendering in WP-Admin (prevent WordPress admin styles from overriding)
$icon_fix_css = '
/* Fix Lucide icons in WP-Admin - force outlined style */
#woonoow-admin-app svg {
fill: none !important;
stroke: currentColor !important;
stroke-width: 2 !important;
stroke-linecap: round !important;
stroke-linejoin: round !important;
}
';
wp_add_inline_style('wnw-admin', $icon_fix_css);
} }
if (file_exists($dist_dir . $js)) { if (file_exists($dist_dir . $js)) {
wp_enqueue_script('wnw-admin', $base_url . $js, ['wp-element'], $ver_js, true); wp_enqueue_script('wnw-admin', $base_url . $js, ['wp-element'], $ver_js, true);
// Add type="module" attribute for Vite build // Add type="module" attribute for Vite build
add_filter('script_loader_tag', function($tag, $handle, $src) { add_filter('script_loader_tag', function ($tag, $handle, $src) {
if ($handle === 'wnw-admin') { if ($handle === 'wnw-admin') {
$tag = str_replace('<script ', '<script type="module" ', $tag); $tag = str_replace('<script ', '<script type="module" ', $tag);
} }
@@ -190,29 +183,30 @@ class Assets {
} }
/** Attach runtime config to a handle */ /** Attach runtime config to a handle */
private static function localize_runtime(string $handle): void { private static function localize_runtime(string $handle): void
{
wp_localize_script($handle, 'WNW_API', [ wp_localize_script($handle, 'WNW_API', [
'root' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))), 'root' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
'nonce' => wp_create_nonce('wp_rest'), 'nonce' => wp_create_nonce('wp_rest'),
'isDev' => self::is_dev_mode(), 'isDev' => self::is_dev_mode(),
'devServer' => self::dev_server_url(), 'devServer' => self::dev_server_url(),
'adminScreen' => 'woonoow', 'adminScreen' => 'woonoow',
'adminUrl' => admin_url('admin.php'), 'adminUrl' => admin_url('admin.php'),
]); ]);
// WNW_CONFIG for compatibility with standalone mode code // WNW_CONFIG for compatibility with standalone mode code
wp_localize_script($handle, 'WNW_CONFIG', [ wp_localize_script($handle, 'WNW_CONFIG', [
'restUrl' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))), 'restUrl' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
'nonce' => wp_create_nonce('wp_rest'), 'nonce' => wp_create_nonce('wp_rest'),
'standaloneMode' => false, 'standaloneMode' => false,
'wpAdminUrl' => admin_url('admin.php?page=woonoow'), 'wpAdminUrl' => admin_url('admin.php?page=woonoow'),
'isAuthenticated' => is_user_logged_in(), 'isAuthenticated' => is_user_logged_in(),
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))), 'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
]); ]);
// WordPress REST API settings (for media upload compatibility) // WordPress REST API settings (for media upload compatibility)
wp_localize_script($handle, 'wpApiSettings', [ wp_localize_script($handle, 'wpApiSettings', [
'root' => untrailingslashit(esc_url_raw(rest_url())), 'root' => untrailingslashit(esc_url_raw(rest_url())),
'nonce' => wp_create_nonce('wp_rest'), 'nonce' => wp_create_nonce('wp_rest'),
]); ]);
@@ -221,9 +215,9 @@ class Assets {
// Compact global (prod) // Compact global (prod)
wp_localize_script($handle, 'wnw', [ wp_localize_script($handle, 'wnw', [
'isDev' => (bool) self::is_dev_mode(), 'isDev' => (bool) self::is_dev_mode(),
'devServer' => (string) self::dev_server_url(), 'devServer' => (string) self::dev_server_url(),
'adminUrl' => admin_url('admin.php'), 'adminUrl' => admin_url('admin.php'),
'siteTitle' => get_bloginfo('name') ?: 'WooNooW', 'siteTitle' => get_bloginfo('name') ?: 'WooNooW',
]); ]);
wp_add_inline_script($handle, 'window.wnw = window.wnw || wnw;', 'after'); wp_add_inline_script($handle, 'window.wnw = window.wnw || wnw;', 'after');
@@ -249,22 +243,23 @@ class Assets {
} }
/** Runtime store meta for frontend (currency, decimals, separators, position). */ /** Runtime store meta for frontend (currency, decimals, separators, position). */
private static function store_runtime(): array { private static function store_runtime(): array
{
// WooCommerce helpers may not exist in some contexts; guard with defaults // WooCommerce helpers may not exist in some contexts; guard with defaults
$currency = function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : 'USD'; $currency = function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : 'USD';
$currency_sym = function_exists('get_woocommerce_currency_symbol') ? get_woocommerce_currency_symbol($currency) : '$'; $currency_sym = function_exists('get_woocommerce_currency_symbol') ? get_woocommerce_currency_symbol($currency) : '$';
$decimals = function_exists('wc_get_price_decimals') ? wc_get_price_decimals() : 2; $decimals = function_exists('wc_get_price_decimals') ? wc_get_price_decimals() : 2;
$thousand_sep = function_exists('wc_get_price_thousand_separator') ? wc_get_price_thousand_separator() : ','; $thousand_sep = function_exists('wc_get_price_thousand_separator') ? wc_get_price_thousand_separator() : ',';
$decimal_sep = function_exists('wc_get_price_decimal_separator') ? wc_get_price_decimal_separator() : '.'; $decimal_sep = function_exists('wc_get_price_decimal_separator') ? wc_get_price_decimal_separator() : '.';
$currency_pos = function_exists('get_option') ? get_option('woocommerce_currency_pos', 'left') : 'left'; $currency_pos = function_exists('get_option') ? get_option('woocommerce_currency_pos', 'left') : 'left';
return [ return [
'currency' => $currency, 'currency' => $currency,
'currency_symbol' => $currency_sym, 'currency_symbol' => $currency_sym,
'decimals' => (int) $decimals, 'decimals' => (int) $decimals,
'thousand_sep' => (string) $thousand_sep, 'thousand_sep' => (string) $thousand_sep,
'decimal_sep' => (string) $decimal_sep, 'decimal_sep' => (string) $decimal_sep,
'currency_pos' => (string) $currency_pos, 'currency_pos' => (string) $currency_pos,
]; ];
} }
@@ -275,9 +270,10 @@ class Assets {
* Note: We don't check WP_ENV to avoid accidentally enabling dev mode * Note: We don't check WP_ENV to avoid accidentally enabling dev mode
* in Local by Flywheel or other local dev environments. * in Local by Flywheel or other local dev environments.
*/ */
private static function is_dev_mode(): bool { private static function is_dev_mode(): bool
{
// Only enable dev mode if explicitly set via constant // Only enable dev mode if explicitly set via constant
$const_dev = defined('WOONOOW_ADMIN_DEV') && WOONOOW_ADMIN_DEV === true; $const_dev = defined('WOONOOW_ADMIN_DEV') && WOONOOW_ADMIN_DEV === true;
/** /**
* Filter: force dev/prod mode for WooNooW admin assets. * Filter: force dev/prod mode for WooNooW admin assets.
@@ -297,7 +293,8 @@ class Assets {
} }
/** Dev server URL (filterable) */ /** Dev server URL (filterable) */
private static function dev_server_url(): string { private static function dev_server_url(): string
{
// Auto-detect based on current host (for Local by Flywheel compatibility) // Auto-detect based on current host (for Local by Flywheel compatibility)
$host = $_SERVER['HTTP_HOST'] ?? 'localhost'; $host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$protocol = is_ssl() ? 'https' : 'http'; $protocol = is_ssl() ? 'https' : 'http';
@@ -314,7 +311,8 @@ class Assets {
} }
/** Basic asset versioning */ /** Basic asset versioning */
private static function asset_version(): string { private static function asset_version(): string
{
// Bump when releasing; in dev we don't cache-bust // Bump when releasing; in dev we don't cache-bust
return defined('WOONOOW_VERSION') ? WOONOOW_VERSION : '0.1.0'; return defined('WOONOOW_VERSION') ? WOONOOW_VERSION : '0.1.0';
} }

View File

@@ -38,11 +38,6 @@ class Permissions {
$has_wc = current_user_can('manage_woocommerce'); $has_wc = current_user_can('manage_woocommerce');
$has_opts = current_user_can('manage_options'); $has_opts = current_user_can('manage_options');
$result = $has_wc || $has_opts; $result = $has_wc || $has_opts;
error_log(sprintf('WooNooW Permissions: check_admin_permission() - WC:%s Options:%s Result:%s',
$has_wc ? 'YES' : 'NO',
$has_opts ? 'YES' : 'NO',
$result ? 'ALLOWED' : 'DENIED'
));
return $result; return $result;
} }
} }

View File

@@ -447,6 +447,7 @@ class ProductsController {
if (isset($data['description'])) $product->set_description(self::sanitize_textarea($data['description'])); if (isset($data['description'])) $product->set_description(self::sanitize_textarea($data['description']));
if (isset($data['short_description'])) $product->set_short_description(self::sanitize_textarea($data['short_description'])); if (isset($data['short_description'])) $product->set_short_description(self::sanitize_textarea($data['short_description']));
if (isset($data['sku'])) $product->set_sku(self::sanitize_text($data['sku'])); if (isset($data['sku'])) $product->set_sku(self::sanitize_text($data['sku']));
if (isset($data['regular_price'])) $product->set_regular_price(self::sanitize_number($data['regular_price'])); if (isset($data['regular_price'])) $product->set_regular_price(self::sanitize_number($data['regular_price']));
if (isset($data['sale_price'])) $product->set_sale_price(self::sanitize_number($data['sale_price'])); if (isset($data['sale_price'])) $product->set_sale_price(self::sanitize_number($data['sale_price']));
@@ -800,15 +801,18 @@ class ProductsController {
$value = $term ? $term->name : $value; $value = $term ? $term->name : $value;
} }
} else { } else {
// Custom attribute - WooCommerce stores as 'attribute_' + exact attribute name // Custom attribute - stored as lowercase in meta
$meta_key = 'attribute_' . $attr_name; $meta_key = 'attribute_' . strtolower($attr_name);
$value = get_post_meta($variation_id, $meta_key, true); $value = get_post_meta($variation_id, $meta_key, true);
// Capitalize the attribute name for display // Capitalize the attribute name for display to match admin SPA
$clean_name = ucfirst($attr_name); $clean_name = ucfirst($attr_name);
} }
$formatted_attributes[$clean_name] = $value; // Only add if value exists
if (!empty($value)) {
$formatted_attributes[$clean_name] = $value;
}
} }
$image_url = $image ? $image[0] : ''; $image_url = $image ? $image[0] : '';
@@ -857,36 +861,106 @@ class ProductsController {
* Save product variations * Save product variations
*/ */
private static function save_product_variations($product, $variations_data) { private static function save_product_variations($product, $variations_data) {
// Get existing variation IDs
$existing_variation_ids = $product->get_children();
$variations_to_keep = [];
foreach ($variations_data as $var_data) { foreach ($variations_data as $var_data) {
if (isset($var_data['id']) && $var_data['id']) { if (isset($var_data['id']) && $var_data['id']) {
// Update existing variation
$variation = wc_get_product($var_data['id']); $variation = wc_get_product($var_data['id']);
if (!$variation) continue;
$variations_to_keep[] = $var_data['id'];
} else { } else {
// Create new variation
$variation = new WC_Product_Variation(); $variation = new WC_Product_Variation();
$variation->set_parent_id($product->get_id()); $variation->set_parent_id($product->get_id());
} }
if ($variation) { // Build attributes array
if (isset($var_data['sku'])) $variation->set_sku($var_data['sku']); $wc_attributes = [];
if (isset($var_data['regular_price'])) $variation->set_regular_price($var_data['regular_price']); if (isset($var_data['attributes']) && is_array($var_data['attributes'])) {
if (isset($var_data['sale_price'])) $variation->set_sale_price($var_data['sale_price']); $parent_attributes = $product->get_attributes();
if (isset($var_data['stock_status'])) $variation->set_stock_status($var_data['stock_status']);
if (isset($var_data['manage_stock'])) $variation->set_manage_stock($var_data['manage_stock']);
if (isset($var_data['stock_quantity'])) $variation->set_stock_quantity($var_data['stock_quantity']);
if (isset($var_data['attributes'])) $variation->set_attributes($var_data['attributes']);
// Handle image - support both image_id and image URL foreach ($var_data['attributes'] as $display_name => $value) {
if (isset($var_data['image']) && !empty($var_data['image'])) { if (empty($value)) continue;
$image_id = attachment_url_to_postid($var_data['image']);
if ($image_id) { foreach ($parent_attributes as $attr_name => $parent_attr) {
$variation->set_image_id($image_id); if (!$parent_attr->get_variation()) continue;
if (strcasecmp($display_name, $attr_name) === 0 || strcasecmp($display_name, ucfirst($attr_name)) === 0) {
$wc_attributes[strtolower($attr_name)] = strtolower($value);
break;
}
} }
} elseif (isset($var_data['image_id'])) {
$variation->set_image_id($var_data['image_id']);
} }
}
$variation->save(); if (!empty($wc_attributes)) {
$variation->set_attributes($wc_attributes);
}
if (isset($var_data['sku'])) $variation->set_sku($var_data['sku']);
// Set prices - if not provided, use parent's price as fallback
if (isset($var_data['regular_price']) && $var_data['regular_price'] !== '') {
$variation->set_regular_price($var_data['regular_price']);
} elseif (!$variation->get_regular_price()) {
// Fallback to parent price if variation has no price
$parent_price = $product->get_regular_price();
if ($parent_price) {
$variation->set_regular_price($parent_price);
}
}
if (isset($var_data['sale_price']) && $var_data['sale_price'] !== '') {
$variation->set_sale_price($var_data['sale_price']);
}
if (isset($var_data['stock_status'])) $variation->set_stock_status($var_data['stock_status']);
if (isset($var_data['manage_stock'])) $variation->set_manage_stock($var_data['manage_stock']);
if (isset($var_data['stock_quantity'])) $variation->set_stock_quantity($var_data['stock_quantity']);
if (isset($var_data['image']) && !empty($var_data['image'])) {
$image_id = attachment_url_to_postid($var_data['image']);
if ($image_id) $variation->set_image_id($image_id);
} elseif (isset($var_data['image_id'])) {
$variation->set_image_id($var_data['image_id']);
}
// Save variation first
$saved_id = $variation->save();
$variations_to_keep[] = $saved_id;
// Manually save attributes using direct database insert
if (!empty($wc_attributes)) {
global $wpdb;
foreach ($wc_attributes as $attr_name => $attr_value) {
$meta_key = 'attribute_' . $attr_name;
$wpdb->delete(
$wpdb->postmeta,
['post_id' => $saved_id, 'meta_key' => $meta_key],
['%d', '%s']
);
$wpdb->insert(
$wpdb->postmeta,
[
'post_id' => $saved_id,
'meta_key' => $meta_key,
'meta_value' => $attr_value
],
['%d', '%s', '%s']
);
}
}
}
// Delete variations that are no longer in the list
$variations_to_delete = array_diff($existing_variation_ids, $variations_to_keep);
foreach ($variations_to_delete as $variation_id) {
$variation_to_delete = wc_get_product($variation_id);
if ($variation_to_delete) {
$variation_to_delete->delete(true);
} }
} }
} }

View File

@@ -66,5 +66,64 @@ class Bootstrap {
MailQueue::init(); MailQueue::init();
WooEmailOverride::init(); WooEmailOverride::init();
OrderStore::init(); OrderStore::init();
// Initialize cart for REST API requests
add_action('woocommerce_init', [self::class, 'init_cart_for_rest_api']);
// Load custom variation attributes for WooCommerce admin
add_action('woocommerce_product_variation_object_read', [self::class, 'load_variation_attributes']);
}
/**
* Properly initialize WooCommerce cart for REST API requests
* This is the recommended approach per WooCommerce core team
*/
public static function init_cart_for_rest_api() {
// Only load cart for REST API requests
if (!WC()->is_rest_api_request()) {
return;
}
// Load frontend includes (required for cart)
WC()->frontend_includes();
// Load cart using WooCommerce's official method
if (null === WC()->cart && function_exists('wc_load_cart')) {
wc_load_cart();
}
}
/**
* Load custom variation attributes from post meta for WooCommerce admin
* This ensures WooCommerce's native admin displays custom attributes correctly
*/
public static function load_variation_attributes($variation) {
if (!$variation instanceof \WC_Product_Variation) {
return;
}
$parent = wc_get_product($variation->get_parent_id());
if (!$parent) {
return;
}
$attributes = [];
foreach ($parent->get_attributes() as $attr_name => $attribute) {
if (!$attribute->get_variation()) {
continue;
}
// Read from post meta (stored as lowercase)
$meta_key = 'attribute_' . strtolower($attr_name);
$value = get_post_meta($variation->get_id(), $meta_key, true);
if (!empty($value)) {
$attributes[strtolower($attr_name)] = $value;
}
}
if (!empty($attributes)) {
$variation->set_attributes($attributes);
}
} }
} }

View File

@@ -35,15 +35,11 @@ class Assets {
public static function enqueue_assets() { public static function enqueue_assets() {
// Only load on pages with WooNooW shortcodes or in full SPA mode // Only load on pages with WooNooW shortcodes or in full SPA mode
if (!self::should_load_assets()) { if (!self::should_load_assets()) {
error_log('[WooNooW Customer] should_load_assets returned false - not loading');
return; return;
} }
error_log('[WooNooW Customer] should_load_assets returned true - loading assets');
// Check if dev mode is enabled // Check if dev mode is enabled
$is_dev = defined('WOONOOW_CUSTOMER_DEV') && WOONOOW_CUSTOMER_DEV; $is_dev = defined('WOONOOW_CUSTOMER_DEV') && WOONOOW_CUSTOMER_DEV;
error_log('[WooNooW Customer] Dev mode: ' . ($is_dev ? 'true' : 'false'));
if ($is_dev) { if ($is_dev) {
// Dev mode: Load from Vite dev server // Dev mode: Load from Vite dev server
@@ -66,9 +62,6 @@ class Assets {
null, null,
false // Load in header false // Load in header
); );
error_log('WooNooW Customer: Loading from Vite dev server at ' . $dev_server);
error_log('WooNooW Customer: Scripts enqueued - vite client and main.tsx');
} else { } else {
// Production mode: Load from build // Production mode: Load from build
$plugin_url = plugin_dir_url(dirname(dirname(__FILE__))); $plugin_url = plugin_dir_url(dirname(dirname(__FILE__)));
@@ -76,7 +69,6 @@ class Assets {
// Check if build exists // Check if build exists
if (!file_exists($dist_path)) { if (!file_exists($dist_path)) {
error_log('WooNooW: customer-spa build not found. Run: cd customer-spa && npm run build');
return; return;
} }
@@ -84,9 +76,6 @@ class Assets {
$js_url = $plugin_url . 'customer-spa/dist/app.js'; $js_url = $plugin_url . 'customer-spa/dist/app.js';
$css_url = $plugin_url . 'customer-spa/dist/app.css'; $css_url = $plugin_url . 'customer-spa/dist/app.css';
error_log('[WooNooW Customer] Enqueuing JS: ' . $js_url);
error_log('[WooNooW Customer] Enqueuing CSS: ' . $css_url);
wp_enqueue_script( wp_enqueue_script(
'woonoow-customer-spa', 'woonoow-customer-spa',
$js_url, $js_url,
@@ -109,8 +98,6 @@ class Assets {
[], [],
null null
); );
error_log('[WooNooW Customer] Assets enqueued successfully');
} }
} }
@@ -242,7 +229,6 @@ class Assets {
<script type="module" crossorigin src="<?php echo $dev_server; ?>/@vite/client"></script> <script type="module" crossorigin src="<?php echo $dev_server; ?>/@vite/client"></script>
<script type="module" crossorigin src="<?php echo $dev_server; ?>/src/main.tsx"></script> <script type="module" crossorigin src="<?php echo $dev_server; ?>/src/main.tsx"></script>
<?php <?php
error_log('WooNooW Customer: Scripts output directly in head with React Refresh preamble');
} }
} }
@@ -252,11 +238,8 @@ class Assets {
private static function should_load_assets() { private static function should_load_assets() {
global $post; global $post;
error_log('[WooNooW Customer] should_load_assets check - Post ID: ' . ($post ? $post->ID : 'none'));
// First check: Is this a designated SPA page? // First check: Is this a designated SPA page?
if (self::is_spa_page()) { if (self::is_spa_page()) {
error_log('[WooNooW Customer] Designated SPA page detected - loading assets');
return true; return true;
} }
@@ -264,8 +247,6 @@ class Assets {
$spa_settings = get_option('woonoow_customer_spa_settings', []); $spa_settings = get_option('woonoow_customer_spa_settings', []);
$mode = isset($spa_settings['mode']) ? $spa_settings['mode'] : 'disabled'; $mode = isset($spa_settings['mode']) ? $spa_settings['mode'] : 'disabled';
error_log('[WooNooW Customer] SPA mode: ' . $mode);
// If disabled, don't load // If disabled, don't load
if ($mode === 'disabled') { if ($mode === 'disabled') {
// Special handling for WooCommerce Shop page (it's an archive, not a regular post) // Special handling for WooCommerce Shop page (it's an archive, not a regular post)
@@ -274,7 +255,6 @@ class Assets {
if ($shop_page_id) { if ($shop_page_id) {
$shop_page = get_post($shop_page_id); $shop_page = get_post($shop_page_id);
if ($shop_page && has_shortcode($shop_page->post_content, 'woonoow_shop')) { if ($shop_page && has_shortcode($shop_page->post_content, 'woonoow_shop')) {
error_log('[WooNooW Customer] Found woonoow_shop shortcode on Shop page (ID: ' . $shop_page_id . ')');
return true; return true;
} }
} }
@@ -282,27 +262,19 @@ class Assets {
// Check for shortcodes on regular pages // Check for shortcodes on regular pages
if ($post) { if ($post) {
error_log('[WooNooW Customer] Checking post content for shortcodes');
error_log('[WooNooW Customer] Post content: ' . substr($post->post_content, 0, 200));
if (has_shortcode($post->post_content, 'woonoow_shop')) { if (has_shortcode($post->post_content, 'woonoow_shop')) {
error_log('[WooNooW Customer] Found woonoow_shop shortcode');
return true; return true;
} }
if (has_shortcode($post->post_content, 'woonoow_cart')) { if (has_shortcode($post->post_content, 'woonoow_cart')) {
error_log('[WooNooW Customer] Found woonoow_cart shortcode');
return true; return true;
} }
if (has_shortcode($post->post_content, 'woonoow_checkout')) { if (has_shortcode($post->post_content, 'woonoow_checkout')) {
error_log('[WooNooW Customer] Found woonoow_checkout shortcode');
return true; return true;
} }
if (has_shortcode($post->post_content, 'woonoow_account')) { if (has_shortcode($post->post_content, 'woonoow_account')) {
error_log('[WooNooW Customer] Found woonoow_account shortcode');
return true; return true;
} }
} }
error_log('[WooNooW Customer] No shortcodes found, not loading');
return false; return false;
} }

View File

@@ -9,14 +9,16 @@ use WP_Error;
* Cart Controller - Customer-facing cart API * Cart Controller - Customer-facing cart API
* Handles cart operations for customer-spa * Handles cart operations for customer-spa
*/ */
class CartController { class CartController
{
/** /**
* Initialize controller * Initialize controller
*/ */
public static function init() { public static function init()
{
// Bypass cookie authentication for cart endpoints to allow guest users // Bypass cookie authentication for cart endpoints to allow guest users
add_filter('rest_authentication_errors', function($result) { add_filter('rest_authentication_errors', function ($result) {
// If already authenticated or error, return as is // If already authenticated or error, return as is
if (!empty($result)) { if (!empty($result)) {
return $result; return $result;
@@ -35,37 +37,38 @@ class CartController {
/** /**
* Register REST API routes * Register REST API routes
*/ */
public static function register_routes() { public static function register_routes()
{
$namespace = 'woonoow/v1'; $namespace = 'woonoow/v1';
// Get cart // Get cart
$result = register_rest_route($namespace, '/cart', [ $result = register_rest_route($namespace, '/cart', [
'methods' => 'GET', 'methods' => 'GET',
'callback' => [__CLASS__, 'get_cart'], 'callback' => [__CLASS__, 'get_cart'],
'permission_callback' => '__return_true', 'permission_callback' => '__return_true',
]); ]);
// Add to cart // Add to cart
$result = register_rest_route($namespace, '/cart/add', [ $result = register_rest_route($namespace, '/cart/add', [
'methods' => 'POST', 'methods' => 'POST',
'callback' => [__CLASS__, 'add_to_cart'], 'callback' => [__CLASS__, 'add_to_cart'],
'permission_callback' => function() { 'permission_callback' => function () {
// Allow both logged-in and guest users // Allow both logged-in and guest users
return true; return true;
}, },
'args' => [ 'args' => [
'product_id' => [ 'product_id' => [
'required' => true, 'required' => true,
'validate_callback' => function($param) { 'validate_callback' => function ($param) {
return is_numeric($param); return is_numeric($param);
}, },
], ],
'quantity' => [ 'quantity' => [
'default' => 1, 'default' => 1,
'sanitize_callback' => 'absint', 'sanitize_callback' => 'absint',
], ],
'variation_id' => [ 'variation_id' => [
'default' => 0, 'default' => 0,
'sanitize_callback' => 'absint', 'sanitize_callback' => 'absint',
], ],
], ],
@@ -73,16 +76,17 @@ class CartController {
// Update cart item // Update cart item
register_rest_route($namespace, '/cart/update', [ register_rest_route($namespace, '/cart/update', [
'methods' => 'POST', 'methods' => 'POST',
'callback' => [__CLASS__, 'update_cart'], 'callback' => [__CLASS__, 'update_cart'],
'permission_callback' => function() { return true; }, 'permission_callback' => function () {
'args' => [ return true; },
'args' => [
'cart_item_key' => [ 'cart_item_key' => [
'required' => true, 'required' => true,
'sanitize_callback' => 'sanitize_text_field', 'sanitize_callback' => 'sanitize_text_field',
], ],
'quantity' => [ 'quantity' => [
'required' => true, 'required' => true,
'sanitize_callback' => 'absint', 'sanitize_callback' => 'absint',
], ],
], ],
@@ -90,12 +94,13 @@ class CartController {
// Remove from cart // Remove from cart
register_rest_route($namespace, '/cart/remove', [ register_rest_route($namespace, '/cart/remove', [
'methods' => 'POST', 'methods' => 'POST',
'callback' => [__CLASS__, 'remove_from_cart'], 'callback' => [__CLASS__, 'remove_from_cart'],
'permission_callback' => function() { return true; }, 'permission_callback' => function () {
'args' => [ return true; },
'args' => [
'cart_item_key' => [ 'cart_item_key' => [
'required' => true, 'required' => true,
'sanitize_callback' => 'sanitize_text_field', 'sanitize_callback' => 'sanitize_text_field',
], ],
], ],
@@ -103,26 +108,36 @@ class CartController {
// Apply coupon // Apply coupon
register_rest_route($namespace, '/cart/apply-coupon', [ register_rest_route($namespace, '/cart/apply-coupon', [
'methods' => 'POST', 'methods' => 'POST',
'callback' => [__CLASS__, 'apply_coupon'], 'callback' => [__CLASS__, 'apply_coupon'],
'permission_callback' => function() { return true; }, 'permission_callback' => function () {
'args' => [ return true; },
'args' => [
'coupon_code' => [ 'coupon_code' => [
'required' => true, 'required' => true,
'sanitize_callback' => 'sanitize_text_field', 'sanitize_callback' => 'sanitize_text_field',
], ],
], ],
]); ]);
// Clear cart
register_rest_route($namespace, '/cart/clear', [
'methods' => 'POST',
'callback' => [__CLASS__, 'clear_cart'],
'permission_callback' => function () {
return true; },
]);
// Remove coupon // Remove coupon
register_rest_route($namespace, '/cart/remove-coupon', [ register_rest_route($namespace, '/cart/remove-coupon', [
'methods' => 'POST', 'methods' => 'POST',
'callback' => [__CLASS__, 'remove_coupon'], 'callback' => [__CLASS__, 'remove_coupon'],
'permission_callback' => function() { return true; }, 'permission_callback' => function () {
'args' => [ return true; },
'args' => [
'coupon_code' => [ 'coupon_code' => [
'required' => true, 'required' => true,
'sanitize_callback' => 'sanitize_text_field', 'type' => 'string',
], ],
], ],
]); ]);
@@ -131,9 +146,18 @@ class CartController {
/** /**
* Get cart contents * Get cart contents
*/ */
public static function get_cart(WP_REST_Request $request) { public static function get_cart(WP_REST_Request $request)
{
// Initialize WooCommerce session and cart for REST API requests
if (!WC()->session) {
WC()->initialize_session();
}
if (!WC()->cart) { if (!WC()->cart) {
return new WP_Error('cart_error', 'Cart not initialized', ['status' => 500]); WC()->initialize_cart();
}
// Set session cookie for guest users to persist cart
if (!WC()->session->has_session()) {
WC()->session->set_customer_session_cookie(true);
} }
return new WP_REST_Response(self::format_cart(), 200); return new WP_REST_Response(self::format_cart(), 200);
@@ -142,140 +166,114 @@ class CartController {
/** /**
* Add item to cart * Add item to cart
*/ */
public static function add_to_cart(WP_REST_Request $request) { public static function add_to_cart(WP_REST_Request $request)
$product_id = $request->get_param('product_id'); {
$quantity = $request->get_param('quantity'); $product_id = $request->get_param('product_id');
$quantity = $request->get_param('quantity') ?: 1; // Default to 1
$variation_id = $request->get_param('variation_id'); $variation_id = $request->get_param('variation_id');
error_log("WooNooW Cart: Adding product {$product_id} (variation: {$variation_id}) qty: {$quantity}");
// Check if WooCommerce is available
if (!function_exists('WC')) {
error_log('WooNooW Cart Error: WooCommerce not loaded');
return new WP_Error('wc_not_loaded', 'WooCommerce is not loaded', ['status' => 500]);
}
// Initialize WooCommerce session and cart for REST API requests // Initialize WooCommerce session and cart for REST API requests
// WooCommerce doesn't auto-initialize these for REST API calls
if (!WC()->session) { if (!WC()->session) {
error_log('WooNooW Cart: Initializing WC session for REST API');
WC()->initialize_session(); WC()->initialize_session();
} }
if (!WC()->cart) { if (!WC()->cart) {
error_log('WooNooW Cart: Initializing WC cart for REST API');
WC()->initialize_cart(); WC()->initialize_cart();
} }
// CRITICAL: Set session cookie for guest users to persist cart
// Set session cookie for guest users
if (!WC()->session->has_session()) { if (!WC()->session->has_session()) {
WC()->session->set_customer_session_cookie(true); WC()->session->set_customer_session_cookie(true);
error_log('WooNooW Cart: Session cookie set for guest user');
} }
error_log('WooNooW Cart: WC Session and Cart initialized successfully');
// Validate product // Validate product
$product = wc_get_product($product_id); $product = wc_get_product($product_id);
if (!$product) { if (!$product) {
error_log("WooNooW Cart Error: Product {$product_id} not found");
return new WP_Error('invalid_product', 'Product not found', ['status' => 404]); return new WP_Error('invalid_product', 'Product not found', ['status' => 404]);
} }
error_log("WooNooW Cart: Product validated - {$product->get_name()} (Type: {$product->get_type()})"); // For variable products, get attributes from request or variation
// For variable products, validate the variation and get attributes
$variation_attributes = []; $variation_attributes = [];
if ($variation_id > 0) { if ($variation_id > 0) {
$variation = wc_get_product($variation_id); $variation = wc_get_product($variation_id);
if (!$variation) { if (!$variation) {
error_log("WooNooW Cart Error: Variation {$variation_id} not found"); return new WP_Error('invalid_variation', "Variation not found", ['status' => 404]);
return new WP_Error('invalid_variation', "Variation {$variation_id} not found", ['status' => 404]);
} }
if ($variation->get_parent_id() != $product_id) { if ($variation->get_parent_id() != $product_id) {
error_log("WooNooW Cart Error: Variation {$variation_id} does not belong to product {$product_id}");
return new WP_Error('invalid_variation', "Variation does not belong to this product", ['status' => 400]); return new WP_Error('invalid_variation', "Variation does not belong to this product", ['status' => 400]);
} }
if (!$variation->is_purchasable() || !$variation->is_in_stock()) { if (!$variation->is_in_stock()) {
error_log("WooNooW Cart Error: Variation {$variation_id} is not purchasable or out of stock"); return new WP_Error('variation_not_available', "This variation is out of stock", ['status' => 400]);
return new WP_Error('variation_not_available', "This variation is not available for purchase", ['status' => 400]);
} }
// Get variation attributes from post meta // Build attributes from request parameters (like WooCommerce does)
// WooCommerce stores variation attributes as post meta with 'attribute_' prefix // Check for attribute_* parameters in the request
$variation_attributes = []; $params = $request->get_params();
foreach ($params as $key => $value) {
// Get parent product to know which attributes to look for if (strpos($key, 'attribute_') === 0) {
$parent_product = wc_get_product($product_id); $variation_attributes[sanitize_title($key)] = wc_clean($value);
$parent_attributes = $parent_product->get_attributes();
error_log("WooNooW Cart: Parent product attributes: " . print_r(array_keys($parent_attributes), true));
// For each parent attribute, get the value from variation post meta
foreach ($parent_attributes as $attribute) {
if ($attribute->get_variation()) {
$attribute_name = $attribute->get_name();
$meta_key = 'attribute_' . $attribute_name;
// Get the value from post meta
$attribute_value = get_post_meta($variation_id, $meta_key, true);
error_log("WooNooW Cart: Checking attribute {$attribute_name} (meta key: {$meta_key}): {$attribute_value}");
if (!empty($attribute_value)) {
// WooCommerce expects lowercase attribute names
$wc_attribute_key = 'attribute_' . strtolower($attribute_name);
$variation_attributes[$wc_attribute_key] = $attribute_value;
}
} }
} }
error_log("WooNooW Cart: Variation validated - {$variation->get_name()}"); // If no attributes in request, get from variation meta directly
error_log("WooNooW Cart: Variation attributes extracted: " . print_r($variation_attributes, true)); if (empty($variation_attributes)) {
$parent = wc_get_product($product_id);
foreach ($parent->get_attributes() as $attr_name => $attribute) {
if (!$attribute->get_variation())
continue;
$meta_key = 'attribute_' . $attr_name;
$value = get_post_meta($variation_id, $meta_key, true);
if (!empty($value)) {
$variation_attributes[$meta_key] = $value;
}
}
}
} }
// Clear any existing notices before adding to cart // Clear any existing notices before adding to cart
wc_clear_notices(); wc_clear_notices();
// Add to cart with variation attributes // Add to cart with variation attributes
error_log("WooNooW Cart: Calling WC()->cart->add_to_cart({$product_id}, {$quantity}, {$variation_id}, attributes)");
$cart_item_key = WC()->cart->add_to_cart($product_id, $quantity, $variation_id, $variation_attributes); $cart_item_key = WC()->cart->add_to_cart($product_id, $quantity, $variation_id, $variation_attributes);
if (!$cart_item_key) { if (!$cart_item_key) {
// Get WooCommerce notices to provide better error message
$notices = wc_get_notices('error'); $notices = wc_get_notices('error');
$error_messages = []; $error_messages = [];
foreach ($notices as $notice) { foreach ($notices as $notice) {
$error_messages[] = is_array($notice) ? $notice['notice'] : $notice; $error_messages[] = is_array($notice) ? $notice['notice'] : $notice;
} }
$error_message = !empty($error_messages) ? implode(', ', $error_messages) : 'Failed to add product to cart'; $error_message = !empty($error_messages) ? implode(', ', $error_messages) : 'Failed to add product to cart';
wc_clear_notices(); // Clear notices after reading wc_clear_notices();
error_log("WooNooW Cart Error: add_to_cart returned false - {$error_message}");
error_log("WooNooW Cart Error: All WC notices: " . print_r($notices, true));
return new WP_Error('add_to_cart_failed', $error_message, ['status' => 400]); return new WP_Error('add_to_cart_failed', $error_message, ['status' => 400]);
} }
error_log("WooNooW Cart: Product added successfully - Key: {$cart_item_key}");
return new WP_REST_Response([ return new WP_REST_Response([
'message' => 'Product added to cart', 'message' => 'Product added to cart',
'cart_item_key' => $cart_item_key, 'cart_item_key' => $cart_item_key,
'cart' => self::format_cart(), 'cart' => self::format_cart(),
], 200); ], 200);
} }
/** /**
* Update cart item quantity * Update cart item quantity
*/ */
public static function update_cart(WP_REST_Request $request) { public static function update_cart(WP_REST_Request $request)
{
$cart_item_key = $request->get_param('cart_item_key'); $cart_item_key = $request->get_param('cart_item_key');
$quantity = $request->get_param('quantity'); $quantity = $request->get_param('quantity');
// Initialize WooCommerce session and cart for REST API requests
if (!WC()->session) {
WC()->initialize_session();
}
if (!WC()->cart) { if (!WC()->cart) {
return new WP_Error('cart_error', 'Cart not initialized', ['status' => 500]); WC()->initialize_cart();
}
if (!WC()->session->has_session()) {
WC()->session->set_customer_session_cookie(true);
} }
// Update quantity // Update quantity
@@ -287,18 +285,32 @@ class CartController {
return new WP_REST_Response([ return new WP_REST_Response([
'message' => 'Cart updated', 'message' => 'Cart updated',
'cart' => self::format_cart(), 'cart' => self::format_cart(),
], 200); ], 200);
} }
/** /**
* Remove item from cart * Remove item from cart
*/ */
public static function remove_from_cart(WP_REST_Request $request) { public static function remove_from_cart(WP_REST_Request $request)
{
$cart_item_key = $request->get_param('cart_item_key'); $cart_item_key = $request->get_param('cart_item_key');
// Initialize WooCommerce session and cart for REST API requests
if (!WC()->session) {
WC()->initialize_session();
}
if (!WC()->cart) { if (!WC()->cart) {
return new WP_Error('cart_error', 'Cart not initialized', ['status' => 500]); WC()->initialize_cart();
}
if (!WC()->session->has_session()) {
WC()->session->set_customer_session_cookie(true);
}
// Check if item exists in cart
$cart_contents = WC()->cart->get_cart();
if (!isset($cart_contents[$cart_item_key])) {
return new WP_Error('item_not_found', "Cart item not found", ['status' => 404]);
} }
// Remove item // Remove item
@@ -310,14 +322,40 @@ class CartController {
return new WP_REST_Response([ return new WP_REST_Response([
'message' => 'Item removed from cart', 'message' => 'Item removed from cart',
'cart' => self::format_cart(), 'cart' => self::format_cart(),
], 200);
}
/**
* Clear entire cart
*/
public static function clear_cart(WP_REST_Request $request)
{
// Initialize WooCommerce session and cart for REST API requests
if (!WC()->session) {
WC()->initialize_session();
}
if (!WC()->cart) {
WC()->initialize_cart();
}
if (!WC()->session->has_session()) {
WC()->session->set_customer_session_cookie(true);
}
// Empty the cart
WC()->cart->empty_cart();
return new WP_REST_Response([
'message' => 'Cart cleared',
'cart' => self::format_cart(),
], 200); ], 200);
} }
/** /**
* Apply coupon to cart * Apply coupon to cart
*/ */
public static function apply_coupon(WP_REST_Request $request) { public static function apply_coupon(WP_REST_Request $request)
{
$coupon_code = $request->get_param('coupon_code'); $coupon_code = $request->get_param('coupon_code');
if (!WC()->cart) { if (!WC()->cart) {
@@ -333,14 +371,15 @@ class CartController {
return new WP_REST_Response([ return new WP_REST_Response([
'message' => 'Coupon applied', 'message' => 'Coupon applied',
'cart' => self::format_cart(), 'cart' => self::format_cart(),
], 200); ], 200);
} }
/** /**
* Remove coupon from cart * Remove coupon from cart
*/ */
public static function remove_coupon(WP_REST_Request $request) { public static function remove_coupon(WP_REST_Request $request)
{
$coupon_code = $request->get_param('coupon_code'); $coupon_code = $request->get_param('coupon_code');
if (!WC()->cart) { if (!WC()->cart) {
@@ -356,14 +395,15 @@ class CartController {
return new WP_REST_Response([ return new WP_REST_Response([
'message' => 'Coupon removed', 'message' => 'Coupon removed',
'cart' => self::format_cart(), 'cart' => self::format_cart(),
], 200); ], 200);
} }
/** /**
* Format cart data for API response * Format cart data for API response
*/ */
private static function format_cart() { private static function format_cart()
{
$cart = WC()->cart; $cart = WC()->cart;
if (!$cart) { if (!$cart) {
@@ -374,18 +414,30 @@ class CartController {
foreach ($cart->get_cart() as $cart_item_key => $cart_item) { foreach ($cart->get_cart() as $cart_item_key => $cart_item) {
$product = $cart_item['data']; $product = $cart_item['data'];
// Format variation attributes with clean names (Size instead of attribute_size)
$formatted_attributes = [];
if (!empty($cart_item['variation'])) {
foreach ($cart_item['variation'] as $attr_key => $attr_value) {
// Remove 'attribute_' prefix and capitalize
$clean_key = str_replace('attribute_', '', $attr_key);
$clean_key = ucfirst($clean_key);
// Capitalize value
$formatted_attributes[$clean_key] = ucfirst($attr_value);
}
}
$items[] = [ $items[] = [
'key' => $cart_item_key, 'key' => $cart_item_key,
'product_id' => $cart_item['product_id'], 'product_id' => $cart_item['product_id'],
'variation_id' => $cart_item['variation_id'] ?? 0, 'variation_id' => $cart_item['variation_id'] ?? 0,
'quantity' => $cart_item['quantity'], 'quantity' => $cart_item['quantity'],
'name' => $product->get_name(), 'name' => $product->get_name(),
'price' => $product->get_price(), 'price' => $product->get_price(),
'subtotal' => $cart_item['line_subtotal'], 'subtotal' => $cart_item['line_subtotal'],
'total' => $cart_item['line_total'], 'total' => $cart_item['line_total'],
'image' => wp_get_attachment_url($product->get_image_id()), 'image' => wp_get_attachment_url($product->get_image_id()),
'permalink' => get_permalink($cart_item['product_id']), 'permalink' => get_permalink($cart_item['product_id']),
'attributes' => $cart_item['variation'] ?? [], 'attributes' => $formatted_attributes,
]; ];
} }
@@ -394,28 +446,28 @@ class CartController {
foreach ($cart->get_applied_coupons() as $coupon_code) { foreach ($cart->get_applied_coupons() as $coupon_code) {
$coupon = new \WC_Coupon($coupon_code); $coupon = new \WC_Coupon($coupon_code);
$coupons[] = [ $coupons[] = [
'code' => $coupon_code, 'code' => $coupon_code,
'discount' => $cart->get_coupon_discount_amount($coupon_code), 'discount' => $cart->get_coupon_discount_amount($coupon_code),
'type' => $coupon->get_discount_type(), 'type' => $coupon->get_discount_type(),
]; ];
} }
return [ return [
'items' => $items, 'items' => $items,
'subtotal' => $cart->get_subtotal(), 'subtotal' => $cart->get_subtotal(),
'subtotal_tax' => $cart->get_subtotal_tax(), 'subtotal_tax' => $cart->get_subtotal_tax(),
'discount_total' => $cart->get_discount_total(), 'discount_total' => $cart->get_discount_total(),
'discount_tax' => $cart->get_discount_tax(), 'discount_tax' => $cart->get_discount_tax(),
'shipping_total' => $cart->get_shipping_total(), 'shipping_total' => $cart->get_shipping_total(),
'shipping_tax' => $cart->get_shipping_tax(), 'shipping_tax' => $cart->get_shipping_tax(),
'cart_contents_tax' => $cart->get_cart_contents_tax(), 'cart_contents_tax' => $cart->get_cart_contents_tax(),
'fee_total' => $cart->get_fee_total(), 'fee_total' => $cart->get_fee_total(),
'fee_tax' => $cart->get_fee_tax(), 'fee_tax' => $cart->get_fee_tax(),
'total' => $cart->get_total('edit'), 'total' => $cart->get_total('edit'),
'total_tax' => $cart->get_total_tax(), 'total_tax' => $cart->get_total_tax(),
'coupons' => $coupons, 'coupons' => $coupons,
'needs_shipping' => $cart->needs_shipping(), 'needs_shipping' => $cart->needs_shipping(),
'needs_payment' => $cart->needs_payment(), 'needs_payment' => $cart->needs_payment(),
]; ];
} }
} }

View File

@@ -5,12 +5,18 @@ namespace WooNooW\Frontend;
* Template Override * Template Override
* Overrides WooCommerce templates to use WooNooW SPA * Overrides WooCommerce templates to use WooNooW SPA
*/ */
class TemplateOverride { class TemplateOverride
{
/** /**
* Initialize * Initialize
*/ */
public static function init() { public static function init()
{
// Hook to wp_loaded with priority 10 (BEFORE WooCommerce's priority 20)
// This ensures we process add-to-cart before WooCommerce does
add_action('wp_loaded', [__CLASS__, 'intercept_add_to_cart'], 10);
// Use blank template for full-page SPA // Use blank template for full-page SPA
add_filter('template_include', [__CLASS__, 'use_spa_template'], 999); add_filter('template_include', [__CLASS__, 'use_spa_template'], 999);
@@ -38,11 +44,61 @@ class TemplateOverride {
add_action('get_footer', [__CLASS__, 'remove_theme_footer']); add_action('get_footer', [__CLASS__, 'remove_theme_footer']);
} }
/**
* Intercept add-to-cart redirect (NOT the add-to-cart itself)
* Let WooCommerce handle the cart operation properly, we just redirect afterward
*
* This is the proper approach - WooCommerce manages sessions correctly,
* we just customize where the redirect goes.
*/
public static function intercept_add_to_cart()
{
// Only act if add-to-cart is present
if (!isset($_GET['add-to-cart'])) {
return;
}
// Get SPA page from appearance settings
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = isset($appearance_settings['general']['spa_page']) ? $appearance_settings['general']['spa_page'] : 0;
if (!$spa_page_id) {
return; // No SPA page configured, let WooCommerce handle everything
}
// Hook into WooCommerce's redirect filter AFTER it adds to cart
// This is the proper way to customize the redirect destination
add_filter('woocommerce_add_to_cart_redirect', function ($url) use ($spa_page_id) {
// Get redirect parameter from original request
$redirect_to = isset($_GET['redirect']) ? sanitize_text_field($_GET['redirect']) : 'cart';
// Build redirect URL with hash route for SPA
$redirect_url = get_permalink($spa_page_id);
// Determine hash route based on redirect parameter
$hash_route = '/cart'; // Default
if ($redirect_to === 'checkout') {
$hash_route = '/checkout';
} elseif ($redirect_to === 'shop') {
$hash_route = '/shop';
}
// Return the SPA URL with hash route
return trailingslashit($redirect_url) . '#' . $hash_route;
}, 999);
// Prevent caching
add_action('template_redirect', function () {
nocache_headers();
}, 1);
}
/** /**
* Disable canonical redirects for SPA routes * Disable canonical redirects for SPA routes
* This prevents WordPress from redirecting /product/slug URLs * This prevents WordPress from redirecting /product/slug URLs
*/ */
public static function disable_canonical_redirect($redirect_url, $requested_url) { public static function disable_canonical_redirect($redirect_url, $requested_url)
{
$settings = get_option('woonoow_customer_spa_settings', []); $settings = get_option('woonoow_customer_spa_settings', []);
$mode = isset($settings['mode']) ? $settings['mode'] : 'disabled'; $mode = isset($settings['mode']) ? $settings['mode'] : 'disabled';
@@ -67,7 +123,8 @@ class TemplateOverride {
/** /**
* Use SPA template (blank page) * Use SPA template (blank page)
*/ */
public static function use_spa_template($template) { public static function use_spa_template($template)
{
// Check if current page is a designated SPA page // Check if current page is a designated SPA page
if (self::is_spa_page()) { if (self::is_spa_page()) {
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php'; $spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
@@ -84,12 +141,14 @@ class TemplateOverride {
if ($mode === 'disabled') { if ($mode === 'disabled') {
// Check if page has woonoow shortcodes // Check if page has woonoow shortcodes
global $post; global $post;
if ($post && ( if (
has_shortcode($post->post_content, 'woonoow_shop') || $post && (
has_shortcode($post->post_content, 'woonoow_cart') || has_shortcode($post->post_content, 'woonoow_shop') ||
has_shortcode($post->post_content, 'woonoow_checkout') || has_shortcode($post->post_content, 'woonoow_cart') ||
has_shortcode($post->post_content, 'woonoow_account') has_shortcode($post->post_content, 'woonoow_checkout') ||
)) { has_shortcode($post->post_content, 'woonoow_account')
)
) {
// Use blank template for shortcode pages too // Use blank template for shortcode pages too
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php'; $spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
if (file_exists($spa_template)) { if (file_exists($spa_template)) {
@@ -126,8 +185,8 @@ class TemplateOverride {
$checkout_pages = isset($settings['checkoutPages']) ? $settings['checkoutPages'] : [ $checkout_pages = isset($settings['checkoutPages']) ? $settings['checkoutPages'] : [
'checkout' => true, 'checkout' => true,
'thankyou' => true, 'thankyou' => true,
'account' => true, 'account' => true,
'cart' => false, 'cart' => false,
]; ];
$should_override = false; $should_override = false;
@@ -172,7 +231,8 @@ class TemplateOverride {
/** /**
* Start SPA wrapper * Start SPA wrapper
*/ */
public static function start_spa_wrapper() { public static function start_spa_wrapper()
{
// Check if we should use SPA // Check if we should use SPA
if (!self::should_use_spa()) { if (!self::should_use_spa()) {
return; return;
@@ -211,7 +271,8 @@ class TemplateOverride {
/** /**
* End SPA wrapper * End SPA wrapper
*/ */
public static function end_spa_wrapper() { public static function end_spa_wrapper()
{
if (!self::should_use_spa()) { if (!self::should_use_spa()) {
return; return;
} }
@@ -223,7 +284,8 @@ class TemplateOverride {
/** /**
* Check if we should use SPA * Check if we should use SPA
*/ */
private static function should_use_spa() { private static function should_use_spa()
{
// Check if frontend mode is enabled // Check if frontend mode is enabled
$mode = get_option('woonoow_frontend_mode', 'shortcodes'); $mode = get_option('woonoow_frontend_mode', 'shortcodes');
@@ -247,7 +309,8 @@ class TemplateOverride {
/** /**
* Remove theme header when SPA is active * Remove theme header when SPA is active
*/ */
public static function remove_theme_header() { public static function remove_theme_header()
{
if (self::should_remove_theme_elements()) { if (self::should_remove_theme_elements()) {
remove_all_actions('wp_head'); remove_all_actions('wp_head');
// Re-add essential WordPress head actions // Re-add essential WordPress head actions
@@ -262,7 +325,8 @@ class TemplateOverride {
/** /**
* Remove theme footer when SPA is active * Remove theme footer when SPA is active
*/ */
public static function remove_theme_footer() { public static function remove_theme_footer()
{
if (self::should_remove_theme_elements()) { if (self::should_remove_theme_elements()) {
remove_all_actions('wp_footer'); remove_all_actions('wp_footer');
// Re-add essential WordPress footer actions // Re-add essential WordPress footer actions
@@ -273,7 +337,8 @@ class TemplateOverride {
/** /**
* Check if current page is the designated SPA page * Check if current page is the designated SPA page
*/ */
private static function is_spa_page() { private static function is_spa_page()
{
global $post; global $post;
if (!$post) { if (!$post) {
return false; return false;
@@ -294,7 +359,8 @@ class TemplateOverride {
/** /**
* Check if we should remove theme header/footer * Check if we should remove theme header/footer
*/ */
private static function should_remove_theme_elements() { private static function should_remove_theme_elements()
{
// Remove for designated SPA pages // Remove for designated SPA pages
if (self::is_spa_page()) { if (self::is_spa_page()) {
return true; return true;
@@ -312,12 +378,14 @@ class TemplateOverride {
// Also remove for pages with shortcodes (even in disabled mode) // Also remove for pages with shortcodes (even in disabled mode)
global $post; global $post;
if ($post && ( if (
has_shortcode($post->post_content, 'woonoow_shop') || $post && (
has_shortcode($post->post_content, 'woonoow_cart') || has_shortcode($post->post_content, 'woonoow_shop') ||
has_shortcode($post->post_content, 'woonoow_checkout') || has_shortcode($post->post_content, 'woonoow_cart') ||
has_shortcode($post->post_content, 'woonoow_account') has_shortcode($post->post_content, 'woonoow_checkout') ||
)) { has_shortcode($post->post_content, 'woonoow_account')
)
) {
return true; return true;
} }
@@ -338,7 +406,8 @@ class TemplateOverride {
/** /**
* Override WooCommerce templates * Override WooCommerce templates
*/ */
public static function override_template($template, $template_name, $template_path) { public static function override_template($template, $template_name, $template_path)
{
// Only override if SPA is enabled // Only override if SPA is enabled
if (!self::should_use_spa()) { if (!self::should_use_spa()) {
return $template; return $template;

View File

@@ -12,21 +12,15 @@
$appearance_settings = get_option('woonoow_appearance_settings', []); $appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_mode = isset($appearance_settings['general']['spa_mode']) ? $appearance_settings['general']['spa_mode'] : 'full'; $spa_mode = isset($appearance_settings['general']['spa_mode']) ? $appearance_settings['general']['spa_mode'] : 'full';
// Debug logging
error_log('[WooNooW SPA Template] Settings: ' . print_r($appearance_settings, true));
error_log('[WooNooW SPA Template] SPA Mode: ' . $spa_mode);
// Set initial page based on mode // Set initial page based on mode
if ($spa_mode === 'checkout_only') { if ($spa_mode === 'checkout_only') {
// Checkout Only mode starts at cart // Checkout Only mode starts at cart
$page_type = 'cart'; $page_type = 'cart';
$data_attrs = 'data-page="cart" data-initial-route="/cart"'; $data_attrs = 'data-page="cart" data-initial-route="/cart"';
error_log('[WooNooW SPA Template] Using CART initial route');
} else { } else {
// Full SPA mode starts at shop // Full SPA mode starts at shop
$page_type = 'shop'; $page_type = 'shop';
$data_attrs = 'data-page="shop" data-initial-route="/shop"'; $data_attrs = 'data-page="shop" data-initial-route="/shop"';
error_log('[WooNooW SPA Template] Using SHOP initial route');
} }
?> ?>