feat: Mobile chart optimization + VIP customer settings
## Task 4: Mobile Chart Optimization ✅ **Problem:** Too many data points = tight/crowded lines on mobile **Solution:** Horizontal scroll container **Implementation:** - ChartCard component enhanced with mobile scroll - Calculates minimum width based on data points (40px per point) - Desktop: Full width responsive - Mobile: Fixed width chart in scrollable container ```tsx // ChartCard.tsx const mobileMinWidth = Math.max(600, dataPoints * 40); <div className="overflow-x-auto -mx-6 px-6 md:mx-0 md:px-0"> <div style={{ minWidth: `${mobileMinWidth}px` }}> {children} </div> </div> ``` **Benefits:** - ✅ All data visible (no loss) - ✅ Natural swipe gesture - ✅ Readable spacing - ✅ Works for all chart types - ✅ No data aggregation needed --- ## Task 5: VIP Customer Settings ✅ **New Feature:** Configure VIP customer qualification criteria ### Backend (PHP) **Files Created:** - `includes/Compat/CustomerSettingsProvider.php` - VIP settings management - VIP detection logic - Customer stats calculation **API Endpoints:** - `GET /store/customer-settings` - Fetch settings - `POST /store/customer-settings` - Save settings **Settings:** ```php woonoow_vip_min_spent = 1000 woonoow_vip_min_orders = 10 woonoow_vip_timeframe = 'all' | '30' | '90' | '365' woonoow_vip_require_both = true woonoow_vip_exclude_refunded = true ``` **VIP Detection:** ```php CustomerSettingsProvider::is_vip_customer($customer_id) CustomerSettingsProvider::get_vip_stats($customer_id) ``` ### Frontend (React) **Files Created:** - `admin-spa/src/routes/Settings/Customers.tsx` **Features:** - 💰 Minimum total spent (currency input) - �� Minimum order count (number input) - 📅 Timeframe selector (all-time, 30/90/365 days) - ⚙️ Require both criteria toggle - 🚫 Exclude refunded orders toggle - 👑 Live preview of VIP qualification **Navigation:** - Added to Settings menu - Route: `/settings/customers` - Position: After Tax, before Notifications --- ## Summary ✅ **Mobile Charts:** Horizontal scroll for readable spacing ✅ **VIP Settings:** Complete backend + frontend implementation **Mobile Chart Strategy:** - Minimum 600px width - 40px per data point - Smooth horizontal scroll - Desktop remains responsive **VIP Customer System:** - Flexible qualification criteria - Multiple timeframes - AND/OR logic support - Refunded order exclusion - Ready for customer list integration **All tasks complete!** 🎉
This commit is contained in:
@@ -198,6 +198,7 @@ import SettingsStore from '@/routes/Settings/Store';
|
||||
import SettingsPayments from '@/routes/Settings/Payments';
|
||||
import SettingsShipping from '@/routes/Settings/Shipping';
|
||||
import SettingsTax from '@/routes/Settings/Tax';
|
||||
import SettingsCustomers from '@/routes/Settings/Customers';
|
||||
import SettingsLocalPickup from '@/routes/Settings/LocalPickup';
|
||||
import SettingsNotifications from '@/routes/Settings/Notifications';
|
||||
import SettingsDeveloper from '@/routes/Settings/Developer';
|
||||
@@ -433,10 +434,10 @@ function AppRoutes() {
|
||||
<Route path="/settings/payments" element={<SettingsPayments />} />
|
||||
<Route path="/settings/shipping" element={<SettingsShipping />} />
|
||||
<Route path="/settings/tax" element={<SettingsTax />} />
|
||||
<Route path="/settings/customers" element={<SettingsCustomers />} />
|
||||
<Route path="/settings/taxes" element={<Navigate to="/settings/tax" replace />} />
|
||||
<Route path="/settings/local-pickup" element={<SettingsLocalPickup />} />
|
||||
<Route path="/settings/checkout" element={<SettingsIndex />} />
|
||||
<Route path="/settings/customers" element={<SettingsIndex />} />
|
||||
<Route path="/settings/notifications" element={<SettingsNotifications />} />
|
||||
<Route path="/settings/brand" element={<SettingsIndex />} />
|
||||
<Route path="/settings/developer" element={<SettingsDeveloper />} />
|
||||
|
||||
@@ -109,6 +109,7 @@ function getStaticFallbackTree(): MainNode[] {
|
||||
{ label: 'Payments', mode: 'spa' as const, path: '/settings/payments' },
|
||||
{ label: 'Shipping & Delivery', mode: 'spa' as const, path: '/settings/shipping' },
|
||||
{ label: 'Tax', mode: 'spa' as const, path: '/settings/tax' },
|
||||
{ label: 'Customers', mode: 'spa' as const, path: '/settings/customers' },
|
||||
{ label: 'Notifications', mode: 'spa' as const, path: '/settings/notifications' },
|
||||
{ label: 'Developer', mode: 'spa' as const, path: '/settings/developer' },
|
||||
],
|
||||
|
||||
@@ -222,6 +222,7 @@ export default function CouponsReport() {
|
||||
<ChartCard
|
||||
title={__('Coupon Usage Over Time')}
|
||||
description={__('Daily coupon usage and discount amount')}
|
||||
dataPoints={chartData.length}
|
||||
>
|
||||
{chartData.length === 0 || chartData.every((d: any) => d.uses === 0) ? (
|
||||
<div className="flex items-center justify-center h-[300px]">
|
||||
|
||||
@@ -205,6 +205,7 @@ export default function OrdersAnalytics() {
|
||||
<ChartCard
|
||||
title={__('Orders Over Time')}
|
||||
description={__('Daily order count and status breakdown')}
|
||||
dataPoints={chartData.length}
|
||||
>
|
||||
{chartData.length === 0 || chartData.every((d: any) => d.orders === 0) ? (
|
||||
<div className="flex items-center justify-center h-[300px]">
|
||||
|
||||
@@ -382,10 +382,11 @@ export default function RevenueAnalytics() {
|
||||
{/* Revenue Chart */}
|
||||
<ChartCard
|
||||
title={__('Revenue Over Time')}
|
||||
description={__('Gross revenue, net revenue, and refunds')}
|
||||
description={__('Gross and net revenue trends')}
|
||||
dataPoints={chartData.length}
|
||||
actions={
|
||||
<Select value={granularity} onValueChange={(v: any) => setGranularity(v)}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<Select value={granularity} onValueChange={(value: any) => setGranularity(value)}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -8,6 +8,8 @@ interface ChartCardProps {
|
||||
actions?: ReactNode;
|
||||
loading?: boolean;
|
||||
height?: number;
|
||||
enableMobileScroll?: boolean; // Enable horizontal scroll on mobile
|
||||
dataPoints?: number; // Number of data points (for calculating mobile width)
|
||||
}
|
||||
|
||||
export function ChartCard({
|
||||
@@ -16,8 +18,14 @@ export function ChartCard({
|
||||
children,
|
||||
actions,
|
||||
loading = false,
|
||||
height = 300
|
||||
height = 300,
|
||||
enableMobileScroll = true,
|
||||
dataPoints = 30
|
||||
}: ChartCardProps) {
|
||||
// Calculate minimum width for mobile based on data points
|
||||
// Each data point needs ~40px for readability
|
||||
const mobileMinWidth = Math.max(600, dataPoints * 40);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-6">
|
||||
@@ -46,7 +54,23 @@ export function ChartCard({
|
||||
</div>
|
||||
{actions && <div className="flex gap-2">{actions}</div>}
|
||||
</div>
|
||||
{children}
|
||||
|
||||
{/* Chart container with mobile scroll */}
|
||||
{enableMobileScroll ? (
|
||||
<div className="overflow-x-auto -mx-6 px-6 md:mx-0 md:px-0">
|
||||
<div
|
||||
className="md:w-full"
|
||||
style={{
|
||||
minWidth: `${mobileMinWidth}px`,
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
240
admin-spa/src/routes/Settings/Customers.tsx
Normal file
240
admin-spa/src/routes/Settings/Customers.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
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`,
|
||||
{
|
||||
headers: {
|
||||
'X-WP-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',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user