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:
@@ -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-search {
|
||||
border: none !important;
|
||||
|
||||
@@ -127,6 +127,7 @@ export default function ProductEdit() {
|
||||
onSubmit={handleSubmit}
|
||||
formRef={formRef}
|
||||
hideSubmitButton={true}
|
||||
productId={product.id}
|
||||
/>
|
||||
|
||||
{/* Level 1 compatibility: Custom meta fields from plugins */}
|
||||
|
||||
215
admin-spa/src/routes/Products/partials/DirectCartLinks.tsx
Normal file
215
admin-spa/src/routes/Products/partials/DirectCartLinks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,7 @@ type Props = {
|
||||
className?: string;
|
||||
formRef?: React.RefObject<HTMLFormElement>;
|
||||
hideSubmitButton?: boolean;
|
||||
productId?: number;
|
||||
};
|
||||
|
||||
export function ProductFormTabbed({
|
||||
@@ -50,6 +51,7 @@ export function ProductFormTabbed({
|
||||
className,
|
||||
formRef,
|
||||
hideSubmitButton = false,
|
||||
productId,
|
||||
}: Props) {
|
||||
// Form state
|
||||
const [name, setName] = useState(initial?.name || '');
|
||||
@@ -225,6 +227,7 @@ export function ProductFormTabbed({
|
||||
variations={variations}
|
||||
setVariations={setVariations}
|
||||
regularPrice={regularPrice}
|
||||
productId={productId}
|
||||
/>
|
||||
</FormSection>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
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 { getStoreCurrency } from '@/lib/currency';
|
||||
import { openWPMediaImage } from '@/lib/wp-media';
|
||||
@@ -30,6 +30,7 @@ type VariationsTabProps = {
|
||||
variations: ProductVariant[];
|
||||
setVariations: (value: ProductVariant[]) => void;
|
||||
regularPrice: string;
|
||||
productId?: number;
|
||||
};
|
||||
|
||||
export function VariationsTab({
|
||||
@@ -38,8 +39,33 @@ export function VariationsTab({
|
||||
variations,
|
||||
setVariations,
|
||||
regularPrice,
|
||||
productId,
|
||||
}: VariationsTabProps) {
|
||||
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 = () => {
|
||||
setAttributes([...attributes, { name: '', options: [], variation: false }]);
|
||||
@@ -305,6 +331,45 @@ export function VariationsTab({
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -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")]
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
important: '#woonoow-admin-app',
|
||||
content: ["./src/**/*.{ts,tsx,css}", "./components/**/*.{ts,tsx,css}"],
|
||||
theme: {
|
||||
container: { center: true, padding: "1rem" },
|
||||
|
||||
20
composer.lock
generated
Normal file
20
composer.lock
generated
Normal 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"
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { Toaster } from 'sonner';
|
||||
// Theme
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { BaseLayout } from './layouts/BaseLayout';
|
||||
import { useAddToCartFromUrl } from './hooks/useAddToCartFromUrl';
|
||||
|
||||
// Pages
|
||||
import Shop from './pages/Shop';
|
||||
@@ -52,31 +51,22 @@ const getAppearanceSettings = () => {
|
||||
return (window as any).woonoowCustomer?.appearanceSettings || {};
|
||||
};
|
||||
|
||||
function App() {
|
||||
const themeConfig = getThemeConfig();
|
||||
const appearanceSettings = getAppearanceSettings();
|
||||
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 = () => {
|
||||
// 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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider config={themeConfig}>
|
||||
<HashRouter>
|
||||
<BaseLayout>
|
||||
<Routes>
|
||||
{/* Root route redirects to initial route based on SPA mode */}
|
||||
@@ -101,6 +91,19 @@ function App() {
|
||||
<Route path="*" element={<Navigate to={initialRoute} replace />} />
|
||||
</Routes>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const themeConfig = getThemeConfig();
|
||||
const appearanceSettings = getAppearanceSettings();
|
||||
const toastPosition = (appearanceSettings?.general?.toast_position || 'top-right') as any;
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider config={themeConfig}>
|
||||
<HashRouter>
|
||||
<AppRoutes />
|
||||
</HashRouter>
|
||||
|
||||
{/* Toast notifications - position from settings */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { useCartStore } from '@/lib/cart/store';
|
||||
|
||||
/**
|
||||
* Hook to handle add-to-cart from URL parameters
|
||||
@@ -10,51 +11,81 @@ import { toast } from 'sonner';
|
||||
* - Simple product: ?add-to-cart=123
|
||||
* - Variable product: ?add-to-cart=123&variation_id=456
|
||||
* - 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() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { setCart } = useCartStore();
|
||||
const processedRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const productId = params.get('add-to-cart');
|
||||
// Check hash route for add-to-cart parameters
|
||||
const hash = window.location.hash;
|
||||
const hashParams = new URLSearchParams(hash.split('?')[1] || '');
|
||||
const productId = hashParams.get('add-to-cart');
|
||||
|
||||
if (!productId) return;
|
||||
|
||||
const variationId = params.get('variation_id');
|
||||
const quantity = parseInt(params.get('quantity') || '1', 10);
|
||||
const variationId = hashParams.get('variation_id');
|
||||
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:', {
|
||||
productId,
|
||||
variationId,
|
||||
quantity,
|
||||
redirect,
|
||||
fullUrl: window.location.href,
|
||||
requestKey,
|
||||
});
|
||||
|
||||
// Add product to cart
|
||||
addToCart(productId, variationId, quantity)
|
||||
.then(() => {
|
||||
// Remove URL parameters after adding to cart
|
||||
const cleanUrl = window.location.pathname + window.location.hash;
|
||||
window.history.replaceState({}, '', cleanUrl);
|
||||
// Mark as processed
|
||||
processedRef.current.add(requestKey);
|
||||
|
||||
// Navigate to cart if not already there
|
||||
if (!location.pathname.includes('/cart')) {
|
||||
navigate('/cart');
|
||||
addToCart(productId, variationId, quantity)
|
||||
.then((cartData) => {
|
||||
// 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) => {
|
||||
console.error('[WooNooW] Failed to add product to cart:', error);
|
||||
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(
|
||||
productId: string,
|
||||
variationId: string | null,
|
||||
quantity: number
|
||||
): Promise<void> {
|
||||
): Promise<any> {
|
||||
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
|
||||
const nonce = (window as any).woonoowCustomer?.nonce || '';
|
||||
|
||||
@@ -85,11 +116,13 @@ async function addToCart(
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
console.log('[WooNooW] Product added to cart:', data);
|
||||
toast.success('Product added to cart');
|
||||
}
|
||||
|
||||
111
customer-spa/src/lib/cart/api.ts
Normal file
111
customer-spa/src/lib/cart/api.ts
Normal 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;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useCartStore, type CartItem } from '@/lib/cart/store';
|
||||
import { useCartSettings } from '@/hooks/useAppearanceSettings';
|
||||
import { updateCartItemQuantity, removeCartItem, clearCartAPI, fetchCart } from '@/lib/cart/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -13,37 +14,96 @@ import {
|
||||
} 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 { Trash2, Plus, Minus, ShoppingBag, ArrowLeft, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function Cart() {
|
||||
const navigate = useNavigate();
|
||||
const { cart, removeItem, updateQuantity, clearCart } = useCartStore();
|
||||
const { cart, setCart } = useCartStore();
|
||||
const { layout, elements } = useCartSettings();
|
||||
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
|
||||
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) {
|
||||
handleRemoveItem(key);
|
||||
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) => {
|
||||
removeItem(key);
|
||||
const handleRemoveItem = async (key: string) => {
|
||||
setIsUpdating(true);
|
||||
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 = () => {
|
||||
clearCart();
|
||||
const handleClearCart = async () => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
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) {
|
||||
return (
|
||||
<Container>
|
||||
|
||||
@@ -6,12 +6,15 @@ use WooNooW\Compat\AddonRegistry;
|
||||
use WooNooW\Compat\RouteRegistry;
|
||||
use WooNooW\Compat\NavigationRegistry;
|
||||
|
||||
class Assets {
|
||||
public static function init() {
|
||||
class Assets
|
||||
{
|
||||
public static function init()
|
||||
{
|
||||
add_action('admin_enqueue_scripts', [__CLASS__, 'enqueue']);
|
||||
}
|
||||
|
||||
public static function enqueue($hook) {
|
||||
public static function enqueue($hook)
|
||||
{
|
||||
// Debug logging
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[WooNooW Assets] Hook: ' . $hook);
|
||||
@@ -42,7 +45,8 @@ class Assets {
|
||||
/** ----------------------------------------
|
||||
* 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
|
||||
|
||||
// 1) Create a small handle to attach config (window.WNW_API)
|
||||
@@ -117,9 +121,9 @@ class Assets {
|
||||
// 1) React Refresh preamble (required by @vitejs/plugin-react)
|
||||
?>
|
||||
<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);
|
||||
window.$RefreshReg$ = () => {};
|
||||
window.$RefreshReg$ = () => { };
|
||||
window.$RefreshSig$ = () => (type) => type;
|
||||
window.__vite_plugin_react_preamble_installed__ = true;
|
||||
</script>
|
||||
@@ -136,7 +140,8 @@ class Assets {
|
||||
/** ----------------------------------------
|
||||
* 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/)
|
||||
$plugin_dir = dirname(dirname(__DIR__));
|
||||
$dist_dir = $plugin_dir . '/admin-spa/dist/';
|
||||
@@ -159,26 +164,14 @@ class Assets {
|
||||
|
||||
if (file_exists($dist_dir . $css)) {
|
||||
wp_enqueue_style('wnw-admin', $base_url . $css, [], $ver_css);
|
||||
|
||||
// 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);
|
||||
// Note: Icon fixes are now in index.css with proper specificity
|
||||
}
|
||||
|
||||
if (file_exists($dist_dir . $js)) {
|
||||
wp_enqueue_script('wnw-admin', $base_url . $js, ['wp-element'], $ver_js, true);
|
||||
|
||||
// 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') {
|
||||
$tag = str_replace('<script ', '<script type="module" ', $tag);
|
||||
}
|
||||
@@ -190,7 +183,8 @@ class Assets {
|
||||
}
|
||||
|
||||
/** 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', [
|
||||
'root' => untrailingslashit(esc_url_raw(rest_url('woonoow/v1'))),
|
||||
'nonce' => wp_create_nonce('wp_rest'),
|
||||
@@ -249,7 +243,8 @@ class Assets {
|
||||
}
|
||||
|
||||
/** 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
|
||||
$currency = function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : 'USD';
|
||||
$currency_sym = function_exists('get_woocommerce_currency_symbol') ? get_woocommerce_currency_symbol($currency) : '$';
|
||||
@@ -275,7 +270,8 @@ class Assets {
|
||||
* Note: We don't check WP_ENV to avoid accidentally enabling dev mode
|
||||
* 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
|
||||
$const_dev = defined('WOONOOW_ADMIN_DEV') && WOONOOW_ADMIN_DEV === true;
|
||||
|
||||
@@ -297,7 +293,8 @@ class Assets {
|
||||
}
|
||||
|
||||
/** 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)
|
||||
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
$protocol = is_ssl() ? 'https' : 'http';
|
||||
@@ -314,7 +311,8 @@ class Assets {
|
||||
}
|
||||
|
||||
/** 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
|
||||
return defined('WOONOOW_VERSION') ? WOONOOW_VERSION : '0.1.0';
|
||||
}
|
||||
|
||||
@@ -38,11 +38,6 @@ class Permissions {
|
||||
$has_wc = current_user_can('manage_woocommerce');
|
||||
$has_opts = current_user_can('manage_options');
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@@ -447,6 +447,7 @@ class ProductsController {
|
||||
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['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['sale_price'])) $product->set_sale_price(self::sanitize_number($data['sale_price']));
|
||||
|
||||
@@ -800,16 +801,19 @@ class ProductsController {
|
||||
$value = $term ? $term->name : $value;
|
||||
}
|
||||
} else {
|
||||
// Custom attribute - WooCommerce stores as 'attribute_' + exact attribute name
|
||||
$meta_key = 'attribute_' . $attr_name;
|
||||
// Custom attribute - stored as lowercase in meta
|
||||
$meta_key = 'attribute_' . strtolower($attr_name);
|
||||
$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);
|
||||
}
|
||||
|
||||
// Only add if value exists
|
||||
if (!empty($value)) {
|
||||
$formatted_attributes[$clean_name] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$image_url = $image ? $image[0] : '';
|
||||
if (!$image_url && $variation->get_image_id()) {
|
||||
@@ -857,36 +861,106 @@ class ProductsController {
|
||||
* Save product variations
|
||||
*/
|
||||
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) {
|
||||
if (isset($var_data['id']) && $var_data['id']) {
|
||||
// Update existing variation
|
||||
$variation = wc_get_product($var_data['id']);
|
||||
if (!$variation) continue;
|
||||
$variations_to_keep[] = $var_data['id'];
|
||||
} else {
|
||||
// Create new variation
|
||||
$variation = new WC_Product_Variation();
|
||||
$variation->set_parent_id($product->get_id());
|
||||
}
|
||||
|
||||
if ($variation) {
|
||||
// Build attributes array
|
||||
$wc_attributes = [];
|
||||
if (isset($var_data['attributes']) && is_array($var_data['attributes'])) {
|
||||
$parent_attributes = $product->get_attributes();
|
||||
|
||||
foreach ($var_data['attributes'] as $display_name => $value) {
|
||||
if (empty($value)) continue;
|
||||
|
||||
foreach ($parent_attributes as $attr_name => $parent_attr) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($wc_attributes)) {
|
||||
$variation->set_attributes($wc_attributes);
|
||||
}
|
||||
|
||||
if (isset($var_data['sku'])) $variation->set_sku($var_data['sku']);
|
||||
if (isset($var_data['regular_price'])) $variation->set_regular_price($var_data['regular_price']);
|
||||
if (isset($var_data['sale_price'])) $variation->set_sale_price($var_data['sale_price']);
|
||||
|
||||
// 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['attributes'])) $variation->set_attributes($var_data['attributes']);
|
||||
|
||||
// Handle image - support both image_id and image URL
|
||||
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);
|
||||
}
|
||||
if ($image_id) $variation->set_image_id($image_id);
|
||||
} elseif (isset($var_data['image_id'])) {
|
||||
$variation->set_image_id($var_data['image_id']);
|
||||
}
|
||||
|
||||
$variation->save();
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,5 +66,64 @@ class Bootstrap {
|
||||
MailQueue::init();
|
||||
WooEmailOverride::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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,15 +35,11 @@ class Assets {
|
||||
public static function enqueue_assets() {
|
||||
// Only load on pages with WooNooW shortcodes or in full SPA mode
|
||||
if (!self::should_load_assets()) {
|
||||
error_log('[WooNooW Customer] should_load_assets returned false - not loading');
|
||||
return;
|
||||
}
|
||||
|
||||
error_log('[WooNooW Customer] should_load_assets returned true - loading assets');
|
||||
|
||||
// Check if dev mode is enabled
|
||||
$is_dev = defined('WOONOOW_CUSTOMER_DEV') && WOONOOW_CUSTOMER_DEV;
|
||||
error_log('[WooNooW Customer] Dev mode: ' . ($is_dev ? 'true' : 'false'));
|
||||
|
||||
if ($is_dev) {
|
||||
// Dev mode: Load from Vite dev server
|
||||
@@ -66,9 +62,6 @@ class Assets {
|
||||
null,
|
||||
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 {
|
||||
// Production mode: Load from build
|
||||
$plugin_url = plugin_dir_url(dirname(dirname(__FILE__)));
|
||||
@@ -76,7 +69,6 @@ class Assets {
|
||||
|
||||
// Check if build exists
|
||||
if (!file_exists($dist_path)) {
|
||||
error_log('WooNooW: customer-spa build not found. Run: cd customer-spa && npm run build');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -84,9 +76,6 @@ class Assets {
|
||||
$js_url = $plugin_url . 'customer-spa/dist/app.js';
|
||||
$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(
|
||||
'woonoow-customer-spa',
|
||||
$js_url,
|
||||
@@ -109,8 +98,6 @@ class Assets {
|
||||
[],
|
||||
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; ?>/src/main.tsx"></script>
|
||||
<?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() {
|
||||
global $post;
|
||||
|
||||
error_log('[WooNooW Customer] should_load_assets check - Post ID: ' . ($post ? $post->ID : 'none'));
|
||||
|
||||
// First check: Is this a designated SPA page?
|
||||
if (self::is_spa_page()) {
|
||||
error_log('[WooNooW Customer] Designated SPA page detected - loading assets');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -264,8 +247,6 @@ class Assets {
|
||||
$spa_settings = get_option('woonoow_customer_spa_settings', []);
|
||||
$mode = isset($spa_settings['mode']) ? $spa_settings['mode'] : 'disabled';
|
||||
|
||||
error_log('[WooNooW Customer] SPA mode: ' . $mode);
|
||||
|
||||
// If disabled, don't load
|
||||
if ($mode === 'disabled') {
|
||||
// Special handling for WooCommerce Shop page (it's an archive, not a regular post)
|
||||
@@ -274,7 +255,6 @@ class Assets {
|
||||
if ($shop_page_id) {
|
||||
$shop_page = get_post($shop_page_id);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -282,27 +262,19 @@ class Assets {
|
||||
|
||||
// Check for shortcodes on regular pages
|
||||
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')) {
|
||||
error_log('[WooNooW Customer] Found woonoow_shop shortcode');
|
||||
return true;
|
||||
}
|
||||
if (has_shortcode($post->post_content, 'woonoow_cart')) {
|
||||
error_log('[WooNooW Customer] Found woonoow_cart shortcode');
|
||||
return true;
|
||||
}
|
||||
if (has_shortcode($post->post_content, 'woonoow_checkout')) {
|
||||
error_log('[WooNooW Customer] Found woonoow_checkout shortcode');
|
||||
return true;
|
||||
}
|
||||
if (has_shortcode($post->post_content, 'woonoow_account')) {
|
||||
error_log('[WooNooW Customer] Found woonoow_account shortcode');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
error_log('[WooNooW Customer] No shortcodes found, not loading');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,14 +9,16 @@ use WP_Error;
|
||||
* Cart Controller - Customer-facing cart API
|
||||
* Handles cart operations for customer-spa
|
||||
*/
|
||||
class CartController {
|
||||
class CartController
|
||||
{
|
||||
|
||||
/**
|
||||
* Initialize controller
|
||||
*/
|
||||
public static function init() {
|
||||
public static function init()
|
||||
{
|
||||
// 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 (!empty($result)) {
|
||||
return $result;
|
||||
@@ -35,7 +37,8 @@ class CartController {
|
||||
/**
|
||||
* Register REST API routes
|
||||
*/
|
||||
public static function register_routes() {
|
||||
public static function register_routes()
|
||||
{
|
||||
$namespace = 'woonoow/v1';
|
||||
|
||||
// Get cart
|
||||
@@ -49,14 +52,14 @@ class CartController {
|
||||
$result = register_rest_route($namespace, '/cart/add', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'add_to_cart'],
|
||||
'permission_callback' => function() {
|
||||
'permission_callback' => function () {
|
||||
// Allow both logged-in and guest users
|
||||
return true;
|
||||
},
|
||||
'args' => [
|
||||
'product_id' => [
|
||||
'required' => true,
|
||||
'validate_callback' => function($param) {
|
||||
'validate_callback' => function ($param) {
|
||||
return is_numeric($param);
|
||||
},
|
||||
],
|
||||
@@ -75,7 +78,8 @@ class CartController {
|
||||
register_rest_route($namespace, '/cart/update', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'update_cart'],
|
||||
'permission_callback' => function() { return true; },
|
||||
'permission_callback' => function () {
|
||||
return true; },
|
||||
'args' => [
|
||||
'cart_item_key' => [
|
||||
'required' => true,
|
||||
@@ -92,7 +96,8 @@ class CartController {
|
||||
register_rest_route($namespace, '/cart/remove', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'remove_from_cart'],
|
||||
'permission_callback' => function() { return true; },
|
||||
'permission_callback' => function () {
|
||||
return true; },
|
||||
'args' => [
|
||||
'cart_item_key' => [
|
||||
'required' => true,
|
||||
@@ -105,7 +110,8 @@ class CartController {
|
||||
register_rest_route($namespace, '/cart/apply-coupon', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'apply_coupon'],
|
||||
'permission_callback' => function() { return true; },
|
||||
'permission_callback' => function () {
|
||||
return true; },
|
||||
'args' => [
|
||||
'coupon_code' => [
|
||||
'required' => true,
|
||||
@@ -114,15 +120,24 @@ class CartController {
|
||||
],
|
||||
]);
|
||||
|
||||
// Clear cart
|
||||
register_rest_route($namespace, '/cart/clear', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'clear_cart'],
|
||||
'permission_callback' => function () {
|
||||
return true; },
|
||||
]);
|
||||
|
||||
// Remove coupon
|
||||
register_rest_route($namespace, '/cart/remove-coupon', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [__CLASS__, 'remove_coupon'],
|
||||
'permission_callback' => function() { return true; },
|
||||
'permission_callback' => function () {
|
||||
return true; },
|
||||
'args' => [
|
||||
'coupon_code' => [
|
||||
'required' => true,
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
'type' => 'string',
|
||||
],
|
||||
],
|
||||
]);
|
||||
@@ -131,9 +146,18 @@ class CartController {
|
||||
/**
|
||||
* 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) {
|
||||
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);
|
||||
@@ -142,124 +166,90 @@ class CartController {
|
||||
/**
|
||||
* 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');
|
||||
$quantity = $request->get_param('quantity') ?: 1; // Default to 1
|
||||
$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
|
||||
// WooCommerce doesn't auto-initialize these for REST API calls
|
||||
if (!WC()->session) {
|
||||
error_log('WooNooW Cart: Initializing WC session for REST API');
|
||||
WC()->initialize_session();
|
||||
}
|
||||
|
||||
if (!WC()->cart) {
|
||||
error_log('WooNooW Cart: Initializing WC cart for REST API');
|
||||
WC()->initialize_cart();
|
||||
}
|
||||
|
||||
// Set session cookie for guest users
|
||||
// CRITICAL: Set session cookie for guest users to persist cart
|
||||
if (!WC()->session->has_session()) {
|
||||
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
|
||||
$product = wc_get_product($product_id);
|
||||
if (!$product) {
|
||||
error_log("WooNooW Cart Error: Product {$product_id} not found");
|
||||
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, validate the variation and get attributes
|
||||
// For variable products, get attributes from request or variation
|
||||
$variation_attributes = [];
|
||||
if ($variation_id > 0) {
|
||||
$variation = wc_get_product($variation_id);
|
||||
if (!$variation) {
|
||||
error_log("WooNooW Cart Error: Variation {$variation_id} not found");
|
||||
return new WP_Error('invalid_variation', "Variation {$variation_id} not found", ['status' => 404]);
|
||||
return new WP_Error('invalid_variation', "Variation not found", ['status' => 404]);
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
if (!$variation->is_purchasable() || !$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 not available for purchase", ['status' => 400]);
|
||||
if (!$variation->is_in_stock()) {
|
||||
return new WP_Error('variation_not_available', "This variation is out of stock", ['status' => 400]);
|
||||
}
|
||||
|
||||
// Get variation attributes from post meta
|
||||
// WooCommerce stores variation attributes as post meta with 'attribute_' prefix
|
||||
$variation_attributes = [];
|
||||
// Build attributes from request parameters (like WooCommerce does)
|
||||
// Check for attribute_* parameters in the request
|
||||
$params = $request->get_params();
|
||||
foreach ($params as $key => $value) {
|
||||
if (strpos($key, 'attribute_') === 0) {
|
||||
$variation_attributes[sanitize_title($key)] = wc_clean($value);
|
||||
}
|
||||
}
|
||||
|
||||
// Get parent product to know which attributes to look for
|
||||
$parent_product = wc_get_product($product_id);
|
||||
$parent_attributes = $parent_product->get_attributes();
|
||||
// If no attributes in request, get from variation meta directly
|
||||
if (empty($variation_attributes)) {
|
||||
$parent = wc_get_product($product_id);
|
||||
foreach ($parent->get_attributes() as $attr_name => $attribute) {
|
||||
if (!$attribute->get_variation())
|
||||
continue;
|
||||
|
||||
error_log("WooNooW Cart: Parent product attributes: " . print_r(array_keys($parent_attributes), true));
|
||||
$meta_key = 'attribute_' . $attr_name;
|
||||
$value = get_post_meta($variation_id, $meta_key, 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;
|
||||
if (!empty($value)) {
|
||||
$variation_attributes[$meta_key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
error_log("WooNooW Cart: Variation validated - {$variation->get_name()}");
|
||||
error_log("WooNooW Cart: Variation attributes extracted: " . print_r($variation_attributes, true));
|
||||
}
|
||||
|
||||
// Clear any existing notices before adding to cart
|
||||
wc_clear_notices();
|
||||
|
||||
// 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);
|
||||
|
||||
if (!$cart_item_key) {
|
||||
// Get WooCommerce notices to provide better error message
|
||||
$notices = wc_get_notices('error');
|
||||
$error_messages = [];
|
||||
foreach ($notices as $notice) {
|
||||
$error_messages[] = is_array($notice) ? $notice['notice'] : $notice;
|
||||
}
|
||||
$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]);
|
||||
}
|
||||
|
||||
error_log("WooNooW Cart: Product added successfully - Key: {$cart_item_key}");
|
||||
|
||||
return new WP_REST_Response([
|
||||
'message' => 'Product added to cart',
|
||||
'cart_item_key' => $cart_item_key,
|
||||
@@ -270,12 +260,20 @@ class CartController {
|
||||
/**
|
||||
* 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');
|
||||
$quantity = $request->get_param('quantity');
|
||||
|
||||
// Initialize WooCommerce session and cart for REST API requests
|
||||
if (!WC()->session) {
|
||||
WC()->initialize_session();
|
||||
}
|
||||
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
|
||||
@@ -294,11 +292,25 @@ class CartController {
|
||||
/**
|
||||
* 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');
|
||||
|
||||
// Initialize WooCommerce session and cart for REST API requests
|
||||
if (!WC()->session) {
|
||||
WC()->initialize_session();
|
||||
}
|
||||
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
|
||||
@@ -314,10 +326,36 @@ class CartController {
|
||||
], 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
|
||||
if (!WC()->cart) {
|
||||
@@ -340,7 +378,8 @@ class CartController {
|
||||
/**
|
||||
* 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');
|
||||
|
||||
if (!WC()->cart) {
|
||||
@@ -363,7 +402,8 @@ class CartController {
|
||||
/**
|
||||
* Format cart data for API response
|
||||
*/
|
||||
private static function format_cart() {
|
||||
private static function format_cart()
|
||||
{
|
||||
$cart = WC()->cart;
|
||||
|
||||
if (!$cart) {
|
||||
@@ -374,6 +414,18 @@ class CartController {
|
||||
foreach ($cart->get_cart() as $cart_item_key => $cart_item) {
|
||||
$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[] = [
|
||||
'key' => $cart_item_key,
|
||||
'product_id' => $cart_item['product_id'],
|
||||
@@ -385,7 +437,7 @@ class CartController {
|
||||
'total' => $cart_item['line_total'],
|
||||
'image' => wp_get_attachment_url($product->get_image_id()),
|
||||
'permalink' => get_permalink($cart_item['product_id']),
|
||||
'attributes' => $cart_item['variation'] ?? [],
|
||||
'attributes' => $formatted_attributes,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,18 @@ namespace WooNooW\Frontend;
|
||||
* Template Override
|
||||
* Overrides WooCommerce templates to use WooNooW SPA
|
||||
*/
|
||||
class TemplateOverride {
|
||||
class TemplateOverride
|
||||
{
|
||||
|
||||
/**
|
||||
* 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
|
||||
add_filter('template_include', [__CLASS__, 'use_spa_template'], 999);
|
||||
|
||||
@@ -38,11 +44,61 @@ class TemplateOverride {
|
||||
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
|
||||
* 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', []);
|
||||
$mode = isset($settings['mode']) ? $settings['mode'] : 'disabled';
|
||||
|
||||
@@ -67,7 +123,8 @@ class TemplateOverride {
|
||||
/**
|
||||
* 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
|
||||
if (self::is_spa_page()) {
|
||||
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
|
||||
@@ -84,12 +141,14 @@ class TemplateOverride {
|
||||
if ($mode === 'disabled') {
|
||||
// Check if page has woonoow shortcodes
|
||||
global $post;
|
||||
if ($post && (
|
||||
if (
|
||||
$post && (
|
||||
has_shortcode($post->post_content, 'woonoow_shop') ||
|
||||
has_shortcode($post->post_content, 'woonoow_cart') ||
|
||||
has_shortcode($post->post_content, 'woonoow_checkout') ||
|
||||
has_shortcode($post->post_content, 'woonoow_account')
|
||||
)) {
|
||||
)
|
||||
) {
|
||||
// Use blank template for shortcode pages too
|
||||
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
|
||||
if (file_exists($spa_template)) {
|
||||
@@ -172,7 +231,8 @@ class TemplateOverride {
|
||||
/**
|
||||
* Start SPA wrapper
|
||||
*/
|
||||
public static function start_spa_wrapper() {
|
||||
public static function start_spa_wrapper()
|
||||
{
|
||||
// Check if we should use SPA
|
||||
if (!self::should_use_spa()) {
|
||||
return;
|
||||
@@ -211,7 +271,8 @@ class TemplateOverride {
|
||||
/**
|
||||
* End SPA wrapper
|
||||
*/
|
||||
public static function end_spa_wrapper() {
|
||||
public static function end_spa_wrapper()
|
||||
{
|
||||
if (!self::should_use_spa()) {
|
||||
return;
|
||||
}
|
||||
@@ -223,7 +284,8 @@ class TemplateOverride {
|
||||
/**
|
||||
* Check if we should use SPA
|
||||
*/
|
||||
private static function should_use_spa() {
|
||||
private static function should_use_spa()
|
||||
{
|
||||
// Check if frontend mode is enabled
|
||||
$mode = get_option('woonoow_frontend_mode', 'shortcodes');
|
||||
|
||||
@@ -247,7 +309,8 @@ class TemplateOverride {
|
||||
/**
|
||||
* 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()) {
|
||||
remove_all_actions('wp_head');
|
||||
// Re-add essential WordPress head actions
|
||||
@@ -262,7 +325,8 @@ class TemplateOverride {
|
||||
/**
|
||||
* 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()) {
|
||||
remove_all_actions('wp_footer');
|
||||
// Re-add essential WordPress footer actions
|
||||
@@ -273,7 +337,8 @@ class TemplateOverride {
|
||||
/**
|
||||
* Check if current page is the designated SPA page
|
||||
*/
|
||||
private static function is_spa_page() {
|
||||
private static function is_spa_page()
|
||||
{
|
||||
global $post;
|
||||
if (!$post) {
|
||||
return false;
|
||||
@@ -294,7 +359,8 @@ class TemplateOverride {
|
||||
/**
|
||||
* 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
|
||||
if (self::is_spa_page()) {
|
||||
return true;
|
||||
@@ -312,12 +378,14 @@ class TemplateOverride {
|
||||
|
||||
// Also remove for pages with shortcodes (even in disabled mode)
|
||||
global $post;
|
||||
if ($post && (
|
||||
if (
|
||||
$post && (
|
||||
has_shortcode($post->post_content, 'woonoow_shop') ||
|
||||
has_shortcode($post->post_content, 'woonoow_cart') ||
|
||||
has_shortcode($post->post_content, 'woonoow_checkout') ||
|
||||
has_shortcode($post->post_content, 'woonoow_account')
|
||||
)) {
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -338,7 +406,8 @@ class TemplateOverride {
|
||||
/**
|
||||
* 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
|
||||
if (!self::should_use_spa()) {
|
||||
return $template;
|
||||
|
||||
@@ -12,21 +12,15 @@
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$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
|
||||
if ($spa_mode === 'checkout_only') {
|
||||
// Checkout Only mode starts at cart
|
||||
$page_type = 'cart';
|
||||
$data_attrs = 'data-page="cart" data-initial-route="/cart"';
|
||||
error_log('[WooNooW SPA Template] Using CART initial route');
|
||||
} else {
|
||||
// Full SPA mode starts at shop
|
||||
$page_type = 'shop';
|
||||
$data_attrs = 'data-page="shop" data-initial-route="/shop"';
|
||||
error_log('[WooNooW SPA Template] Using SHOP initial route');
|
||||
}
|
||||
?>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user