diff --git a/admin/admin.php b/admin/admin.php index 63fd14e..302cf5b 100644 --- a/admin/admin.php +++ b/admin/admin.php @@ -146,6 +146,15 @@ add_action('admin_menu', function() use ($adminController) { [$adminController, 'addRulePage'] ); + add_submenu_page( + 'sodino-dashboard', + __('قالب‌های آماده', 'sodino'), + __('قالب‌های آماده', 'sodino'), + 'manage_options', + 'sodino-templates', + [$adminController, 'templatesPage'] + ); + add_submenu_page( 'sodino-dashboard', __('آپسل (پیشنهاد فروش)', 'sodino'), @@ -247,8 +256,8 @@ add_action('admin_enqueue_scripts', function($hook) { * Handle admin actions */ add_action('admin_init', function() use ($ruleController, $settingsController, $adminController) { - $page = $_GET['page'] ?? ''; - $action = $_GET['action'] ?? ''; + $page = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : ''; + $action = isset($_GET['action']) ? sanitize_key(wp_unslash($_GET['action'])) : ''; // Rule actions if ($page === 'sodino-rules' && $action === 'delete') { diff --git a/admin/class-banner-list-table.php b/admin/class-banner-list-table.php index 64d7e9c..b099483 100644 --- a/admin/class-banner-list-table.php +++ b/admin/class-banner-list-table.php @@ -131,6 +131,10 @@ class Sodino_Banner_List_Table extends WP_List_Table { public function process_bulk_action() { if ('delete' === $this->current_action()) { + if (!current_user_can('manage_options')) { + return; + } + $banner_ids = isset($_POST['banner_ids']) ? array_map('intval', $_POST['banner_ids']) : []; if (!empty($banner_ids) && check_admin_referer('bulk-' . $this->_args['plural'])) { foreach ($banner_ids as $id) { diff --git a/admin/class-rules-list-table.php b/admin/class-rules-list-table.php index ff40c4e..b69078e 100644 --- a/admin/class-rules-list-table.php +++ b/admin/class-rules-list-table.php @@ -85,6 +85,9 @@ class Sodino_Rules_List_Table extends WP_List_Table { 'cart_contains_category' => __('سبد شامل دسته‌بندی', 'sodino'), 'customer_order_count_min' => __('حداقل سفارش مشتری', 'sodino'), 'customer_order_count_max' => __('حداکثر سفارش مشتری', 'sodino'), + 'customer_days_since_last_order_min' => __('حداقل روز از آخرین سفارش', 'sodino'), + 'product_total_sales_max' => __('حداکثر فروش کل محصول', 'sodino'), + 'product_total_sales_min' => __('حداقل فروش کل محصول', 'sodino'), 'day_of_week' => __('روز هفته', 'sodino'), ]; } @@ -171,6 +174,10 @@ class Sodino_Rules_List_Table extends WP_List_Table { public function process_bulk_action() { if ('delete' === $this->current_action()) { + if (!current_user_can('manage_options')) { + return; + } + $rule_ids = isset($_POST['rule_ids']) ? array_map('intval', $_POST['rule_ids']) : []; if (!empty($rule_ids) && check_admin_referer('bulk-' . $this->_args['plural'])) { foreach ($rule_ids as $id) { diff --git a/admin/class-upsell-list-table.php b/admin/class-upsell-list-table.php index 0a76a1c..6fd2cd5 100644 --- a/admin/class-upsell-list-table.php +++ b/admin/class-upsell-list-table.php @@ -144,6 +144,10 @@ class Sodino_Upsell_List_Table extends WP_List_Table { public function process_bulk_action() { if ('delete' === $this->current_action()) { + if (!current_user_can('manage_options')) { + return; + } + $upsell_ids = isset($_POST['upsell_ids']) ? array_map('intval', $_POST['upsell_ids']) : []; if (!empty($upsell_ids) && check_admin_referer('bulk-' . $this->_args['plural'])) { foreach ($upsell_ids as $id) { diff --git a/admin/components/sidebar.php b/admin/components/sidebar.php index 19d9584..c93936b 100644 --- a/admin/components/sidebar.php +++ b/admin/components/sidebar.php @@ -9,6 +9,7 @@ $menu_items = [ 'sodino-dashboard' => __('داشبورد', 'sodino'), 'sodino-rules' => __('قوانین', 'sodino'), 'sodino-add-rule' => __('افزودن قانون', 'sodino'), + 'sodino-templates' => __('قالب‌های آماده', 'sodino'), 'sodino-upsells' => __('آپسل (پیشنهاد فروش)', 'sodino'), 'sodino-add-upsell' => __('افزودن آپسل', 'sodino'), 'sodino-banners' => __('بنرهای هوشمند', 'sodino'), diff --git a/admin/views/partials/rule-condition-row.php b/admin/views/partials/rule-condition-row.php index 92193ee..ea789fc 100644 --- a/admin/views/partials/rule-condition-row.php +++ b/admin/views/partials/rule-condition-row.php @@ -26,6 +26,9 @@ $condition_value = $condition['value'] ?? ''; + + + diff --git a/admin/views/rule-form.php b/admin/views/rule-form.php index e1671ed..7615fe6 100644 --- a/admin/views/rule-form.php +++ b/admin/views/rule-form.php @@ -49,6 +49,13 @@ $weekdays = [ + +
+ +

+
+ +
diff --git a/admin/views/rules-list.php b/admin/views/rules-list.php index d1848a0..9876b22 100644 --- a/admin/views/rules-list.php +++ b/admin/views/rules-list.php @@ -81,6 +81,7 @@ $current_page = sanitize_text_field($_GET['page'] ?? 'sodino-rules');
+ display(); ?>
diff --git a/admin/views/templates.php b/admin/views/templates.php new file mode 100644 index 0000000..84449fe --- /dev/null +++ b/admin/views/templates.php @@ -0,0 +1,80 @@ + +
+
+
+

+

+
+
+
+ +
+
+

+

+
+ +
+ $template) : ?> +
+
+
+

+

+
+ + + + + +
+
+ + + +
+
+ +
+
+ +
+
+

+

+
+ +
+ $template) : ?> +
+
+
+

+

+
+ + + +
+
+ + + +
+
+ +
+
+ diff --git a/admin/views/upsell-form.php b/admin/views/upsell-form.php index 45b41e8..cbf5854 100644 --- a/admin/views/upsell-form.php +++ b/admin/views/upsell-form.php @@ -92,6 +92,13 @@ $product_categories = get_terms([ + +
+ +

+
+ +
diff --git a/admin/views/upsell-list.php b/admin/views/upsell-list.php index 9ec68f8..5db40bb 100644 --- a/admin/views/upsell-list.php +++ b/admin/views/upsell-list.php @@ -76,6 +76,7 @@ $current_page = sanitize_text_field($_GET['page'] ?? 'sodino-upsells');
+ display(); ?>
diff --git a/app/Controllers/AdminController.php b/app/Controllers/AdminController.php index 8e07341..bfe511f 100644 --- a/app/Controllers/AdminController.php +++ b/app/Controllers/AdminController.php @@ -37,6 +37,9 @@ class AdminController { 'cart_contains_category', 'customer_order_count_min', 'customer_order_count_max', + 'customer_days_since_last_order_min', + 'product_total_sales_max', + 'product_total_sales_min', 'day_of_week', ]; private $allowedRuleConditionOperators = ['is', 'is_not', 'in', 'not_in']; @@ -81,6 +84,15 @@ class AdminController { [$this, 'addRulePage'] ); + add_submenu_page( + 'sodino-rules', + __('قالب‌های آماده', 'sodino'), + __('قالب‌های آماده', 'sodino'), + 'manage_options', + 'sodino-templates', + [$this, 'templatesPage'] + ); + add_submenu_page( 'sodino-rules', __('آپسل (پیشنهاد فروش)', 'sodino'), @@ -199,6 +211,12 @@ class AdminController { $this->listRulesPage(); } + public function templatesPage() { + $ruleTemplates = $this->getRuleTemplates(); + $upsellTemplates = $this->getUpsellTemplates(); + include SODINO_PLUGIN_DIR . 'admin/views/templates.php'; + } + /** * Dashboard page */ @@ -249,6 +267,8 @@ class AdminController { $this->saveRule(); } else { $rule = new Rule(); + $templateKey = isset($_GET['template']) ? sanitize_key(wp_unslash($_GET['template'])) : ''; + $selectedTemplate = $this->applyRuleTemplate($rule, $templateKey); include SODINO_PLUGIN_DIR . 'admin/views/rule-form.php'; } } @@ -284,6 +304,8 @@ class AdminController { $this->saveUpsell(); } else { $upsell = new Upsell(); + $templateKey = isset($_GET['template']) ? sanitize_key(wp_unslash($_GET['template'])) : ''; + $selectedTemplate = $this->applyUpsellTemplate($upsell, $templateKey); include SODINO_PLUGIN_DIR . 'admin/views/upsell-form.php'; } } @@ -371,6 +393,117 @@ class AdminController { include SODINO_PLUGIN_DIR . 'admin/views/banner-list.php'; } + private function getRuleTemplates() { + return [ + 'first_purchase' => [ + 'title' => __('تخفیف اولین خرید', 'sodino'), + 'description' => __('برای کاربران واردشده‌ای که هنوز سفارشی ثبت نکرده‌اند تخفیف درصدی اعمال می‌کند.', 'sodino'), + 'name' => __('تخفیف اولین خرید', 'sodino'), + 'priority' => 90, + 'usage_limit' => 0, + 'conditions' => [ + ['type' => 'user_type', 'operator' => 'is', 'value' => 'new'], + ], + 'actions' => [ + ['type' => 'discount_percent', 'value' => 10], + ], + ], + 'return_after_30_days' => [ + 'title' => __('تخفیف بازگشت مشتری بعد از ۳۰ روز', 'sodino'), + 'description' => __('برای مشتریانی که حداقل یک سفارش دارند و ۳۰ روز از آخرین سفارششان گذشته است.', 'sodino'), + 'name' => __('کمپین بازگشت مشتریان قدیمی', 'sodino'), + 'priority' => 80, + 'usage_limit' => 0, + 'conditions' => [ + ['type' => 'customer_order_count_min', 'operator' => 'is', 'value' => '1'], + ['type' => 'customer_days_since_last_order_min', 'operator' => 'is', 'value' => '30'], + ], + 'actions' => [ + ['type' => 'discount_percent', 'value' => 12], + ], + ], + 'cart_total_threshold' => [ + 'title' => __('تخفیف سبد بالای مبلغ مشخص', 'sodino'), + 'description' => __('وقتی مبلغ سبد از حد مشخصی بالاتر رفت، مشتری تخفیف دریافت می‌کند.', 'sodino'), + 'name' => __('تخفیف سبد خرید بالای ۱٬۰۰۰٬۰۰۰', 'sodino'), + 'priority' => 70, + 'usage_limit' => 0, + 'conditions' => [ + ['type' => 'cart_total_min', 'operator' => 'is', 'value' => '1000000'], + ], + 'actions' => [ + ['type' => 'discount_percent', 'value' => 5], + ], + ], + 'slow_moving_stock' => [ + 'title' => __('فروش سریع موجودی کم‌فروش', 'sodino'), + 'description' => __('برای محصولاتی که فروش کل پایینی دارند تخفیف خودکار می‌گذارد.', 'sodino'), + 'name' => __('تخفیف محصولات کم‌فروش', 'sodino'), + 'priority' => 60, + 'usage_limit' => 0, + 'conditions' => [ + ['type' => 'product_total_sales_max', 'operator' => 'is', 'value' => '5'], + ], + 'actions' => [ + ['type' => 'discount_percent', 'value' => 15], + ], + ], + ]; + } + + private function getUpsellTemplates() { + return [ + 'complementary_cart_product' => [ + 'title' => __('پیشنهاد محصول مکمل در سبد خرید', 'sodino'), + 'description' => __('وقتی محصول یا دسته فعال‌ساز در سبد باشد، محصول مکمل را با تخفیف پیشنهاد می‌دهد.', 'sodino'), + 'name' => __('پیشنهاد محصول مکمل', 'sodino'), + 'trigger_type' => 'product', + 'trigger_value' => '', + 'target_product_id' => 0, + 'discount_type' => 'percentage', + 'discount_value' => 10, + 'priority' => 80, + ], + ]; + } + + private function applyRuleTemplate(Rule $rule, $templateKey) { + $templates = $this->getRuleTemplates(); + if (empty($templateKey) || empty($templates[$templateKey])) { + return null; + } + + $template = $templates[$templateKey]; + $rule->name = $template['name']; + $rule->priority = (int) $template['priority']; + $rule->usage_limit = (int) $template['usage_limit']; + $rule->conditions = $template['conditions']; + $rule->actions = $template['actions']; + $rule->enabled = 1; + $rule->syncLegacyFields(); + + return $template; + } + + private function applyUpsellTemplate(Upsell $upsell, $templateKey) { + $templates = $this->getUpsellTemplates(); + if (empty($templateKey) || empty($templates[$templateKey])) { + return null; + } + + $template = $templates[$templateKey]; + $upsell->title = $template['name']; + $upsell->trigger_type = $template['trigger_type']; + $upsell->trigger_value = $template['trigger_value']; + $upsell->target_product_id = (int) $template['target_product_id']; + $upsell->discount_type = $template['discount_type']; + $upsell->discount_value = (float) $template['discount_value']; + $upsell->priority = (int) $template['priority']; + $upsell->status = 1; + + return $template; + } + private function editBannerPage() { $id = isset($_GET['id']) ? (int) $_GET['id'] : 0; $banner = $this->bannerRepository->getById($id); @@ -522,7 +655,10 @@ class AdminController { return; } - if (!isset($_GET['_wpnonce']) || !in_array($_GET['action'], ['delete_upsell', 'toggle_upsell_status'], true) || !wp_verify_nonce($_GET['_wpnonce'], $_GET['action'])) { + $action = isset($_GET['action']) ? sanitize_key(wp_unslash($_GET['action'])) : ''; + $nonce = isset($_GET['_wpnonce']) ? sanitize_text_field(wp_unslash($_GET['_wpnonce'])) : ''; + + if (!in_array($action, ['delete_upsell', 'toggle_upsell_status'], true) || !wp_verify_nonce($nonce, $action)) { return; } @@ -531,13 +667,13 @@ class AdminController { return; } - if ($_GET['action'] === 'delete_upsell') { + if ($action === 'delete_upsell') { $this->upsellRepository->delete($id); wp_safe_redirect(admin_url('admin.php?page=sodino-upsells')); exit; } - if ($_GET['action'] === 'toggle_upsell_status') { + if ($action === 'toggle_upsell_status') { $upsell = $this->upsellRepository->getById($id); if ($upsell) { $upsell->status = $upsell->status ? 0 : 1; @@ -553,7 +689,10 @@ class AdminController { return; } - if (!isset($_GET['_wpnonce']) || !in_array($_GET['action'], ['delete_banner', 'toggle_banner_status'], true) || !wp_verify_nonce($_GET['_wpnonce'], $_GET['action'])) { + $action = isset($_GET['action']) ? sanitize_key(wp_unslash($_GET['action'])) : ''; + $nonce = isset($_GET['_wpnonce']) ? sanitize_text_field(wp_unslash($_GET['_wpnonce'])) : ''; + + if (!in_array($action, ['delete_banner', 'toggle_banner_status'], true) || !wp_verify_nonce($nonce, $action)) { return; } @@ -562,13 +701,13 @@ class AdminController { return; } - if ($_GET['action'] === 'delete_banner') { + if ($action === 'delete_banner') { $this->bannerRepository->delete($id); wp_safe_redirect(admin_url('admin.php?page=sodino-banners')); exit; } - if ($_GET['action'] === 'toggle_banner_status') { + if ($action === 'toggle_banner_status') { $banner = $this->bannerRepository->getById($id); if ($banner) { $banner->status = $banner->status ? 0 : 1; @@ -588,7 +727,7 @@ class AdminController { wp_send_json([]); } - $term = sanitize_text_field($_POST['term'] ?? ''); + $term = isset($_POST['term']) ? sanitize_text_field(wp_unslash($_POST['term'])) : ''; if (empty($term) || !function_exists('wc_get_products')) { wp_send_json([]); } diff --git a/app/Core/Cache.php b/app/Core/Cache.php index 66cf705..bc22d55 100644 --- a/app/Core/Cache.php +++ b/app/Core/Cache.php @@ -20,6 +20,10 @@ class Cache { * Get cached value */ public function get($key, $group = 'sodino') { + if (!$this->isEnabled()) { + return false; + } + $full_key = $this->buildKey($key, $group); // Check memory cache first @@ -42,6 +46,10 @@ class Cache { * Set cached value */ public function set($key, $value, $expiration = 3600, $group = 'sodino') { + if (!$this->isEnabled()) { + return false; + } + $full_key = $this->buildKey($key, $group); // Set in memory cache @@ -134,4 +142,12 @@ class Cache { private function buildKey($key, $group) { return "sodino_{$group}_{$key}"; } + + private function isEnabled() { + if (!class_exists(__NAMESPACE__ . '\Settings')) { + return true; + } + + return Settings::getInstance()->isCacheEnabled(); + } } diff --git a/app/Repositories/BannerRepository.php b/app/Repositories/BannerRepository.php index 191cb72..59465a5 100644 --- a/app/Repositories/BannerRepository.php +++ b/app/Repositories/BannerRepository.php @@ -73,14 +73,12 @@ class BannerRepository { public function incrementImpression($id) { global $wpdb; - $wpdb->query($wpdb->prepare("UPDATE {$this->table_name} SET impressions = impressions + 1 WHERE id = %d", $id)); - $this->clearCache(); + return $wpdb->query($wpdb->prepare("UPDATE {$this->table_name} SET impressions = impressions + 1 WHERE id = %d", $id)); } public function incrementClick($id) { global $wpdb; - $wpdb->query($wpdb->prepare("UPDATE {$this->table_name} SET clicks = clicks + 1 WHERE id = %d", $id)); - $this->clearCache(); + return $wpdb->query($wpdb->prepare("UPDATE {$this->table_name} SET clicks = clicks + 1 WHERE id = %d", $id)); } public function clearCache() { diff --git a/app/Repositories/EventRepository.php b/app/Repositories/EventRepository.php index cc0bf0b..ae89582 100644 --- a/app/Repositories/EventRepository.php +++ b/app/Repositories/EventRepository.php @@ -23,7 +23,7 @@ class EventRepository { $where = $this->buildWhereClauses($filters, $params); $sql = "SELECT * FROM {$this->table_name} WHERE " . implode(' AND ', $where) . " ORDER BY created_at ASC"; - return $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); + return $wpdb->get_results($this->prepareSql($sql, $params), ARRAY_A); } public function getCount(array $filters = []) { @@ -32,7 +32,7 @@ class EventRepository { $where = $this->buildWhereClauses($filters, $params); $sql = "SELECT COUNT(*) FROM {$this->table_name} WHERE " . implode(' AND ', $where); - return (int) $wpdb->get_var($wpdb->prepare($sql, $params)); + return (int) $wpdb->get_var($this->prepareSql($sql, $params)); } public function getSum($field, array $filters = []) { @@ -45,7 +45,7 @@ class EventRepository { $where = $this->buildWhereClauses($filters, $params); $sql = "SELECT SUM({$field}) FROM {$this->table_name} WHERE " . implode(' AND ', $where); - return floatval($wpdb->get_var($wpdb->prepare($sql, $params))); + return floatval($wpdb->get_var($this->prepareSql($sql, $params))); } public function getRuleUsageCount($rule_id) { @@ -95,4 +95,14 @@ class EventRepository { return $where; } + + private function prepareSql($sql, array $params) { + global $wpdb; + + if (empty($params)) { + return $sql; + } + + return $wpdb->prepare($sql, $params); + } } diff --git a/app/Repositories/RuleRepository.php b/app/Repositories/RuleRepository.php index c655357..7e13410 100644 --- a/app/Repositories/RuleRepository.php +++ b/app/Repositories/RuleRepository.php @@ -126,12 +126,18 @@ class RuleRepository { */ public function incrementUsage($id) { global $wpdb; - return $wpdb->query( + $result = $wpdb->query( $wpdb->prepare( "UPDATE {$this->table_name} SET usage_count = usage_count + 1 WHERE id = %d", $id ) ); + + if ($result !== false) { + $this->clearCache(); + } + + return $result; } /** diff --git a/app/Services/AnalyticsService.php b/app/Services/AnalyticsService.php index c33f2b4..c8326ef 100644 --- a/app/Services/AnalyticsService.php +++ b/app/Services/AnalyticsService.php @@ -44,13 +44,19 @@ class AnalyticsService { $summary = $this->getSummary($filters); $salesChart = $this->getSalesChart($filters); $rulePerformance = $this->getRulePerformance($filters); + $roiReport = $this->getRoiReport($filters); + $upsellPerformance = $this->getUpsellPerformance($filters); + $bannerPerformance = $this->getBannerPerformance($filters, $summary); $userBehavior = $this->getUserBehavior($filters); - $insights = $this->getInsights($summary, $filters); + $insights = $this->getInsights($summary, $filters, $roiReport); $result = [ 'summary' => $summary, 'sales_chart' => $salesChart, 'rule_performance' => $rulePerformance, + 'roi_report' => $roiReport, + 'upsell_performance' => $upsellPerformance, + 'banner_performance' => $bannerPerformance, 'user_behavior' => $userBehavior, 'insights' => $insights, ]; @@ -152,6 +158,160 @@ class AnalyticsService { return $result; } + public function getRoiReport(array $filters = []) { + $orders = $this->getOrdersInRange($filters); + $attributedRevenue = 0; + $attributedDiscount = 0; + $attributedOrders = []; + $ruleRows = []; + $upsellRows = []; + + foreach ($orders as $order) { + $orderId = $order->get_id(); + foreach ($order->get_items() as $item) { + $lineRevenue = (float) $item->get_total(); + $ruleIds = $this->parseIdList($item->get_meta('_sodino_rule_ids', true)); + $ruleDiscount = (float) $item->get_meta('_sodino_rule_discount', true); + $upsellId = (int) $item->get_meta('_sodino_upsell_id', true); + + if (!empty($ruleIds) || $upsellId > 0) { + $attributedRevenue += $lineRevenue; + $attributedDiscount += $ruleDiscount; + $attributedOrders[$orderId] = true; + } + + foreach ($ruleIds as $ruleId) { + if (!isset($ruleRows[$ruleId])) { + $ruleRows[$ruleId] = [ + 'rule_id' => $ruleId, + 'name' => $this->getRuleName($ruleId), + 'orders' => [], + 'revenue' => 0, + 'discount' => 0, + ]; + } + + $allocation = count($ruleIds) > 0 ? $lineRevenue / count($ruleIds) : $lineRevenue; + $discountAllocation = count($ruleIds) > 0 ? $ruleDiscount / count($ruleIds) : $ruleDiscount; + $ruleRows[$ruleId]['orders'][$orderId] = true; + $ruleRows[$ruleId]['revenue'] += $allocation; + $ruleRows[$ruleId]['discount'] += $discountAllocation; + } + + if ($upsellId > 0) { + if (!isset($upsellRows[$upsellId])) { + $upsellRows[$upsellId] = [ + 'upsell_id' => $upsellId, + 'title' => $this->getUpsellTitle($upsellId), + 'orders' => [], + 'revenue' => 0, + ]; + } + + $upsellRows[$upsellId]['orders'][$orderId] = true; + $upsellRows[$upsellId]['revenue'] += $lineRevenue; + } + } + } + + $ruleRows = array_map(function ($row) { + $row['order_count'] = count($row['orders']); + unset($row['orders']); + $row['revenue'] = round($row['revenue'], 2); + $row['discount'] = round($row['discount'], 2); + return $row; + }, array_values($ruleRows)); + + usort($ruleRows, function ($a, $b) { + return $b['revenue'] <=> $a['revenue']; + }); + + $upsellRows = array_map(function ($row) { + $row['order_count'] = count($row['orders']); + unset($row['orders']); + $row['revenue'] = round($row['revenue'], 2); + return $row; + }, array_values($upsellRows)); + + usort($upsellRows, function ($a, $b) { + return $b['revenue'] <=> $a['revenue']; + }); + + return [ + 'attributed_revenue' => round($attributedRevenue, 2), + 'attributed_discount' => round($attributedDiscount, 2), + 'attributed_order_count' => count($attributedOrders), + 'rule_rows' => $ruleRows, + 'upsell_rows' => $upsellRows, + ]; + } + + public function getUpsellPerformance(array $filters = []) { + $roi = $this->getRoiReport($filters); + $orderRows = []; + foreach ($roi['upsell_rows'] as $row) { + $orderRows[(int) $row['upsell_id']] = $row; + } + + $repository = new \Sodino\Repositories\UpsellRepository(); + $rows = []; + foreach ($repository->getAll() as $upsell) { + $orderRow = $orderRows[(int) $upsell->id] ?? null; + $impressions = max(0, (int) $upsell->impressions); + $addToCartConversions = max(0, (int) $upsell->conversions); + $orderCount = $orderRow ? (int) $orderRow['order_count'] : 0; + $revenue = $orderRow ? (float) $orderRow['revenue'] : 0; + + $rows[] = [ + 'id' => (int) $upsell->id, + 'title' => $upsell->title, + 'impressions' => $impressions, + 'add_to_cart' => $addToCartConversions, + 'orders' => $orderCount, + 'revenue' => round($revenue, 2), + 'cart_conversion_rate' => $impressions > 0 ? round(($addToCartConversions / $impressions) * 100, 2) : 0, + 'order_conversion_rate' => $impressions > 0 ? round(($orderCount / $impressions) * 100, 2) : 0, + ]; + } + + usort($rows, function ($a, $b) { + return $b['revenue'] <=> $a['revenue']; + }); + + return $rows; + } + + public function getBannerPerformance(array $filters = [], array $summary = []) { + $repository = new \Sodino\Repositories\BannerRepository(); + $purchaseCount = (int) ($summary['purchase_count'] ?? $this->eventRepository->getCount(array_merge($filters, ['event_type' => 'purchase']))); + $clickTotal = 0; + foreach ($repository->getAll() as $banner) { + $clickTotal += max(0, (int) $banner->clicks); + } + + $rows = []; + foreach ($repository->getAll() as $banner) { + $impressions = max(0, (int) $banner->impressions); + $clicks = max(0, (int) $banner->clicks); + $estimatedOrders = $clickTotal > 0 ? round(($clicks / $clickTotal) * $purchaseCount, 2) : 0; + + $rows[] = [ + 'id' => (int) $banner->id, + 'title' => $banner->title, + 'impressions' => $impressions, + 'clicks' => $clicks, + 'ctr' => $impressions > 0 ? round(($clicks / $impressions) * 100, 2) : 0, + 'estimated_orders' => $estimatedOrders, + ]; + } + + usort($rows, function ($a, $b) { + return $b['clicks'] <=> $a['clicks']; + }); + + return $rows; + } + public function getUserBehavior(array $filters = []) { $productViewCount = $this->eventRepository->getCount(array_merge($filters, ['event_type' => 'product_view'])); $addToCartCount = $this->eventRepository->getCount(array_merge($filters, ['event_type' => 'add_to_cart'])); @@ -179,9 +339,16 @@ class AnalyticsService { return $performance[0]['name'] ?? null; } - private function getInsights(array $summary, array $filters = []) { + private function getInsights(array $summary, array $filters = [], array $roiReport = []) { $insights = []; + if (!empty($roiReport['attributed_revenue'])) { + $insights[] = sprintf( + __('سودینو %s فروش اثرگرفته ثبت کرده است.', 'sodino'), + wp_strip_all_tags(wc_price((float) $roiReport['attributed_revenue'])) + ); + } + if (!empty($summary['best_rule'])) { $insights[] = sprintf('%s %s', __('قانون برتر:', 'sodino'), esc_html($summary['best_rule'])); } @@ -208,24 +375,74 @@ class AnalyticsService { } private function getDateRange($range, $start, $end) { + $today = current_time('Y-m-d'); $result = [ - 'start' => date('Y-m-d', strtotime('-6 days')), - 'end' => date('Y-m-d'), + 'start' => date('Y-m-d', strtotime($today . ' -6 days')), + 'end' => $today, ]; if ($range === '30d') { - $result['start'] = date('Y-m-d', strtotime('-29 days')); - $result['end'] = date('Y-m-d'); + $result['start'] = date('Y-m-d', strtotime($today . ' -29 days')); + $result['end'] = $today; } if ($range === 'custom' && !empty($start) && !empty($end)) { - $result['start'] = date('Y-m-d', strtotime($start)); - $result['end'] = date('Y-m-d', strtotime($end)); + $startTimestamp = strtotime($start); + $endTimestamp = strtotime($end); + + if ($startTimestamp && $endTimestamp) { + if ($endTimestamp < $startTimestamp) { + $endTimestamp = $startTimestamp; + } + + $result['start'] = date('Y-m-d', $startTimestamp); + $result['end'] = date('Y-m-d', $endTimestamp); + } } return $result; } + private function getOrdersInRange(array $filters = []) { + if (!function_exists('wc_get_orders')) { + return []; + } + + $range = $this->getDateRange($filters['range'] ?? '7d', $filters['start_date'] ?? '', $filters['end_date'] ?? ''); + $start = $filters['from'] ?? $range['start']; + $end = $filters['to'] ?? $range['end']; + + return wc_get_orders([ + 'limit' => -1, + 'status' => ['completed', 'processing'], + 'date_created' => $start . '...' . $end, + 'return' => 'objects', + ]); + } + + private function parseIdList($value) { + $ids = []; + foreach (explode(',', (string) $value) as $id) { + $id = absint(trim($id)); + if ($id > 0) { + $ids[] = $id; + } + } + + return array_values(array_unique($ids)); + } + + private function getRuleName($ruleId) { + $rule = $this->ruleRepository->getById((int) $ruleId); + return $rule ? $rule->name : sprintf(__('قانون #%d', 'sodino'), (int) $ruleId); + } + + private function getUpsellTitle($upsellId) { + $repository = new \Sodino\Repositories\UpsellRepository(); + $upsell = $repository->getById((int) $upsellId); + return $upsell ? $upsell->title : sprintf(__('آپسل #%d', 'sodino'), (int) $upsellId); + } + public function getProductIdsByCategory($category_id) { $products = get_posts([ 'post_type' => 'product', diff --git a/app/Services/BannerService.php b/app/Services/BannerService.php index 05763b2..63469fe 100644 --- a/app/Services/BannerService.php +++ b/app/Services/BannerService.php @@ -112,6 +112,12 @@ class BannerService { wp_cache_set('version', $version, 'sodino_banners'); } - return 'sodino_active_banners_' . md5($version . '|' . $position . '|' . serialize($context)); + $runtimeContext = [ + 'user' => is_user_logged_in() ? 'returning' : 'new', + 'device' => wp_is_mobile() ? 'mobile' : 'desktop', + 'minute' => gmdate('YmdHi', current_time('timestamp', true)), + ]; + + return 'sodino_active_banners_' . md5($version . '|' . $position . '|' . wp_json_encode($context) . '|' . wp_json_encode($runtimeContext)); } } diff --git a/app/Services/PricingService.php b/app/Services/PricingService.php index 16147d5..ecc12d8 100644 --- a/app/Services/PricingService.php +++ b/app/Services/PricingService.php @@ -11,7 +11,8 @@ class PricingService { private $trackingService; private $settings; private $cache; - private $trackedApplications = []; + private $appliedRules = []; + private $trackedConversions = []; public function __construct(RuleRepository $ruleRepository, TrackingService $trackingService) { $this->ruleRepository = $ruleRepository; @@ -29,7 +30,14 @@ class PricingService { return $price; } + if (!$product || !is_a($product, 'WC_Product')) { + return $price; + } + $price = $this->normalizePrice($price); + if ($price <= 0) { + return $price; + } if (!$this->settings->get('cart_pricing_enabled') && is_cart()) { return $price; @@ -50,8 +58,7 @@ class PricingService { foreach ($rules as $rule) { $oldPrice = $price; $price = $this->applyRuleActions($rule, $price); - - $this->trackDiscountOnce($product, $oldPrice, $price, $rule->id); + $this->rememberAppliedRule($product, $oldPrice, $price, $rule->id); } $price = $this->enforceLimits($originalPrice, $price); @@ -59,6 +66,56 @@ class PricingService { return max(0, $price); } + public function getAppliedRulesForProduct($product) { + if (!$product || !is_a($product, 'WC_Product')) { + return []; + } + + $keys = $this->getProductTrackingKeys($product); + $rules = []; + + foreach ($keys as $key) { + if (!empty($this->appliedRules[$key])) { + $rules = array_merge($rules, $this->appliedRules[$key]); + } + } + + if (empty($rules)) { + return []; + } + + $unique = []; + foreach ($rules as $rule) { + $unique[(int) $rule['rule_id']] = $rule; + } + + return array_values($unique); + } + + public function trackAppliedRulesForProduct($product) { + foreach ($this->getAppliedRulesForProduct($product) as $rule) { + $trackingKey = implode(':', [ + (int) $product->get_id(), + (int) $rule['rule_id'], + round((float) $rule['original_price'], 4), + round((float) $rule['discounted_price'], 4), + ]); + + if (isset($this->trackedConversions[$trackingKey])) { + continue; + } + + $this->trackedConversions[$trackingKey] = true; + $this->trackingService->recordDiscountApplied( + $product, + (float) $rule['original_price'], + (float) $rule['discounted_price'], + (int) $rule['rule_id'] + ); + $this->ruleRepository->incrementUsage((int) $rule['rule_id']); + } + } + private function getApplicableRules($product) { $rules = $this->ruleRepository->getEnabled(); $applicable = []; @@ -160,6 +217,12 @@ class PricingService { return $this->getCustomerOrderCount() >= intval($value); case 'customer_order_count_max': return $this->getCustomerOrderCount() <= intval($value); + case 'customer_days_since_last_order_min': + return $this->getCustomerDaysSinceLastOrder() >= intval($value); + case 'product_total_sales_max': + return $this->getProductTotalSales($product) <= intval($value); + case 'product_total_sales_min': + return $this->getProductTotalSales($product) >= intval($value); case 'day_of_week': return in_array((string) current_time('N'), array_map('strval', $this->normalizeIdList($value)), true); default: @@ -195,6 +258,35 @@ class PricingService { return (int) wc_get_customer_order_count(get_current_user_id()); } + private function getCustomerDaysSinceLastOrder() { + if (!is_user_logged_in()) { + return 0; + } + + $orders = wc_get_orders([ + 'customer_id' => get_current_user_id(), + 'limit' => 1, + 'orderby' => 'date', + 'order' => 'DESC', + 'status' => ['wc-completed', 'wc-processing'], + 'return' => 'objects', + ]); + + if (empty($orders)) { + return 0; + } + + $date = $orders[0]->get_date_created(); + if (!$date) { + return 0; + } + + $lastOrderTimestamp = $date->getTimestamp(); + $now = current_time('timestamp', true); + + return max(0, (int) floor(($now - $lastOrderTimestamp) / DAY_IN_SECONDS)); + } + private function userHasAllowedRole($roles) { if (!is_user_logged_in()) { return false; @@ -227,12 +319,36 @@ class PricingService { return $this->productHasTerm($product, $categories, 'product_cat'); } + private function getProductTotalSales($product) { + if (!$product || !is_a($product, 'WC_Product')) { + return 0; + } + + $productId = (int) $product->get_id(); + if ($product->is_type('variation') && method_exists($product, 'get_parent_id')) { + $parentId = (int) $product->get_parent_id(); + if ($parentId > 0) { + $productId = $parentId; + } + } + + return (int) get_post_meta($productId, 'total_sales', true); + } + private function productHasTerm($product, $terms, $taxonomy) { if (!$product || empty($terms)) { return false; } - $product_terms = wp_get_post_terms($product->get_id(), $taxonomy, ['fields' => 'ids']); + $productId = (int) $product->get_id(); + if ($product->is_type('variation') && method_exists($product, 'get_parent_id')) { + $parentId = (int) $product->get_parent_id(); + if ($parentId > 0) { + $productId = $parentId; + } + } + + $product_terms = wp_get_post_terms($productId, $taxonomy, ['fields' => 'ids']); if (is_wp_error($product_terms)) { return false; } @@ -428,16 +544,30 @@ class PricingService { return false; } - private function trackDiscountOnce($product, $oldPrice, $price, $ruleId) { - $productId = $product ? $product->get_id() : 0; - $key = implode(':', [$productId, (int) $ruleId, round($oldPrice, 4), round($price, 4)]); - - if ($price >= $oldPrice || isset($this->trackedApplications[$key])) { + private function rememberAppliedRule($product, $oldPrice, $price, $ruleId) { + if ($price >= $oldPrice || !$product || !is_a($product, 'WC_Product')) { return; } - $this->trackedApplications[$key] = true; - $this->trackingService->recordDiscountApplied($product, $oldPrice, $price, $ruleId); - $this->ruleRepository->incrementUsage($ruleId); + foreach ($this->getProductTrackingKeys($product) as $key) { + $this->appliedRules[$key][(int) $ruleId] = [ + 'rule_id' => (int) $ruleId, + 'original_price' => (float) $oldPrice, + 'discounted_price' => (float) $price, + ]; + } + } + + private function getProductTrackingKeys($product) { + $keys = [(int) $product->get_id()]; + + if ($product->is_type('variation') && method_exists($product, 'get_parent_id')) { + $parentId = (int) $product->get_parent_id(); + if ($parentId > 0) { + $keys[] = $parentId; + } + } + + return array_values(array_unique($keys)); } } diff --git a/app/Services/TrackingService.php b/app/Services/TrackingService.php index 87121bb..1fe5e72 100644 --- a/app/Services/TrackingService.php +++ b/app/Services/TrackingService.php @@ -98,6 +98,10 @@ class TrackingService { } private function logEvent($type, array $data = []) { + if (empty($type)) { + return; + } + $event = [ 'event_type' => $type, 'product_id' => isset($data['product_id']) ? intval($data['product_id']) : null, @@ -111,7 +115,7 @@ class TrackingService { 'created_at' => current_time('mysql'), ]; - $this->eventRepository->insert($event); + return $this->eventRepository->insert($event); } private function getSessionId() { @@ -124,7 +128,10 @@ class TrackingService { return $session_id; } - return 'guest_' . md5($_SERVER['REMOTE_ADDR'] . '|' . $_SERVER['HTTP_USER_AGENT']); + $remote_addr = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : ''; + $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : ''; + + return 'guest_' . md5($remote_addr . '|' . $user_agent); } private function hasLogged($key) { diff --git a/composer.json b/composer.json index fe86e0c..8ab98c5 100644 --- a/composer.json +++ b/composer.json @@ -6,8 +6,7 @@ "version": "2.0.0", "authors": [ { - "name": "Your Name", - "email": "your.email@example.com" + "name": "Soheil Khaledabadi" } ], "require": { diff --git a/public/hooks/banner-hooks.php b/public/hooks/banner-hooks.php index 9bc50fd..3f0ae2d 100644 --- a/public/hooks/banner-hooks.php +++ b/public/hooks/banner-hooks.php @@ -20,6 +20,7 @@ add_action('woocommerce_before_cart', 'sodino_render_cart_banner'); add_action('wp_enqueue_scripts', 'sodino_enqueue_banner_assets'); add_action('wp_ajax_nopriv_sodino_banner_click', 'sodino_handle_banner_click'); add_action('wp_ajax_sodino_banner_click', 'sodino_handle_banner_click'); +add_action('woocommerce_checkout_create_order', 'sodino_add_banner_order_meta', 20, 2); function sodino_enqueue_banner_assets() { if (is_admin()) { @@ -164,12 +165,15 @@ function sodino_render_cart_banner() { } function sodino_handle_banner_click() { - $nonce = $_POST['nonce'] ?? ($_POST['security'] ?? ''); + $nonce = isset($_POST['nonce']) + ? sanitize_text_field(wp_unslash($_POST['nonce'])) + : (isset($_POST['security']) ? sanitize_text_field(wp_unslash($_POST['security'])) : ''); + if (!isset($_POST['banner_id']) || !wp_verify_nonce($nonce, 'sodino_banner_click')) { wp_send_json_error(); } - $bannerId = intval($_POST['banner_id']); + $bannerId = absint(wp_unslash($_POST['banner_id'])); if (!$bannerId) { wp_send_json_error(); } @@ -180,5 +184,46 @@ function sodino_handle_banner_click() { } $sodino_banner_service->increaseClick($bannerId); + sodino_remember_banner_click($bannerId); wp_send_json_success(); } + +function sodino_remember_banner_click($bannerId) { + if (!function_exists('WC') || !WC()->session) { + return; + } + + $clicks = WC()->session->get('sodino_banner_clicks', []); + if (!is_array($clicks)) { + $clicks = []; + } + + $clicks[(int) $bannerId] = current_time('timestamp'); + WC()->session->set('sodino_banner_clicks', $clicks); +} + +function sodino_add_banner_order_meta($order, $data) { + if (!function_exists('WC') || !WC()->session || !$order) { + return; + } + + $clicks = WC()->session->get('sodino_banner_clicks', []); + if (empty($clicks) || !is_array($clicks)) { + return; + } + + $validClicks = []; + $cutoff = current_time('timestamp') - DAY_IN_SECONDS; + foreach ($clicks as $bannerId => $timestamp) { + if ((int) $bannerId > 0 && (int) $timestamp >= $cutoff) { + $validClicks[] = (int) $bannerId; + } + } + + if (empty($validClicks)) { + return; + } + + $order->update_meta_data('_sodino_banner_click_ids', implode(',', array_values(array_unique($validClicks)))); + WC()->session->__unset('sodino_banner_clicks'); +} diff --git a/public/hooks/pricing-hooks.php b/public/hooks/pricing-hooks.php index c50e9df..ca1d6f0 100644 --- a/public/hooks/pricing-hooks.php +++ b/public/hooks/pricing-hooks.php @@ -22,6 +22,11 @@ add_filter('woocommerce_product_get_sale_price', 'sodino_apply_dynamic_pricing', add_filter('woocommerce_product_variation_get_price', 'sodino_apply_dynamic_pricing', 10, 2); add_filter('woocommerce_product_variation_get_sale_price', 'sodino_apply_dynamic_pricing', 10, 2); add_filter('woocommerce_package_rates', 'sodino_apply_free_shipping_rules', 20, 1); +add_filter('woocommerce_add_cart_item_data', 'sodino_add_dynamic_pricing_cart_item_data', 20, 4); +add_filter('woocommerce_get_cart_item_from_session', 'sodino_restore_dynamic_pricing_cart_item_data', 20, 2); +add_filter('woocommerce_get_item_data', 'sodino_display_dynamic_pricing_cart_item_data', 20, 2); +add_action('woocommerce_checkout_create_order_line_item', 'sodino_add_dynamic_pricing_order_item_meta', 20, 4); +add_action('woocommerce_add_to_cart', 'sodino_track_dynamic_pricing_conversion', 30, 6); function sodino_apply_dynamic_pricing($price, $product) { global $sodino_pricing_service; @@ -32,3 +37,95 @@ function sodino_apply_free_shipping_rules($rates) { global $sodino_pricing_service; return $sodino_pricing_service->applyFreeShippingRates($rates); } + +function sodino_add_dynamic_pricing_cart_item_data($cart_item_data, $product_id, $variation_id, $quantity) { + global $sodino_pricing_service; + + if (!$sodino_pricing_service) { + return $cart_item_data; + } + + $product = wc_get_product($variation_id ?: $product_id); + if (!$product) { + return $cart_item_data; + } + + $basePrice = $product->get_price('edit'); + if ($basePrice === '' || !is_numeric($basePrice)) { + return $cart_item_data; + } + + $discountedPrice = $sodino_pricing_service->applyDynamicPricing($basePrice, $product); + $rules = $sodino_pricing_service->getAppliedRulesForProduct($product); + if (empty($rules) || $discountedPrice >= (float) $basePrice) { + return $cart_item_data; + } + + $cart_item_data['sodino_dynamic_pricing'] = [ + 'rule_ids' => array_values(array_unique(array_map('intval', array_column($rules, 'rule_id')))), + 'original_price' => (float) $basePrice, + 'discounted_price' => (float) $discountedPrice, + 'discount_value' => max(0, (float) $basePrice - (float) $discountedPrice), + ]; + + return $cart_item_data; +} + +function sodino_restore_dynamic_pricing_cart_item_data($cart_item, $values) { + if (!empty($values['sodino_dynamic_pricing'])) { + $cart_item['sodino_dynamic_pricing'] = $values['sodino_dynamic_pricing']; + } + + return $cart_item; +} + +function sodino_display_dynamic_pricing_cart_item_data($item_data, $cart_item) { + if (empty($cart_item['sodino_dynamic_pricing']['discount_value'])) { + return $item_data; + } + + $item_data[] = [ + 'key' => __('تخفیف سودینو', 'sodino'), + 'value' => wp_kses_post(wc_price((float) $cart_item['sodino_dynamic_pricing']['discount_value'])), + ]; + + return $item_data; +} + +function sodino_add_dynamic_pricing_order_item_meta($item, $cart_item_key, $values, $order) { + if (empty($values['sodino_dynamic_pricing'])) { + return; + } + + $pricing = $values['sodino_dynamic_pricing']; + $ruleIds = !empty($pricing['rule_ids']) ? array_map('intval', (array) $pricing['rule_ids']) : []; + + if (empty($ruleIds)) { + return; + } + + $item->add_meta_data('_sodino_rule_ids', implode(',', $ruleIds), true); + $item->add_meta_data('_sodino_rule_discount', (float) ($pricing['discount_value'] ?? 0), true); + $item->add_meta_data(__('تخفیف قوانین سودینو', 'sodino'), wc_price((float) ($pricing['discount_value'] ?? 0)), true); +} + +function sodino_track_dynamic_pricing_conversion($cart_item_key, $product_id, $quantity, $variation_id, $variation, $cart_item_data) { + global $sodino_pricing_service; + + if (!$sodino_pricing_service) { + return; + } + + $product = wc_get_product($variation_id ?: $product_id); + if (!$product) { + return; + } + + $basePrice = $product->get_price('edit'); + if ($basePrice === '' || !is_numeric($basePrice)) { + return; + } + + $sodino_pricing_service->applyDynamicPricing($basePrice, $product); + $sodino_pricing_service->trackAppliedRulesForProduct($product); +} diff --git a/readme.txt b/readme.txt index 44f741f..6b5ebad 100644 --- a/readme.txt +++ b/readme.txt @@ -2,9 +2,9 @@ Contributors: Soheil khaledabadi Tags: woocommerce, pricing, dynamic pricing, revenue optimization Requires at least: 5.0 -Tested up to: 6.0 +Tested up to: 6.9 Requires PHP: 7.4 -Stable tag: 1.0.0 +Stable tag: 2.0.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -16,8 +16,10 @@ Sodino dynamically adjusts WooCommerce product prices based on user behavior and Features: - Dynamic pricing based on user type (new vs returning) -- Rule-based system for discounts -- Admin panel to manage rules +- Advanced rule builder for product, category, cart, customer, role, and schedule conditions +- Smart upsell offers with real cart discounts and performance tracking +- Targeted banners with scheduling, device targeting, and click/impression reporting +- Admin dashboard for revenue, discount, conversion, and rule performance - MVC architecture for extensibility == Installation == @@ -34,5 +36,9 @@ Yes, it applies to all product types. == Changelog == += 2.0.0 = +* Added advanced pricing rules, upsells, smart banners, analytics dashboard, and tools page. +* Improved database migrations, cache handling, security checks, and WooCommerce compatibility. + = 1.0.0 = -* Initial release. \ No newline at end of file +* Initial release. diff --git a/sodino.php b/sodino.php index cb7808b..0ee850d 100644 --- a/sodino.php +++ b/sodino.php @@ -1,17 +1,17 @@ isEnabled()) { + return; + } if ($settings->isPricingEnabled()) { require_once SODINO_PLUGIN_DIR . 'public/hooks/pricing-hooks.php'; @@ -147,7 +151,7 @@ function sodino_init_public_hooks() { require_once SODINO_PLUGIN_DIR . 'public/hooks/banner-hooks.php'; } - // Always load analytics + // Load analytics while Sodino is enabled. require_once SODINO_PLUGIN_DIR . 'public/hooks/analytics-hooks.php'; }