From 9c31b4ce6c859d2135f7ee45abf5225a90db59bd Mon Sep 17 00:00:00 2001 From: dwindown Date: Tue, 11 Nov 2025 00:49:07 +0700 Subject: [PATCH] feat: Mobile chart optimization + VIP customer settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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);
{children}
``` **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!** 🎉 --- admin-spa/src/App.tsx | 3 +- admin-spa/src/nav/tree.ts | 1 + admin-spa/src/routes/Dashboard/Coupons.tsx | 1 + admin-spa/src/routes/Dashboard/Orders.tsx | 1 + admin-spa/src/routes/Dashboard/Revenue.tsx | 7 +- .../routes/Dashboard/components/ChartCard.tsx | 28 +- admin-spa/src/routes/Settings/Customers.tsx | 240 ++++++++++++++++++ includes/Api/StoreController.php | 88 +++++++ includes/Compat/CustomerSettingsProvider.php | 187 ++++++++++++++ 9 files changed, 550 insertions(+), 6 deletions(-) create mode 100644 admin-spa/src/routes/Settings/Customers.tsx create mode 100644 includes/Compat/CustomerSettingsProvider.php diff --git a/admin-spa/src/App.tsx b/admin-spa/src/App.tsx index 5fc672b..e30a64a 100644 --- a/admin-spa/src/App.tsx +++ b/admin-spa/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> - } /> } /> } /> } /> diff --git a/admin-spa/src/nav/tree.ts b/admin-spa/src/nav/tree.ts index 568eb76..804bfaa 100644 --- a/admin-spa/src/nav/tree.ts +++ b/admin-spa/src/nav/tree.ts @@ -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' }, ], diff --git a/admin-spa/src/routes/Dashboard/Coupons.tsx b/admin-spa/src/routes/Dashboard/Coupons.tsx index 6fbcef5..bf9d61a 100644 --- a/admin-spa/src/routes/Dashboard/Coupons.tsx +++ b/admin-spa/src/routes/Dashboard/Coupons.tsx @@ -222,6 +222,7 @@ export default function CouponsReport() { {chartData.length === 0 || chartData.every((d: any) => d.uses === 0) ? (
diff --git a/admin-spa/src/routes/Dashboard/Orders.tsx b/admin-spa/src/routes/Dashboard/Orders.tsx index d2ba0e7..a5f4435 100644 --- a/admin-spa/src/routes/Dashboard/Orders.tsx +++ b/admin-spa/src/routes/Dashboard/Orders.tsx @@ -205,6 +205,7 @@ export default function OrdersAnalytics() { {chartData.length === 0 || chartData.every((d: any) => d.orders === 0) ? (
diff --git a/admin-spa/src/routes/Dashboard/Revenue.tsx b/admin-spa/src/routes/Dashboard/Revenue.tsx index c942932..37ef5d1 100644 --- a/admin-spa/src/routes/Dashboard/Revenue.tsx +++ b/admin-spa/src/routes/Dashboard/Revenue.tsx @@ -382,10 +382,11 @@ export default function RevenueAnalytics() { {/* Revenue Chart */} setGranularity(v)}> - + setSettings({ ...settings, vip_min_spent: parseFloat(e.target.value) || 0 })} + /> +

+ {__('Minimum total amount a customer must spend')} +

+
+ +
+ + setSettings({ ...settings, vip_min_orders: parseInt(e.target.value) || 0 })} + /> +

+ {__('Minimum number of orders a customer must place')} +

+
+ +
+ + +

+ {__('Time period to calculate totals')} +

+
+ + setSettings({ ...settings, vip_require_both: checked })} + /> +

+ {settings.vip_require_both + ? __('Customer must meet BOTH criteria') + : __('Customer must meet EITHER criterion')} +

+ + setSettings({ ...settings, vip_exclude_refunded: checked })} + /> +

+ {__('Do not count refunded orders')} +

+ +
+

+ + {__('Preview')} +

+
+

+ {settings.vip_require_both ? __('Customer needs') : __('Customer needs')}{' '} + + {formatMoney(settings.vip_min_spent, { + currency: store.currency, + symbol: store.symbol, + thousandSep: store.thousand_sep, + decimalSep: store.decimal_sep, + decimals: 0, + preferSymbol: true, + })} + {' '} + {settings.vip_require_both ? __('AND') : __('OR')}{' '} + + {settings.vip_min_orders} {__('orders')} + +

+
+
+
+ + +
+ + +
+ + ); +} diff --git a/includes/Api/StoreController.php b/includes/Api/StoreController.php index aee9be3..6e2b1d1 100644 --- a/includes/Api/StoreController.php +++ b/includes/Api/StoreController.php @@ -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 * diff --git a/includes/Compat/CustomerSettingsProvider.php b/includes/Compat/CustomerSettingsProvider.php new file mode 100644 index 0000000..1940f57 --- /dev/null +++ b/includes/Compat/CustomerSettingsProvider.php @@ -0,0 +1,187 @@ + 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'], + ]; + } +}