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:
dwindown
2025-11-11 00:49:07 +07:00
parent 8fd3691975
commit 9c31b4ce6c
9 changed files with 550 additions and 6 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -10,6 +10,7 @@
namespace WooNooW\API;
use WooNooW\Compat\StoreSettingsProvider;
use WooNooW\Compat\CustomerSettingsProvider;
use WP_REST_Controller;
use WP_REST_Server;
use WP_REST_Request;
@@ -85,6 +86,24 @@ class StoreController extends WP_REST_Controller {
'permission_callback' => [$this, 'check_permission'],
],
]);
// GET /woonoow/v1/store/customer-settings
register_rest_route($this->namespace, '/' . $this->rest_base . '/customer-settings', [
[
'methods' => WP_REST_Server::READABLE,
'callback' => [$this, 'get_customer_settings'],
'permission_callback' => [$this, 'check_permission'],
],
]);
// POST /woonoow/v1/store/customer-settings
register_rest_route($this->namespace, '/' . $this->rest_base . '/customer-settings', [
[
'methods' => WP_REST_Server::EDITABLE,
'callback' => [$this, 'save_customer_settings'],
'permission_callback' => [$this, 'check_permission'],
],
]);
}
/**
@@ -241,6 +260,75 @@ class StoreController extends WP_REST_Controller {
}
}
/**
* Get customer settings
*
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error Response object or error
*/
public function get_customer_settings(WP_REST_Request $request) {
try {
$settings = CustomerSettingsProvider::get_settings();
$response = rest_ensure_response($settings);
$response->header('Cache-Control', 'max-age=60');
return $response;
} catch (\Exception $e) {
return new WP_Error(
'get_customer_settings_failed',
$e->getMessage(),
['status' => 500]
);
}
}
/**
* Save customer settings
*
* @param WP_REST_Request $request Request object
* @return WP_REST_Response|WP_Error Response object or error
*/
public function save_customer_settings(WP_REST_Request $request) {
try {
$settings = $request->get_json_params();
if (empty($settings)) {
return new WP_Error(
'invalid_settings',
__('Invalid settings data', 'woonoow'),
['status' => 400]
);
}
$updated = CustomerSettingsProvider::update_settings($settings);
if (!$updated) {
return new WP_Error(
'update_failed',
__('Failed to update customer settings', 'woonoow'),
['status' => 500]
);
}
// Return updated settings
$new_settings = CustomerSettingsProvider::get_settings();
return new WP_REST_Response([
'success' => true,
'message' => __('Customer settings updated successfully', 'woonoow'),
'settings' => $new_settings,
], 200);
} catch (\Exception $e) {
return new WP_Error(
'save_customer_settings_failed',
$e->getMessage(),
['status' => 500]
);
}
}
/**
* Check permission
*

View File

@@ -0,0 +1,187 @@
<?php
/**
* Customer Settings Provider
*
* Provides customer-related settings including VIP qualification criteria.
*
* @package WooNooW
*/
namespace WooNooW\Compat;
class CustomerSettingsProvider {
/**
* Get customer settings
*
* @return array
*/
public static function get_settings() {
return [
// VIP Customer Qualification
'vip_min_spent' => floatval(get_option('woonoow_vip_min_spent', 1000)),
'vip_min_orders' => intval(get_option('woonoow_vip_min_orders', 10)),
'vip_timeframe' => get_option('woonoow_vip_timeframe', 'all'), // all, 30, 90, 365
'vip_require_both' => get_option('woonoow_vip_require_both', 'yes') === 'yes',
'vip_exclude_refunded' => get_option('woonoow_vip_exclude_refunded', 'yes') === 'yes',
];
}
/**
* Update customer settings
*
* @param array $settings
* @return bool
*/
public static function update_settings($settings) {
$updated = true;
// VIP settings
if (isset($settings['vip_min_spent'])) {
$updated = $updated && update_option('woonoow_vip_min_spent', floatval($settings['vip_min_spent']));
}
if (isset($settings['vip_min_orders'])) {
$updated = $updated && update_option('woonoow_vip_min_orders', intval($settings['vip_min_orders']));
}
if (isset($settings['vip_timeframe'])) {
$timeframe = in_array($settings['vip_timeframe'], ['all', '30', '90', '365'])
? $settings['vip_timeframe']
: 'all';
$updated = $updated && update_option('woonoow_vip_timeframe', $timeframe);
}
if (isset($settings['vip_require_both'])) {
$updated = $updated && update_option('woonoow_vip_require_both', $settings['vip_require_both'] ? 'yes' : 'no');
}
if (isset($settings['vip_exclude_refunded'])) {
$updated = $updated && update_option('woonoow_vip_exclude_refunded', $settings['vip_exclude_refunded'] ? 'yes' : 'no');
}
return $updated;
}
/**
* Check if a customer is VIP based on current settings
*
* @param int $customer_id
* @return bool
*/
public static function is_vip_customer($customer_id) {
if (!$customer_id || $customer_id <= 0) {
return false;
}
$settings = self::get_settings();
// Build query args
$query_args = [
'customer_id' => $customer_id,
'status' => ['wc-completed', 'wc-processing'],
'limit' => -1, // Get all orders
];
// Apply timeframe filter
if ($settings['vip_timeframe'] !== 'all') {
$days = intval($settings['vip_timeframe']);
$query_args['date_created'] = '>' . date('Y-m-d', strtotime("-{$days} days"));
}
// Exclude refunded orders if setting is enabled
if ($settings['vip_exclude_refunded']) {
$query_args['status'] = array_diff($query_args['status'], ['wc-refunded']);
}
// Get orders
$orders = wc_get_orders($query_args);
// Calculate totals
$total_spent = 0;
$order_count = count($orders);
foreach ($orders as $order) {
$total_spent += floatval($order->get_total());
}
// Check qualification
$meets_spent = $total_spent >= $settings['vip_min_spent'];
$meets_orders = $order_count >= $settings['vip_min_orders'];
if ($settings['vip_require_both']) {
// Must meet both criteria
return $meets_spent && $meets_orders;
} else {
// Must meet at least one criterion
return $meets_spent || $meets_orders;
}
}
/**
* Get VIP customer stats for a customer
*
* @param int $customer_id
* @return array
*/
public static function get_vip_stats($customer_id) {
if (!$customer_id || $customer_id <= 0) {
return [
'is_vip' => false,
'total_spent' => 0,
'order_count' => 0,
'meets_spent' => false,
'meets_orders' => false,
];
}
$settings = self::get_settings();
// Build query args
$query_args = [
'customer_id' => $customer_id,
'status' => ['wc-completed', 'wc-processing'],
'limit' => -1,
];
// Apply timeframe filter
if ($settings['vip_timeframe'] !== 'all') {
$days = intval($settings['vip_timeframe']);
$query_args['date_created'] = '>' . date('Y-m-d', strtotime("-{$days} days"));
}
// Exclude refunded if enabled
if ($settings['vip_exclude_refunded']) {
$query_args['status'] = array_diff($query_args['status'], ['wc-refunded']);
}
// Get orders
$orders = wc_get_orders($query_args);
// Calculate totals
$total_spent = 0;
$order_count = count($orders);
foreach ($orders as $order) {
$total_spent += floatval($order->get_total());
}
// Check criteria
$meets_spent = $total_spent >= $settings['vip_min_spent'];
$meets_orders = $order_count >= $settings['vip_min_orders'];
$is_vip = $settings['vip_require_both']
? ($meets_spent && $meets_orders)
: ($meets_spent || $meets_orders);
return [
'is_vip' => $is_vip,
'total_spent' => $total_spent,
'order_count' => $order_count,
'meets_spent' => $meets_spent,
'meets_orders' => $meets_orders,
'required_spent' => $settings['vip_min_spent'],
'required_orders' => $settings['vip_min_orders'],
];
}
}