## ✅ Issue 1: Cookie Authentication in Standalone Mode **Problem:** - `rest_cookie_invalid_nonce` errors on customer-settings - `Cookie check failed` errors on media uploads - Both endpoints returning 403 in standalone mode **Root Cause:** WordPress REST API requires `credentials: "include"` for cookie-based authentication in cross-origin contexts (standalone mode uses different URL). **Fixed:** 1. **Customer Settings (Customers.tsx)** - Added `credentials: "include"` to both GET and POST requests - Use `WNW_CONFIG.nonce` as primary nonce source - Fallback to `wpApiSettings.nonce` 2. **Media Upload (image-upload.tsx)** - Added `credentials: "include"` to media upload - Prioritize `WNW_CONFIG.nonce` for standalone mode - Changed from `same-origin` to `include` for cross-origin support **Result:** - ✅ Customer settings load and save in standalone mode - ✅ Image/logo uploads work in standalone mode - ✅ SVG uploads work with proper authentication ## ✅ Issue 2: Dynamic VIP Customer Calculation **Problem:** VIP calculation was hardcoded (TODO comment) **Requirement:** Use dynamic settings from Customer Settings page **Fixed (AnalyticsController.php):** 1. **Individual Customer VIP Status** - Call `CustomerSettingsProvider::is_vip_customer()` for each customer - Add `is_vip` field to customer data - Set `segment` to "vip" for VIP customers - Count VIP customers dynamically 2. **Segments Overview** - Replace hardcoded `vip: 0` with actual `$vip_count` - VIP count updates automatically based on settings **How It Works:** - CustomerSettingsProvider reads settings from database - Checks: min_spent, min_orders, timeframe, require_both, exclude_refunded - Calculates VIP status in real-time based on current criteria - Updates immediately when settings change **Result:** - ✅ VIP badge shows correctly on customer list - ✅ VIP count in segments reflects actual qualified customers - ✅ Changes to VIP criteria instantly affect dashboard - ✅ No cache issues - recalculates on each request --- ## Files Modified: - `Customers.tsx` - Add credentials for cookie auth - `image-upload.tsx` - Add credentials for media upload - `AnalyticsController.php` - Dynamic VIP calculation ## Testing: 1. ✅ Customer settings save in standalone mode 2. ✅ Logo upload works in standalone mode 3. ✅ VIP customers show correct badge 4. ✅ Change VIP criteria → dashboard updates 5. ✅ Segments show correct VIP count
243 lines
8.7 KiB
TypeScript
243 lines
8.7 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { __ } from '@/lib/i18n';
|
|
import { Crown, Info, Save } from 'lucide-react';
|
|
import { SettingsCard } from './components/SettingsCard';
|
|
import { ToggleField } from './components/ToggleField';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { formatMoney, getStoreCurrency } from '@/lib/currency';
|
|
|
|
interface CustomerSettings {
|
|
vip_min_spent: number;
|
|
vip_min_orders: number;
|
|
vip_timeframe: 'all' | '30' | '90' | '365';
|
|
vip_require_both: boolean;
|
|
vip_exclude_refunded: boolean;
|
|
}
|
|
|
|
export default function CustomersSettings() {
|
|
const [settings, setSettings] = useState<CustomerSettings>({
|
|
vip_min_spent: 1000,
|
|
vip_min_orders: 10,
|
|
vip_timeframe: 'all',
|
|
vip_require_both: true,
|
|
vip_exclude_refunded: true,
|
|
});
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [message, setMessage] = useState('');
|
|
const store = getStoreCurrency();
|
|
|
|
useEffect(() => {
|
|
fetchSettings();
|
|
}, []);
|
|
|
|
const fetchSettings = async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
const response = await fetch(
|
|
`${(window as any).WNW_CONFIG?.restUrl || ''}/store/customer-settings`,
|
|
{
|
|
credentials: 'include',
|
|
headers: {
|
|
'X-WP-Nonce': (window as any).WNW_CONFIG?.nonce || (window as any).wpApiSettings?.nonce || '',
|
|
},
|
|
}
|
|
);
|
|
|
|
if (!response.ok) throw new Error('Failed to fetch');
|
|
const data = await response.json();
|
|
setSettings(data);
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
setMessage('Failed to load settings');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
try {
|
|
setIsSaving(true);
|
|
setMessage('');
|
|
const response = await fetch(
|
|
`${(window as any).WNW_CONFIG?.restUrl || ''}/store/customer-settings`,
|
|
{
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-WP-Nonce': (window as any).WNW_CONFIG?.nonce || (window as any).wpApiSettings?.nonce || '',
|
|
},
|
|
body: JSON.stringify(settings),
|
|
}
|
|
);
|
|
|
|
if (!response.ok) throw new Error('Failed to save');
|
|
const data = await response.json();
|
|
setMessage(data.message || 'Settings saved successfully');
|
|
if (data.settings) setSettings(data.settings);
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
setMessage('Failed to save settings');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold">{__('Customer Settings')}</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
{__('Configure VIP customer qualification')}
|
|
</p>
|
|
</div>
|
|
<div className="animate-pulse h-64 bg-muted rounded-lg"></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold">{__('Customer Settings')}</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
{__('Configure VIP customer qualification criteria')}
|
|
</p>
|
|
</div>
|
|
|
|
{message && (
|
|
<div className={`p-4 rounded-lg ${message.includes('success') ? 'bg-green-50 text-green-900' : 'bg-red-50 text-red-900'}`}>
|
|
{message}
|
|
</div>
|
|
)}
|
|
|
|
<SettingsCard
|
|
title={__('VIP Customer Qualification')}
|
|
description={__('Define criteria for identifying VIP customers')}
|
|
>
|
|
<div className="space-y-6">
|
|
<div className="flex gap-3 p-4 bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900 rounded-lg">
|
|
<Info className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
|
<div className="text-sm text-blue-900 dark:text-blue-100">
|
|
<p className="font-medium mb-1">{__('What are VIP customers?')}</p>
|
|
<p className="text-blue-700 dark:text-blue-300">
|
|
{__('VIP customers are high-value customers who meet your qualification criteria.')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="vip_min_spent">{__('Minimum Total Spent')}</Label>
|
|
<Input
|
|
id="vip_min_spent"
|
|
type="number"
|
|
value={settings.vip_min_spent}
|
|
onChange={(e) => setSettings({ ...settings, vip_min_spent: parseFloat(e.target.value) || 0 })}
|
|
/>
|
|
<p className="text-sm text-muted-foreground">
|
|
{__('Minimum total amount a customer must spend')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="vip_min_orders">{__('Minimum Order Count')}</Label>
|
|
<Input
|
|
id="vip_min_orders"
|
|
type="number"
|
|
value={settings.vip_min_orders}
|
|
onChange={(e) => setSettings({ ...settings, vip_min_orders: parseInt(e.target.value) || 0 })}
|
|
/>
|
|
<p className="text-sm text-muted-foreground">
|
|
{__('Minimum number of orders a customer must place')}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="vip_timeframe">{__('Timeframe')}</Label>
|
|
<Select
|
|
value={settings.vip_timeframe}
|
|
onValueChange={(value: string) => setSettings({ ...settings, vip_timeframe: value as CustomerSettings['vip_timeframe'] })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">{__('All time')}</SelectItem>
|
|
<SelectItem value="30">{__('Last 30 days')}</SelectItem>
|
|
<SelectItem value="90">{__('Last 90 days')}</SelectItem>
|
|
<SelectItem value="365">{__('Last year')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-sm text-muted-foreground">
|
|
{__('Time period to calculate totals')}
|
|
</p>
|
|
</div>
|
|
|
|
<ToggleField
|
|
id="vip_require_both"
|
|
label={__('Require both criteria')}
|
|
checked={settings.vip_require_both}
|
|
onCheckedChange={(checked) => setSettings({ ...settings, vip_require_both: checked })}
|
|
/>
|
|
<p className="text-sm text-muted-foreground -mt-4">
|
|
{settings.vip_require_both
|
|
? __('Customer must meet BOTH criteria')
|
|
: __('Customer must meet EITHER criterion')}
|
|
</p>
|
|
|
|
<ToggleField
|
|
id="vip_exclude_refunded"
|
|
label={__('Exclude refunded orders')}
|
|
checked={settings.vip_exclude_refunded}
|
|
onCheckedChange={(checked) => setSettings({ ...settings, vip_exclude_refunded: checked })}
|
|
/>
|
|
<p className="text-sm text-muted-foreground -mt-4">
|
|
{__('Do not count refunded orders')}
|
|
</p>
|
|
|
|
<div className="pt-4 border-t">
|
|
<h3 className="font-medium mb-3 flex items-center gap-2">
|
|
<Crown className="w-4 h-4 text-yellow-500" />
|
|
{__('Preview')}
|
|
</h3>
|
|
<div className="text-sm text-muted-foreground">
|
|
<p>
|
|
{settings.vip_require_both ? __('Customer needs') : __('Customer needs')}{' '}
|
|
<span className="font-semibold text-foreground">
|
|
{formatMoney(settings.vip_min_spent, {
|
|
currency: store.currency,
|
|
symbol: store.symbol,
|
|
thousandSep: store.thousand_sep,
|
|
decimalSep: store.decimal_sep,
|
|
decimals: 0,
|
|
preferSymbol: true,
|
|
})}
|
|
</span>{' '}
|
|
{settings.vip_require_both ? __('AND') : __('OR')}{' '}
|
|
<span className="font-semibold text-foreground">
|
|
{settings.vip_min_orders} {__('orders')}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SettingsCard>
|
|
|
|
<div className="flex justify-end gap-3">
|
|
<Button variant="outline" onClick={fetchSettings} disabled={isSaving}>
|
|
{__('Reset')}
|
|
</Button>
|
|
<Button onClick={handleSave} disabled={isSaving}>
|
|
<Save className="w-4 h-4 mr-2" />
|
|
{isSaving ? __('Saving...') : __('Save Changes')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|