From 5930c1ad6f5bf8c4e4b60c799eafb9d96e195d70 Mon Sep 17 00:00:00 2001 From: soheil khaledabadi Date: Sat, 2 May 2026 23:30:23 +0330 Subject: [PATCH] feat: Implement upsell functionality with repository and service layers --- admin/admin.php | 30 ++- admin/class-upsell-list-table.php | 138 ++++++++++++ admin/css/admin.css | 296 +++++++++++++++++++++++++- admin/js/dashboard.js | 175 +++++++++++++++ admin/js/upsell-admin.js | 106 +++++++++ admin/views/competitor-price.php | 145 +++++++++++++ admin/views/dashboard.php | 170 +++++++++++++++ admin/views/rule-form.php | 203 ++++++++++++++---- admin/views/rules-list.php | 80 ++++++- admin/views/settings.php | 196 ++++++++++++++++- admin/views/upsell-form.php | 187 ++++++++++++++++ admin/views/upsell-list.php | 78 +++++++ app/Controllers/AdminController.php | 259 +++++++++++++++++++++- app/Models/Rule.php | 18 ++ app/Models/Upsell.php | 53 +++++ app/Repositories/EventRepository.php | 98 +++++++++ app/Repositories/UpsellRepository.php | 61 ++++++ app/Services/AnalyticsService.php | 281 ++++++++++++++++++++++++ app/Services/PricingService.php | 181 ++++++++++++---- app/Services/TrackingService.php | 137 ++++++++++++ app/Services/UpsellService.php | 149 +++++++++++++ database/migrations.php | 33 ++- public/hooks/analytics-hooks.php | 53 +++++ public/hooks/pricing-hooks.php | 6 +- public/hooks/upsell-hooks.php | 91 ++++++++ sodino.php | 32 +++ 26 files changed, 3130 insertions(+), 126 deletions(-) create mode 100644 admin/class-upsell-list-table.php create mode 100644 admin/js/dashboard.js create mode 100644 admin/js/upsell-admin.js create mode 100644 admin/views/competitor-price.php create mode 100644 admin/views/dashboard.php create mode 100644 admin/views/upsell-form.php create mode 100644 admin/views/upsell-list.php create mode 100644 app/Models/Upsell.php create mode 100644 app/Repositories/EventRepository.php create mode 100644 app/Repositories/UpsellRepository.php create mode 100644 app/Services/AnalyticsService.php create mode 100644 app/Services/TrackingService.php create mode 100644 app/Services/UpsellService.php create mode 100644 public/hooks/analytics-hooks.php create mode 100644 public/hooks/upsell-hooks.php diff --git a/admin/admin.php b/admin/admin.php index 77e5165..eceba5c 100644 --- a/admin/admin.php +++ b/admin/admin.php @@ -6,23 +6,49 @@ if (!defined('ABSPATH')) { use Sodino\Controllers\AdminController; use Sodino\Repositories\RuleRepository; +use Sodino\Repositories\UpsellRepository; // Initialize admin $ruleRepository = new RuleRepository(); -$adminController = new AdminController($ruleRepository); +$upsellRepository = new UpsellRepository(); +$adminController = new AdminController($ruleRepository, $upsellRepository); // Add menu add_action('admin_menu', [$adminController, 'addMenu']); +// Admin AJAX handlers +add_action('wp_ajax_sodino_search_products', [$adminController, 'searchProductsAjax']); + // Enqueue admin assets -add_action('admin_enqueue_scripts', function($hook) { +add_action('admin_enqueue_scripts', function($hook) use ($adminController) { if (strpos($hook, 'sodino') === false) { return; } + + // Enqueue Tailwind via CDN script + wp_enqueue_script('sodino-tailwind', 'https://cdn.tailwindcss.com', [], null); + wp_enqueue_style('sodino-admin', plugin_dir_url(__FILE__) . 'css/admin.css', [], SODINO_VERSION); + + if (strpos($hook, 'sodino_page_sodino-dashboard') !== false) { + wp_enqueue_script('sodino-chart-js', 'https://cdn.jsdelivr.net/npm/chart.js', [], null, true); + wp_enqueue_script('sodino-dashboard-js', plugin_dir_url(__FILE__) . 'js/dashboard.js', ['sodino-chart-js'], null, true); + } + + if (strpos($hook, 'sodino_page_sodino-add-upsell') !== false) { + wp_enqueue_script('sodino-upsell-admin', plugin_dir_url(__FILE__) . 'js/upsell-admin.js', [], SODINO_VERSION, true); + wp_localize_script('sodino-upsell-admin', 'sodinoUpsellAdmin', [ + 'nonce' => wp_create_nonce('sodino_search_products'), + ]); + } }); // Handle delete for any Sodino admin page if (isset($_GET['page']) && strpos($_GET['page'], 'sodino') === 0 && isset($_GET['action']) && $_GET['action'] === 'delete') { add_action('admin_init', [$adminController, 'handleDelete']); +} + +// Handle upsell actions +if (isset($_GET['page']) && strpos($_GET['page'], 'sodino') === 0 && isset($_GET['action']) && in_array($_GET['action'], ['delete_upsell', 'toggle_upsell_status'], true)) { + add_action('admin_init', [$adminController, 'handleUpsellActions']); } \ No newline at end of file diff --git a/admin/class-upsell-list-table.php b/admin/class-upsell-list-table.php new file mode 100644 index 0000000..c1fe645 --- /dev/null +++ b/admin/class-upsell-list-table.php @@ -0,0 +1,138 @@ + 'sodino_upsell', + 'plural' => 'sodino_upsells', + 'ajax' => false, + ]); + + $this->repository = $repository; + } + + public function get_columns() { + return [ + 'cb' => '', + 'title' => __('عنوان', 'sodino'), + 'trigger' => __('شرط فعال‌سازی', 'sodino'), + 'suggested_product'=> __('محصول پیشنهادی', 'sodino'), + 'discount' => __('تخفیف', 'sodino'), + 'status' => __('وضعیت', 'sodino'), + 'actions' => __('عملیات', 'sodino'), + ]; + } + + protected function get_sortable_columns() { + return [ + 'title' => ['title', true], + 'priority' => ['priority', true], + ]; + } + + protected function column_cb($item) { + return sprintf('', $item->id); + } + + public function get_bulk_actions() { + return [ + 'delete' => __('حذف گروهی', 'sodino'), + ]; + } + + public function column_title($item) { + $edit_url = admin_url('admin.php?page=sodino-add-upsell&action=edit&id=' . $item->id); + $title = sprintf('%s', esc_url($edit_url), esc_html($item->title)); + return $title; + } + + public function column_trigger($item) { + switch ($item->trigger_type) { + case 'product': + $product = wc_get_product(intval($item->trigger_value)); + return $product ? esc_html($product->get_name()) : __('محصول خاص', 'sodino'); + case 'category': + $term = get_term(intval($item->trigger_value)); + return $term && !is_wp_error($term) ? esc_html($term->name) : __('دسته‌بندی', 'sodino'); + case 'cart_total': + return sprintf('%s %s', esc_html(number_format_i18n(floatval($item->trigger_value))), __('تومان', 'sodino')); + default: + return __('نامشخص', 'sodino'); + } + } + + public function column_suggested_product($item) { + $product = wc_get_product($item->target_product_id); + return $product ? esc_html($product->get_name()) : __('نامشخص', 'sodino'); + } + + public function column_discount($item) { + if ($item->discount_type === 'fixed') { + return sprintf('%s %s', esc_html(number_format_i18n($item->discount_value)), __('تومان', 'sodino')); + } + if ($item->discount_type === 'percentage') { + return sprintf('%s %%', esc_html($item->discount_value)); + } + return __('بدون تخفیف', 'sodino'); + } + + public function column_status($item) { + return $item->status ? __('فعال', 'sodino') : __('غیرفعال', 'sodino'); + } + + public function column_actions($item) { + $edit_url = admin_url('admin.php?page=sodino-add-upsell&action=edit&id=' . $item->id); + $toggle_url = wp_nonce_url(admin_url('admin.php?page=sodino-upsells&action=toggle_upsell_status&id=' . $item->id), 'toggle_upsell_status'); + $delete_url = wp_nonce_url(admin_url('admin.php?page=sodino-upsells&action=delete_upsell&id=' . $item->id), 'delete_upsell'); + + $toggle_label = $item->status ? __('غیرفعال کردن', 'sodino') : __('فعال کردن', 'sodino'); + + return sprintf( + '%s | %s | %s', + esc_url($edit_url), + esc_html__('ویرایش', 'sodino'), + esc_url($toggle_url), + esc_html($toggle_label), + esc_url($delete_url), + esc_js(__('آیا از حذف این پیشنهاد آپسل مطمئن هستید؟', 'sodino')), + esc_html__('حذف', 'sodino') + ); + } + + public function prepare_items() { + $this->_column_headers = [$this->get_columns(), [], $this->get_sortable_columns()]; + $this->process_bulk_action(); + + $all_items = $this->repository->getAll(); + $current_page = $this->get_pagenum(); + $total_items = count($all_items); + $this->items = array_slice($all_items, ($current_page - 1) * $this->items_per_page, $this->items_per_page); + + $this->set_pagination_args([ + 'total_items' => $total_items, + 'per_page' => $this->items_per_page, + 'total_pages' => ceil($total_items / $this->items_per_page), + ]); + } + + public function process_bulk_action() { + if ('delete' === $this->current_action()) { + $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) { + $this->repository->delete($id); + } + } + } + } +} diff --git a/admin/css/admin.css b/admin/css/admin.css index 29a9b81..ee219ec 100644 --- a/admin/css/admin.css +++ b/admin/css/admin.css @@ -1,22 +1,296 @@ -.wrap { +#sodino-app { direction: rtl; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #0f172a; } -.sodino-admin-table th, -.sodino-admin-table td { +#sodino-app * { + box-sizing: border-box; +} + +#sodino-app a { + color: inherit; + text-decoration: none; +} + +#sodino-app .sd-app { + display: grid; + gap: 24px; +} + +#sodino-app .sd-header { + background: #eef2ff; + border: 1px solid #c7d2fe; + border-radius: 1rem; + padding: 24px; +} + +#sodino-app .sd-header-title { + font-size: 1.75rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +#sodino-app .sd-header-subtitle { + color: #475569; + line-height: 1.75; +} + +#sodino-app .sd-page-layout { + display: grid; + gap: 24px; +} + +@media (min-width: 1024px) { + #sodino-app .sd-page-layout { + grid-template-columns: 260px minmax(0, 1fr); + } +} + +#sodino-app .sd-sidebar { + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 1rem; + padding: 18px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); +} + +#sodino-app .sd-sidebar-title { + font-size: 1rem; + font-weight: 700; + margin-bottom: 1rem; +} + +#sodino-app .sd-nav { + display: grid; + gap: 10px; +} + +#sodino-app .sd-nav-link { + display: block; + padding: 0.85rem 1rem; + border-radius: 0.9rem; + font-weight: 600; + color: #334155; + background: #f8fafc; + border: 1px solid transparent; + transition: all 0.15s ease-in-out; text-align: right; } -.page-title-action { - float: left; +#sodino-app .sd-nav-link:hover, +#sodino-app .sd-nav-link.active { + background: #e0e7ff; + border-color: #c7d2fe; + color: #1e3a8a; } -.rtl .page-title-action { - float: left; +#sodino-app .sd-nav-link.disabled { + color: #94a3b8; + pointer-events: none; + background: #f8fafc; } -input.regular-text, -select.regular-text, -input.small-text { - direction: rtl; +#sodino-app .sd-main { + display: grid; + gap: 24px; +} + +#sodino-app .sd-card { + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 1.25rem; + padding: 24px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); +} + +#sodino-app .sd-stat-grid { + display: grid; + gap: 20px; +} + +@media (min-width: 1024px) { + #sodino-app .sd-stat-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +#sodino-app .sd-card-title { + font-size: 1rem; + font-weight: 700; + color: #334155; + margin-bottom: 1rem; +} + +#sodino-app .sd-card-amount { + font-size: 2rem; + font-weight: 700; + color: #0f172a; +} + +#sodino-app .sd-btn { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 0.85rem; + padding: 0.75rem 1.2rem; + font-weight: 600; + transition: all 0.15s ease-in-out; + border: 1px solid transparent; + cursor: pointer; +} + +#sodino-app .sd-btn-primary { + background: #4338ca; + color: #ffffff; +} + +#sodino-app .sd-btn-primary:hover { + background: #3730a3; +} + +#sodino-app .sd-btn-secondary { + background: #f8fafc; + color: #0f172a; + border-color: #e2e8f0; +} + +#sodino-app .sd-btn-secondary:hover { + background: #eef2ff; +} + +#sodino-app .sd-btn-danger { + background: #ef4444; + color: #ffffff; +} + +#sodino-app .sd-btn-danger:hover { + background: #dc2626; +} + +#sodino-app .sd-form-group { + display: grid; + gap: 12px; + margin-bottom: 18px; +} + +#sodino-app .sd-label { + font-weight: 700; + text-align: right; + color: #0f172a; +} + +#sodino-app .sd-input, +#sodino-app .sd-select, +#sodino-app .sd-textarea { + width: 100%; + border-radius: 1rem; + border: 1px solid #cbd5e1; + padding: 0.85rem 1rem; + background: #ffffff; + color: #0f172a; + outline: none; +} + +#sodino-app .sd-input:focus, +#sodino-app .sd-select:focus, +#sodino-app .sd-textarea:focus { + border-color: #6366f1; + box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.15); +} + +#sodino-app .sd-input[readonly], +#sodino-app .sd-select[disabled] { + background: #f8fafc; +} + +#sodino-app .sd-helper { + color: #64748b; + font-size: 0.95rem; +} + +#sodino-app .sd-table-wrapper { + overflow-x: auto; +} + +#sodino-app table.sd-table { + width: 100%; + border-collapse: collapse; + min-width: 700px; +} + +#sodino-app table.sd-table th, +#sodino-app table.sd-table td { + padding: 1rem 1.25rem; + text-align: right; + border-bottom: 1px solid #e2e8f0; + vertical-align: middle; +} + +#sodino-app table.sd-table thead th { + background: #f8fafc; + color: #475569; + font-weight: 700; +} + +#sodino-app table.sd-table tbody tr:nth-child(odd) { + background: #f8fafc; +} + +#sodino-app table.sd-table tbody tr:hover { + background: #eef2ff; +} + +#sodino-app .sd-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.35rem 0.8rem; + border-radius: 9999px; + font-size: 0.85rem; + font-weight: 700; +} + +#sodino-app .sd-badge--success { + background: #dcfce7; + color: #166534; +} + +#sodino-app .sd-badge--danger { + background: #fee2e2; + color: #991b1b; +} + +#sodino-app .sd-alert { + border-radius: 1rem; + padding: 18px 20px; + margin-bottom: 20px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); +} + +#sodino-app .sd-alert-success { + background: #ecfdf5; + color: #064e3b; + border: 1px solid #d1fae5; +} + +#sodino-app .sd-alert-error { + background: #fef2f2; + color: #7f1d1d; + border: 1px solid #fecdd3; +} + +#sodino-app .sd-grid-3 { + display: grid; + gap: 20px; +} + +@media (min-width: 768px) { + #sodino-app .sd-grid-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +#sodino-app .sd-chart-card { + min-height: 300px; } diff --git a/admin/js/dashboard.js b/admin/js/dashboard.js new file mode 100644 index 0000000..e5c8aee --- /dev/null +++ b/admin/js/dashboard.js @@ -0,0 +1,175 @@ +document.addEventListener('DOMContentLoaded', function () { + const dataEl = document.getElementById('sodino-dashboard-data'); + if (!dataEl) { + return; + } + + let dashboardData; + try { + dashboardData = JSON.parse(dataEl.textContent || '{}'); + } catch (e) { + return; + } + + if (typeof Chart === 'undefined') { + return; + } + + const salesChartContext = document.getElementById('sodinoSalesChart'); + const discountChartContext = document.getElementById('sodinoDiscountChart'); + const ruleChartContext = document.getElementById('sodinoRuleChart'); + + if (salesChartContext && dashboardData.salesChart) { + new Chart(salesChartContext.getContext('2d'), { + type: 'line', + data: { + labels: dashboardData.salesChart.labels, + datasets: [ + { + label: dashboardData.translations.afterApplying, + data: dashboardData.salesChart.after, + borderColor: '#0ea5e9', + backgroundColor: 'rgba(14, 165, 233, 0.15)', + fill: true, + tension: 0.35, + pointRadius: 3, + }, + { + label: dashboardData.translations.beforeApplying, + data: dashboardData.salesChart.before, + borderColor: '#ef4444', + backgroundColor: 'rgba(239, 68, 68, 0.15)', + fill: true, + tension: 0.35, + pointRadius: 3, + }, + ], + }, + options: { + responsive: true, + plugins: { + legend: { + labels: { + color: '#334155', + }, + }, + }, + scales: { + x: { + ticks: { + color: '#475569', + }, + grid: { + color: 'rgba(148, 163, 184, 0.15)', + }, + }, + y: { + ticks: { + color: '#475569', + }, + grid: { + color: 'rgba(148, 163, 184, 0.15)', + }, + }, + }, + }, + }); + } + + if (discountChartContext && dashboardData.summary) { + new Chart(discountChartContext.getContext('2d'), { + type: 'bar', + data: { + labels: [dashboardData.translations.totalDiscount, dashboardData.translations.totalRevenue], + datasets: [ + { + label: dashboardData.translations.discountEffect, + data: [dashboardData.summary.total_discount, dashboardData.summary.total_revenue], + backgroundColor: ['#fbbf24', '#0ea5e9'], + borderRadius: 999, + borderSkipped: false, + }, + ], + }, + options: { + responsive: true, + plugins: { + legend: { + display: false, + }, + }, + scales: { + x: { + ticks: { + color: '#475569', + }, + grid: { + display: false, + }, + }, + y: { + ticks: { + color: '#475569', + }, + grid: { + color: 'rgba(148, 163, 184, 0.15)', + }, + }, + }, + }, + }); + } + + if (ruleChartContext && dashboardData.rulePerformance) { + new Chart(ruleChartContext.getContext('2d'), { + type: 'bar', + data: { + labels: dashboardData.rulePerformance.names, + datasets: [ + { + label: dashboardData.translations.ruleRevenue, + data: dashboardData.rulePerformance.revenue, + backgroundColor: '#0ea5e9', + borderRadius: 999, + }, + { + label: dashboardData.translations.ruleDiscount, + data: dashboardData.rulePerformance.discount, + backgroundColor: '#f59e0b', + borderRadius: 999, + }, + ], + }, + options: { + indexAxis: 'y', + responsive: true, + plugins: { + legend: { + position: 'bottom', + labels: { + color: '#475569', + }, + }, + }, + scales: { + x: { + ticks: { + color: '#475569', + }, + grid: { + color: 'rgba(148, 163, 184, 0.15)', + }, + }, + y: { + ticks: { + color: '#475569', + }, + grid: { + display: false, + }, + }, + }, + }, + }); + } +}); diff --git a/admin/js/upsell-admin.js b/admin/js/upsell-admin.js new file mode 100644 index 0000000..d1ab8b6 --- /dev/null +++ b/admin/js/upsell-admin.js @@ -0,0 +1,106 @@ +(function () { + const triggerTypeInputs = document.querySelectorAll('.trigger-type'); + const triggerProductWrapper = document.getElementById('trigger-product-wrapper'); + const triggerCategoryWrapper = document.getElementById('trigger-category-wrapper'); + const triggerAmountWrapper = document.getElementById('trigger-amount-wrapper'); + const triggerValueInput = document.getElementById('trigger_value'); + const triggerProductSearch = document.getElementById('trigger_product_search'); + const triggerProductResults = document.getElementById('trigger_product_results'); + + const targetProductSearch = document.getElementById('target_product_search'); + const targetProductResults = document.getElementById('target_product_results'); + const targetProductIdInput = document.getElementById('target_product_id'); + + function setTriggerVisibility() { + const selected = document.querySelector('input[name="trigger_type"]:checked'); + const value = selected ? selected.value : 'product'; + + triggerProductWrapper.classList.toggle('hidden', value !== 'product'); + triggerCategoryWrapper.classList.toggle('hidden', value !== 'category'); + triggerAmountWrapper.classList.toggle('hidden', value !== 'cart_total'); + + if (value !== 'product' && triggerProductSearch) { + triggerValueInput.value = ''; + } + } + + function searchProducts(inputElement, resultsContainer, targetInput) { + const term = inputElement.value.trim(); + if (!term) { + resultsContainer.classList.add('hidden'); + resultsContainer.innerHTML = ''; + return; + } + + const formData = new URLSearchParams(); + formData.append('action', 'sodino_search_products'); + formData.append('security', sodinoUpsellAdmin.nonce); + formData.append('term', term); + + fetch(ajaxurl, { + method: 'POST', + credentials: 'same-origin', + body: formData, + }) + .then(response => response.json()) + .then(data => { + if (!Array.isArray(data)) { + resultsContainer.classList.add('hidden'); + return; + } + + const items = data.map(product => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'w-full text-right px-4 py-3 hover:bg-blue-50 focus:bg-blue-50'; + button.textContent = product.label; + button.addEventListener('click', function () { + if (targetInput) { + targetInput.value = product.id; + inputElement.value = product.label; + } else { + triggerValueInput.value = product.id; + inputElement.value = product.label; + } + resultsContainer.classList.add('hidden'); + }); + return button; + }); + + resultsContainer.innerHTML = ''; + items.forEach(item => resultsContainer.appendChild(item)); + resultsContainer.classList.toggle('hidden', items.length === 0); + }) + .catch(() => { + resultsContainer.classList.add('hidden'); + }); + } + + if (triggerTypeInputs.length) { + triggerTypeInputs.forEach(input => { + input.addEventListener('change', setTriggerVisibility); + }); + setTriggerVisibility(); + } + + if (triggerProductSearch) { + triggerProductSearch.addEventListener('input', function () { + searchProducts(triggerProductSearch, triggerProductResults, null); + }); + } + + if (targetProductSearch) { + targetProductSearch.addEventListener('input', function () { + searchProducts(targetProductSearch, targetProductResults, targetProductIdInput); + }); + } + + document.addEventListener('click', function (event) { + if (triggerProductResults && !triggerProductResults.contains(event.target) && event.target !== triggerProductSearch) { + triggerProductResults.classList.add('hidden'); + } + if (targetProductResults && !targetProductResults.contains(event.target) && event.target !== targetProductSearch) { + targetProductResults.classList.add('hidden'); + } + }); +})(); diff --git a/admin/views/competitor-price.php b/admin/views/competitor-price.php new file mode 100644 index 0000000..ab61436 --- /dev/null +++ b/admin/views/competitor-price.php @@ -0,0 +1,145 @@ + +
+
+
+
+
+
+

+

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

+

+
+ +
+
+ +
+
+

+

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
23,500 تومان24,900 تومان1,400 تومان کمتر
78,000 تومان82,000 تومان4,000 تومان کمتر
155,000 تومان161,000 تومان6,000 تومان بیشتر
+
+
+ +
+
+
+

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

+

+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
diff --git a/admin/views/dashboard.php b/admin/views/dashboard.php new file mode 100644 index 0000000..4c0b459 --- /dev/null +++ b/admin/views/dashboard.php @@ -0,0 +1,170 @@ + +
+ +
+
+
+
+
+

+

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

+

+
+ + +
+
+

+
+
+
+

+
+
+
+

+
%
+
+
+

+
+
+
+ + +
+
+

+
+ +
+
+
+

+
+ +
+
+
+

+
+ +
+
+
+ + +
+
+

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

+
+ +
+
+
+ +
+
+
+
+
+
+
+ + diff --git a/admin/views/rule-form.php b/admin/views/rule-form.php index 3ddf185..8083c08 100644 --- a/admin/views/rule-form.php +++ b/admin/views/rule-form.php @@ -3,52 +3,165 @@ if (!defined('ABSPATH')) { exit; } + +$current_page = sanitize_text_field($_GET['page'] ?? 'sodino-add-rule'); ?> -
-

id ? __('ویرایش قانون', 'sodino') : __('افزودن قانون جدید', 'sodino'); ?>

+
+ +
+
+
+
+
+

+

+
+
+
+
+
-
- - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
- -
- -
%
+
+
+ + - id ? __('به‌روزرسانی قانون', 'sodino') : __('افزودن قانون', 'sودino'), 'primary'); ?> - + +
+ +
+
+
+

id ? __('ویرایش قانون', 'sodino') : __('افزودن قانون جدید', 'sodino'); ?>

+

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

+
+ + +
+ + +

+
+ + +
+ + +

+
+ + +
+ + +

+
+ + +
+ + +

+
+ + +
+ + +

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

+
+ + +
+ +
+
+ + +
+ +
+
+
+
+
+
\ No newline at end of file diff --git a/admin/views/rules-list.php b/admin/views/rules-list.php index 2fdd4aa..2e20880 100644 --- a/admin/views/rules-list.php +++ b/admin/views/rules-list.php @@ -3,13 +3,81 @@ if (!defined('ABSPATH')) { exit; } + +$current_page = sanitize_text_field($_GET['page'] ?? 'sodino-rules'); ?> -
-

+
+ +
+
+
+
+
+

+

+
+
+
+
+
- +
+
+ + -
- display(); ?> -
+ +
+ +
+
+
+

+

+
+ + + + + + +
+
+ + +
+
+ display(); ?> +
+
+
+
+
\ No newline at end of file diff --git a/admin/views/settings.php b/admin/views/settings.php index 65ac7b1..928df9c 100644 --- a/admin/views/settings.php +++ b/admin/views/settings.php @@ -3,10 +3,198 @@ if (!defined('ABSPATH')) { exit; } + +$current_page = sanitize_text_field($_GET['page'] ?? 'sodino-settings'); ?> -
-

-
-

+
+ +
+
+
+
+
+

+

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

+

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

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

+

+
+
+
+ +

+
+
+ +

+
+
+ +

+
+
+
+ + +
+
+

+

+
+
+
+ +

+
+
+ + +

+
+
+
+ + +
+
+

+

+
+
+
+ + +

+
+
+ + +

+
+
+
+ + +
+
+

+

+
+
+
+ +

+
+
+ +

+
+
+ +

+
+
+
+ + +
+ +
+
+
+
diff --git a/admin/views/upsell-form.php b/admin/views/upsell-form.php new file mode 100644 index 0000000..453bf62 --- /dev/null +++ b/admin/views/upsell-form.php @@ -0,0 +1,187 @@ +trigger_type === 'product' && $upsell->trigger_value) { + $trigger_product = wc_get_product(intval($upsell->trigger_value)); + $trigger_product_label = $trigger_product ? $trigger_product->get_name() : ''; +} + +if ($upsell->target_product_id) { + $target_product = wc_get_product($upsell->target_product_id); + $target_product_label = $target_product ? $target_product->get_name() : ''; +} + +$product_categories = get_terms([ + 'taxonomy' => 'product_cat', + 'hide_empty' => false, +]); +?> +
+
+
+
+
+
+

+

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

id ? __('ویرایش آپسل', 'sodino') : __('افزودن آپسل جدید', 'sodino'); ?>

+

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

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

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ +
+ +
+
+
+
+
+
+
diff --git a/admin/views/upsell-list.php b/admin/views/upsell-list.php new file mode 100644 index 0000000..fd3d4c7 --- /dev/null +++ b/admin/views/upsell-list.php @@ -0,0 +1,78 @@ + +
+
+
+
+
+
+

+

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

+

+
+ + + + + + +
+
+ +
+
+ display(); ?> +
+
+
+
+
+
diff --git a/app/Controllers/AdminController.php b/app/Controllers/AdminController.php index 79eb9ba..013f83f 100644 --- a/app/Controllers/AdminController.php +++ b/app/Controllers/AdminController.php @@ -2,16 +2,20 @@ namespace Sodino\Controllers; use Sodino\Repositories\RuleRepository; +use Sodino\Repositories\UpsellRepository; use Sodino\Models\Rule; +use Sodino\Models\Upsell; /** * Admin Controller */ class AdminController { private $ruleRepository; + private $upsellRepository; - public function __construct(RuleRepository $ruleRepository) { + public function __construct(RuleRepository $ruleRepository, UpsellRepository $upsellRepository) { $this->ruleRepository = $ruleRepository; + $this->upsellRepository = $upsellRepository; } /** @@ -19,8 +23,8 @@ class AdminController { */ public function addMenu() { add_menu_page( - __('قیمت‌یار', 'sodino'), - __('قیمت‌یار', 'sodino'), + __('سودینو', 'sodino'), + __('سودینو', 'sodino'), 'manage_options', 'sodino-rules', [$this, 'rulesPage'], @@ -48,7 +52,43 @@ class AdminController { add_submenu_page( 'sodino-rules', - __('تنظیمات', 'sodino'), + __('آپسل (پیشنهاد فروش)', 'sodino'), + __('آپسل (پیشنهاد فروش)', 'sodino'), + 'manage_options', + 'sodino-upsells', + [$this, 'upsellsPage'] + ); + + add_submenu_page( + 'sodino-rules', + __('افزودن آپسل', 'sodino'), + __('افزودن آپسل', 'sodino'), + 'manage_options', + 'sodino-add-upsell', + [$this, 'addUpsellPage'] + ); + + add_submenu_page( + 'sodino-rules', + __('قیمت رقبا (به‌زودی)', 'sodino'), + __('قیمت رقبا (به‌زودی)', 'sodino'), + 'manage_options', + 'sodino-competitor-price', + [$this, 'competitorPricePage'] + ); + + add_submenu_page( + 'sodino-rules', + __('داشبورد سودینو', 'sودino'), + __('داشبورد سودینو', 'sodino'), + 'manage_options', + 'sodino-dashboard', + [$this, 'dashboardPage'] + ); + + add_submenu_page( + 'sodino-rules', + __('تنظیمات', 'sودino'), __('تنظیمات', 'sودینو'), 'manage_options', 'sodino-settings', @@ -63,6 +103,32 @@ class AdminController { $this->listRulesPage(); } + /** + * Dashboard page + */ + public function dashboardPage() { + $settings = $this->getSettings(); + $analyticsService = new \Sodino\Services\AnalyticsService(new \Sodino\Repositories\EventRepository(), $this->ruleRepository); + + $filters = [ + 'range' => isset($_GET['range']) ? sanitize_text_field($_GET['range']) : '7d', + 'start_date' => isset($_GET['start_date']) ? sanitize_text_field($_GET['start_date']) : '', + 'end_date' => isset($_GET['end_date']) ? sanitize_text_field($_GET['end_date']) : '', + 'product_id' => isset($_GET['product_id']) ? intval($_GET['product_id']) : 0, + 'category_id' => isset($_GET['category_id']) ? intval($_GET['category_id']) : 0, + ]; + + if (!empty($filters['product_id'])) { + $filters['product_ids'] = [$filters['product_id']]; + } + + $dashboardData = $analyticsService->getDashboardData($filters); + $productOptions = $analyticsService->getProductOptions(); + $categoryOptions = $analyticsService->getCategoryOptions(); + + include SODINO_PLUGIN_DIR . 'admin/views/dashboard.php'; + } + /** * List rules page */ @@ -95,9 +161,191 @@ class AdminController { * Settings page */ public function settingsPage() { + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $this->saveSettings(); + } + + $settings = $this->getSettings(); include SODINO_PLUGIN_DIR . 'admin/views/settings.php'; } + /** + * Upsell list page + */ + public function upsellsPage() { + $this->listUpsellsPage(); + } + + /** + * Add or edit upsell page + */ + public function addUpsellPage() { + if (isset($_GET['action']) && $_GET['action'] === 'edit') { + return $this->editUpsellPage(); + } + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $this->saveUpsell(); + } else { + $upsell = new Upsell(); + include SODINO_PLUGIN_DIR . 'admin/views/upsell-form.php'; + } + } + + /** + * Competitor price page + */ + public function competitorPricePage() { + include SODINO_PLUGIN_DIR . 'admin/views/competitor-price.php'; + } + + private function listUpsellsPage() { + require_once SODINO_PLUGIN_DIR . 'admin/class-upsell-list-table.php'; + $upsellTable = new \Sodino_Upsell_List_Table($this->upsellRepository); + $upsellTable->prepare_items(); + include SODINO_PLUGIN_DIR . 'admin/views/upsell-list.php'; + } + + private function editUpsellPage() { + $id = isset($_GET['id']) ? (int) $_GET['id'] : 0; + $upsell = $this->upsellRepository->getById($id); + + if (!$upsell) { + wp_die(__('Upsell not found', 'sodino')); + } + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $this->saveUpsell($upsell); + } else { + include SODINO_PLUGIN_DIR . 'admin/views/upsell-form.php'; + } + } + + private function saveUpsell($upsell = null) { + if (!isset($_POST['sodino_upsell_nonce']) || !wp_verify_nonce($_POST['sodino_upsell_nonce'], 'sodino_save_upsell')) { + wp_die(__('خطای امنیتی رخ داد.', 'sodino')); + } + + if (!$upsell) { + $upsell = new Upsell(); + } + + $upsell->title = sanitize_text_field($_POST['title'] ?? ''); + $upsell->trigger_type = sanitize_text_field($_POST['trigger_type'] ?? 'product'); + $upsell->trigger_value = sanitize_text_field($_POST['trigger_value'] ?? ''); + $upsell->target_product_id = max(0, intval($_POST['target_product_id'] ?? 0)); + $upsell->discount_type = sanitize_text_field($_POST['discount_type'] ?? 'percentage'); + $upsell->discount_value = max(0, floatval($_POST['discount_value'] ?? 0)); + $upsell->priority = max(1, intval($_POST['priority'] ?? 10)); + $upsell->status = isset($_POST['status']) ? 1 : 0; + + $this->upsellRepository->save($upsell); + + wp_safe_redirect(admin_url('admin.php?page=sodino-upsells')); + exit; + } + + public function handleUpsellActions() { + if (!isset($_GET['_wpnonce']) || !in_array($_GET['action'], ['delete_upsell', 'toggle_upsell_status'], true) || !wp_verify_nonce($_GET['_wpnonce'], $_GET['action'])) { + return; + } + + $id = isset($_GET['id']) ? (int) $_GET['id'] : 0; + if (!$id) { + return; + } + + if ($_GET['action'] === 'delete_upsell') { + $this->upsellRepository->delete($id); + wp_safe_redirect(admin_url('admin.php?page=sodino-upsells')); + exit; + } + + if ($_GET['action'] === 'toggle_upsell_status') { + $upsell = $this->upsellRepository->getById($id); + if ($upsell) { + $upsell->status = $upsell->status ? 0 : 1; + $this->upsellRepository->save($upsell); + } + wp_safe_redirect(admin_url('admin.php?page=sodino-upsells')); + exit; + } + } + + public function searchProductsAjax() { + if (!check_ajax_referer('sodino_search_products', 'security', false)) { + wp_send_json([]); + } + + $term = sanitize_text_field($_POST['term'] ?? ''); + if (empty($term) || !function_exists('wc_get_products')) { + wp_send_json([]); + } + + $products = wc_get_products([ + 'limit' => 10, + 'status' => 'publish', + 'search' => $term, + ]); + + $results = []; + foreach ($products as $product) { + $results[] = [ + 'id' => $product->get_id(), + 'label' => $product->get_name(), + ]; + } + + wp_send_json($results); + } + + private function getSettingsDefaults() { + return [ + 'plugin_enabled' => 1, + 'pricing_enabled' => 1, + 'upsell_enabled' => 1, + 'allow_multiple_rules' => 0, + 'strategy' => 'priority', + 'max_discount_percent' => 100, + 'min_product_price' => 0, + 'ab_testing_enabled' => 0, + 'cart_pricing_enabled' => 1, + 'scheduled_campaigns_enabled' => 1, + ]; + } + + private function getSettings() { + return wp_parse_args(get_option('sodino_settings', []), $this->getSettingsDefaults()); + } + + private function saveSettings() { + if (!current_user_can('manage_options')) { + wp_die(__('دسترسی کافی ندارید.', 'sodino')); + } + + if (!isset($_POST['sodino_settings_nonce']) || !wp_verify_nonce($_POST['sodino_settings_nonce'], 'sodino_save_settings')) { + wp_die(__('خطای امنیتی رخ داد.', 'sodino')); + } + + $settings = [ + 'plugin_enabled' => isset($_POST['plugin_enabled']) ? 1 : 0, + 'pricing_enabled' => isset($_POST['pricing_enabled']) ? 1 : 0, + 'upsell_enabled' => isset($_POST['upsell_enabled']) ? 1 : 0, + 'allow_multiple_rules' => isset($_POST['allow_multiple_rules']) ? 1 : 0, + 'strategy' => sanitize_text_field($_POST['strategy'] ?? 'priority'), + 'max_discount_percent' => max(0, min(100, floatval($_POST['max_discount_percent'] ?? 100))), + 'min_product_price' => max(0, floatval($_POST['min_product_price'] ?? 0)), + 'ab_testing_enabled' => isset($_POST['ab_testing_enabled']) ? 1 : 0, + 'cart_pricing_enabled' => isset($_POST['cart_pricing_enabled']) ? 1 : 0, + 'scheduled_campaigns_enabled' => isset($_POST['scheduled_campaigns_enabled']) ? 1 : 0, + ]; + + update_option('sodino_settings', $settings); + + wp_safe_redirect(add_query_arg('updated', 'true', admin_url('admin.php?page=sodino-settings'))); + exit; + } + /** * Edit rule page */ @@ -129,6 +377,9 @@ class AdminController { } $rule->name = sanitize_text_field($_POST['name'] ?? ''); + $rule->priority = max(1, intval($_POST['priority'] ?? 10)); + $rule->usage_limit = max(0, intval($_POST['usage_limit'] ?? 0)); + $rule->user_roles = array_map('sanitize_text_field', (array) ($_POST['user_roles'] ?? [])); $rule->condition_type = sanitize_text_field($_POST['condition_type'] ?? 'user_type'); $rule->condition_value = sanitize_text_field($_POST['condition_value'] ?? 'new'); $rule->action_type = sanitize_text_field($_POST['action_type'] ?? 'discount_percent'); diff --git a/app/Models/Rule.php b/app/Models/Rule.php index c0b9444..aa6cc91 100644 --- a/app/Models/Rule.php +++ b/app/Models/Rule.php @@ -10,6 +10,8 @@ class Rule { public $conditions; public $actions; public $priority; + public $usage_limit; + public $user_roles; public $start_date; public $end_date; public $enabled; @@ -29,6 +31,8 @@ class Rule { $this->conditions = $this->parseJsonField($data['conditions'] ?? '[]'); $this->actions = $this->parseJsonField($data['actions'] ?? '[]'); $this->priority = isset($data['priority']) ? (int) $data['priority'] : 10; + $this->usage_limit = isset($data['usage_limit']) ? (int) $data['usage_limit'] : 0; + $this->user_roles = $this->parseRolesField($data['user_roles'] ?? ''); $this->start_date = $data['start_date'] ?? null; $this->end_date = $data['end_date'] ?? null; $this->enabled = isset($data['enabled']) ? (int) $data['enabled'] : 1; @@ -61,6 +65,18 @@ class Rule { return is_array($decoded) ? $decoded : []; } + private function parseRolesField($value) { + if (is_array($value)) { + return array_filter(array_map('trim', $value)); + } + + if (!is_string($value)) { + return []; + } + + return array_filter(array_map('trim', explode(',', $value))); + } + /** * Convert to array */ @@ -71,6 +87,8 @@ class Rule { 'conditions' => wp_json_encode($this->conditions), 'actions' => wp_json_encode($this->actions), 'priority' => $this->priority, + 'usage_limit' => $this->usage_limit, + 'user_roles' => is_array($this->user_roles) ? implode(',', $this->user_roles) : $this->user_roles, 'start_date' => $this->start_date, 'end_date' => $this->end_date, 'enabled' => $this->enabled, diff --git a/app/Models/Upsell.php b/app/Models/Upsell.php new file mode 100644 index 0000000..7b82683 --- /dev/null +++ b/app/Models/Upsell.php @@ -0,0 +1,53 @@ +id = isset($data['id']) ? (int) $data['id'] : null; + $this->title = $data['title'] ?? ''; + $this->trigger_type = $data['trigger_type'] ?? 'product'; + $this->trigger_value = isset($data['trigger_value']) ? (string) $data['trigger_value'] : ''; + $this->target_product_id = isset($data['target_product_id']) ? (int) $data['target_product_id'] : 0; + $this->discount_type = $data['discount_type'] ?? 'percentage'; + $this->discount_value = isset($data['discount_value']) ? floatval($data['discount_value']) : 0; + $this->status = isset($data['status']) ? (int) $data['status'] : 1; + $this->priority = isset($data['priority']) ? (int) $data['priority'] : 10; + $this->created_at = $data['created_at'] ?? null; + $this->updated_at = $data['updated_at'] ?? null; + } + + public function isActive() { + return (bool) $this->status; + } + + public function toArray() { + return [ + 'id' => $this->id, + 'title' => sanitize_text_field($this->title), + 'trigger_type' => sanitize_text_field($this->trigger_type), + 'trigger_value' => sanitize_text_field($this->trigger_value), + 'target_product_id' => $this->target_product_id, + 'discount_type' => sanitize_text_field($this->discount_type), + 'discount_value' => floatval($this->discount_value), + 'status' => $this->status, + 'priority' => $this->priority, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Repositories/EventRepository.php b/app/Repositories/EventRepository.php new file mode 100644 index 0000000..cc0bf0b --- /dev/null +++ b/app/Repositories/EventRepository.php @@ -0,0 +1,98 @@ +table_name = $wpdb->prefix . 'sodino_events'; + } + + public function insert(array $data) { + global $wpdb; + return $wpdb->insert($this->table_name, $data); + } + + public function getEvents(array $filters = []) { + global $wpdb; + $params = []; + $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); + } + + public function getCount(array $filters = []) { + global $wpdb; + $params = []; + $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)); + } + + public function getSum($field, array $filters = []) { + global $wpdb; + if (!in_array($field, ['value', 'discount_value'], true)) { + return 0; + } + + $params = []; + $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))); + } + + public function getRuleUsageCount($rule_id) { + global $wpdb; + return (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$this->table_name} WHERE event_type = %s AND rule_id = %d", + 'discount_applied', + $rule_id + )); + } + + private function buildWhereClauses(array $filters, array &$params) { + $where = ['1=1']; + + if (!empty($filters['event_type'])) { + if (is_array($filters['event_type'])) { + $placeholders = implode(', ', array_fill(0, count($filters['event_type']), '%s')); + $where[] = "event_type IN ($placeholders)"; + $params = array_merge($params, $filters['event_type']); + } else { + $where[] = 'event_type = %s'; + $params[] = $filters['event_type']; + } + } + + if (!empty($filters['product_ids'])) { + $ids = array_map('intval', (array) $filters['product_ids']); + $placeholders = implode(', ', array_fill(0, count($ids), '%d')); + $where[] = "product_id IN ($placeholders)"; + $params = array_merge($params, $ids); + } + + if (!empty($filters['rule_id'])) { + $where[] = 'rule_id = %d'; + $params[] = intval($filters['rule_id']); + } + + if (!empty($filters['from'])) { + $where[] = 'created_at >= %s'; + $params[] = $filters['from']; + } + + if (!empty($filters['to'])) { + $where[] = 'created_at <= %s'; + $params[] = $filters['to']; + } + + return $where; + } +} diff --git a/app/Repositories/UpsellRepository.php b/app/Repositories/UpsellRepository.php new file mode 100644 index 0000000..273ed9a --- /dev/null +++ b/app/Repositories/UpsellRepository.php @@ -0,0 +1,61 @@ +table_name = $wpdb->prefix . 'sodino_upsells'; + } + + public function getAll() { + global $wpdb; + $results = $wpdb->get_results("SELECT * FROM {$this->table_name} ORDER BY priority DESC, id ASC", ARRAY_A); + $items = []; + foreach ($results as $result) { + $items[] = new Upsell($result); + } + return $items; + } + + public function getById($id) { + global $wpdb; + $result = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table_name} WHERE id = %d", $id), ARRAY_A); + return $result ? new Upsell($result) : null; + } + + public function getActive() { + global $wpdb; + $results = $wpdb->get_results("SELECT * FROM {$this->table_name} WHERE status = 1 ORDER BY priority DESC, id ASC", ARRAY_A); + $items = []; + foreach ($results as $result) { + $items[] = new Upsell($result); + } + return $items; + } + + public function save(Upsell $upsell) { + global $wpdb; + $data = $upsell->toArray(); + unset($data['id'], $data['created_at'], $data['updated_at']); + + if ($upsell->id) { + $wpdb->update($this->table_name, $data, ['id' => $upsell->id]); + return $upsell->id; + } + + $wpdb->insert($this->table_name, $data); + return $wpdb->insert_id; + } + + public function delete($id) { + global $wpdb; + return $wpdb->delete($this->table_name, ['id' => $id]); + } +} diff --git a/app/Services/AnalyticsService.php b/app/Services/AnalyticsService.php new file mode 100644 index 0000000..c33f2b4 --- /dev/null +++ b/app/Services/AnalyticsService.php @@ -0,0 +1,281 @@ +eventRepository = $eventRepository; + $this->ruleRepository = $ruleRepository; + } + + public function primeCache() { + $cache_keys = [ + 'sodino_dashboard_summary', + 'sodino_dashboard_sales_chart', + 'sodino_dashboard_rule_performance', + 'sodino_dashboard_user_behavior', + ]; + + foreach ($cache_keys as $key) { + delete_transient($key); + } + } + + public function getDashboardData(array $filters = []) { + $cache_key = 'sodino_dashboard_' . md5(wp_json_encode($filters)); + $cached = get_transient($cache_key); + if ($cached !== false) { + return $cached; + } + + $range = $this->getDateRange($filters['range'] ?? '7d', $filters['start_date'] ?? '', $filters['end_date'] ?? ''); + $filters['from'] = $range['start']; + $filters['to'] = $range['end']; + + if (!empty($filters['category_id'])) { + $filters['product_ids'] = $this->getProductIdsByCategory($filters['category_id']); + } + + $summary = $this->getSummary($filters); + $salesChart = $this->getSalesChart($filters); + $rulePerformance = $this->getRulePerformance($filters); + $userBehavior = $this->getUserBehavior($filters); + $insights = $this->getInsights($summary, $filters); + + $result = [ + 'summary' => $summary, + 'sales_chart' => $salesChart, + 'rule_performance' => $rulePerformance, + 'user_behavior' => $userBehavior, + 'insights' => $insights, + ]; + + set_transient($cache_key, $result, 10 * MINUTE_IN_SECONDS); + return $result; + } + + public function getSummary(array $filters = []) { + $purchaseFilters = array_merge($filters, ['event_type' => 'purchase']); + $discountFilters = array_merge($filters, ['event_type' => 'discount_applied']); + + $purchaseCount = $this->eventRepository->getCount($purchaseFilters); + $totalRevenue = $this->eventRepository->getSum('value', $purchaseFilters); + $totalDiscount = $this->eventRepository->getSum('discount_value', $discountFilters); + + $productViewCount = $this->eventRepository->getCount(array_merge($filters, ['event_type' => 'product_view'])); + $addToCartCount = $this->eventRepository->getCount(array_merge($filters, ['event_type' => 'add_to_cart'])); + $checkoutStartCount = $this->eventRepository->getCount(array_merge($filters, ['event_type' => 'checkout_start'])); + + $conversionRate = 0; + if ($checkoutStartCount > 0) { + $conversionRate = round(($purchaseCount / $checkoutStartCount) * 100, 2); + } elseif ($addToCartCount > 0) { + $conversionRate = round(($purchaseCount / $addToCartCount) * 100, 2); + } + + $addToCartRate = 0; + if ($productViewCount > 0) { + $addToCartRate = round(($addToCartCount / $productViewCount) * 100, 2); + } + + $bestRule = $this->getBestRule($filters); + + return [ + 'total_revenue' => $totalRevenue, + 'total_discount' => $totalDiscount, + 'purchase_count' => $purchaseCount, + 'conversion_rate' => $conversionRate, + 'add_to_cart_rate' => $addToCartRate, + 'best_rule' => $bestRule, + ]; + } + + public function getSalesChart(array $filters = []) { + $filters = array_merge($filters, $this->getDateRange($filters['range'] ?? '7d', $filters['start_date'] ?? '', $filters['end_date'] ?? '')); + + if (!empty($filters['category_id'])) { + $filters['product_ids'] = $this->getProductIdsByCategory($filters['category_id']); + } + + $start = new \DateTime($filters['start']); + $end = new \DateTime($filters['end']); + $days = []; + $series = [ 'before' => [], 'after' => [], 'labels' => [] ]; + + while ($start <= $end) { + $date = $start->format('Y-m-d'); + $series['labels'][] = $date; + $dayFilters = array_merge($filters, ['from' => $date . ' 00:00:00', 'to' => $date . ' 23:59:59']); + $purchases = $this->eventRepository->getEvents(array_merge($dayFilters, ['event_type' => 'purchase'])); + $dayRevenue = 0; + $dayDiscount = 0; + foreach ($purchases as $purchase) { + $dayRevenue += floatval($purchase['value']); + } + $discountEvents = $this->eventRepository->getEvents(array_merge($dayFilters, ['event_type' => 'discount_applied'])); + foreach ($discountEvents as $discount) { + $dayDiscount += floatval($discount['discount_value']); + } + $series['after'][] = round($dayRevenue, 2); + $series['before'][] = round($dayRevenue + $dayDiscount, 2); + $start->modify('+1 day'); + } + + return $series; + } + + public function getRulePerformance(array $filters = []) { + $rules = $this->ruleRepository->getAll(); + $result = []; + + foreach ($rules as $rule) { + $ruleFilters = array_merge($filters, ['event_type' => 'discount_applied', 'rule_id' => $rule->id]); + $count = $this->eventRepository->getCount($ruleFilters); + $revenue = $this->eventRepository->getSum('value', $ruleFilters); + $totalDiscount = $this->eventRepository->getSum('discount_value', $ruleFilters); + + if ($count > 0) { + $result[] = [ + 'name' => $rule->name, + 'count' => $count, + 'revenue' => round($revenue, 2), + 'discount' => round($totalDiscount, 2), + ]; + } + } + + return $result; + } + + 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'])); + $checkoutStartCount = $this->eventRepository->getCount(array_merge($filters, ['event_type' => 'checkout_start'])); + $purchaseCount = $this->eventRepository->getCount(array_merge($filters, ['event_type' => 'purchase'])); + + return [ + 'product_views' => $productViewCount, + 'add_to_cart' => $addToCartCount, + 'checkout_start' => $checkoutStartCount, + 'purchases' => $purchaseCount, + ]; + } + + private function getBestRule(array $filters = []) { + $performance = $this->getRulePerformance($filters); + if (empty($performance)) { + return null; + } + + usort($performance, function ($a, $b) { + return $b['revenue'] <=> $a['revenue']; + }); + + return $performance[0]['name'] ?? null; + } + + private function getInsights(array $summary, array $filters = []) { + $insights = []; + + if (!empty($summary['best_rule'])) { + $insights[] = sprintf('%s %s', __('قانون برتر:', 'sodino'), esc_html($summary['best_rule'])); + } + + if ($summary['total_discount'] > 0 && $summary['total_revenue'] > 0) { + $discountShare = round(($summary['total_discount'] / ($summary['total_revenue'] + $summary['total_discount'])) * 100, 2); + $insights[] = sprintf( + '%s %s%% %s', + __('تخفیف‌ها باعث ایجاد', 'sodino'), + esc_html($discountShare), + __('درصد از درآمد شده‌اند.', 'sodino') + ); + } + + if ($summary['conversion_rate'] > 0) { + $insights[] = sprintf('%s %s%% %s', __('نرخ تبدیل تقریبی', 'sodino'), esc_html($summary['conversion_rate']), __('است.', 'sodino')); + } + + if (empty($insights)) { + $insights[] = __('هیچ دادهٔ قابل تحلیلی هنوز ثبت نشده است.', 'sodino'); + } + + return $insights; + } + + private function getDateRange($range, $start, $end) { + $result = [ + 'start' => date('Y-m-d', strtotime('-6 days')), + 'end' => date('Y-m-d'), + ]; + + if ($range === '30d') { + $result['start'] = date('Y-m-d', strtotime('-29 days')); + $result['end'] = date('Y-m-d'); + } + + if ($range === 'custom' && !empty($start) && !empty($end)) { + $result['start'] = date('Y-m-d', strtotime($start)); + $result['end'] = date('Y-m-d', strtotime($end)); + } + + return $result; + } + + public function getProductIdsByCategory($category_id) { + $products = get_posts([ + 'post_type' => 'product', + 'numberposts' => -1, + 'fields' => 'ids', + 'tax_query' => [ + [ + 'taxonomy' => 'product_cat', + 'field' => 'term_id', + 'terms' => intval($category_id), + ], + ], + ]); + + return $products ?: []; + } + + public function getProductOptions() { + $products = get_posts([ + 'post_type' => 'product', + 'numberposts' => -1, + 'fields' => 'ids', + 'orderby' => 'title', + 'order' => 'ASC', + ]); + + $options = []; + foreach ($products as $product_id) { + $options[] = [ + 'id' => $product_id, + 'name' => get_the_title($product_id), + ]; + } + + return $options; + } + + public function getCategoryOptions() { + $categories = get_terms(['taxonomy' => 'product_cat', 'hide_empty' => false]); + $options = []; + + if (!is_wp_error($categories)) { + foreach ($categories as $category) { + $options[] = [ + 'id' => $category->term_id, + 'name' => $category->name, + ]; + } + } + + return $options; + } +} diff --git a/app/Services/PricingService.php b/app/Services/PricingService.php index f7a525c..782dfcf 100644 --- a/app/Services/PricingService.php +++ b/app/Services/PricingService.php @@ -2,54 +2,98 @@ namespace Sodino\Services; use Sodino\Repositories\RuleRepository; +use Sodino\Services\TrackingService; -/** - * Pricing Service - */ class PricingService { private $ruleRepository; + private $trackingService; private $rulesCache = null; - private $freeShipping = false; - public function __construct(RuleRepository $ruleRepository) { + public function __construct(RuleRepository $ruleRepository, TrackingService $trackingService) { $this->ruleRepository = $ruleRepository; + $this->trackingService = $trackingService; } - /** - * Apply dynamic pricing to a product price - */ public function applyDynamicPricing($price, $product) { + $settings = $this->getSettings(); + if (empty($settings['plugin_enabled']) || empty($settings['pricing_enabled'])) { + return $price; + } + + if (!$price || !is_numeric($price)) { + return $price; + } + $price = $this->normalizePrice($price); + if (!$settings['cart_pricing_enabled'] && is_cart()) { + return $price; + } + + $originalPrice = $price; $rules = $this->getEnabledRules(); - $matchedRule = null; + $matchedRules = []; foreach ($rules as $rule) { if ($this->ruleMatches($rule, $product)) { - if ($matchedRule === null || $rule->priority > $matchedRule->priority) { - $matchedRule = $rule; - } + $matchedRules[] = $rule; } } - if ($matchedRule) { - $price = $this->applyActions($matchedRule, $price); + if (empty($matchedRules)) { + return $price; } + if (!$settings['allow_multiple_rules']) { + $chosenRule = $this->chooseRule($matchedRules, $price, $settings['strategy']); + $matchedRules = $chosenRule ? [$chosenRule] : []; + } + + foreach ($matchedRules as $rule) { + $oldPrice = $price; + $price = $this->applyActions($rule, $price); + if ($price < $oldPrice) { + $this->trackingService->recordDiscountApplied($product, $oldPrice, $price, $rule->id); + } + } + + $price = $this->enforceLimits($originalPrice, $price, $settings); + return max(0, $price); } - public function shouldApplyFreeShipping() { - $rules = $this->getEnabledRules(); - foreach ($rules as $rule) { - if ($this->ruleMatches($rule, null) && $this->ruleHasFreeShipping($rule)) { - return true; - } + private function chooseRule(array $rules, $price, $strategy) { + if ($strategy === 'highest_discount') { + usort($rules, function ($a, $b) use ($price) { + return $this->estimateRuleDiscount($b, $price) <=> $this->estimateRuleDiscount($a, $price); + }); + return $rules[0] ?? null; } - return false; + + if ($strategy === 'first_valid') { + return $rules[0] ?? null; + } + + usort($rules, function ($a, $b) { + return $b->priority <=> $a->priority; + }); + return $rules[0] ?? null; } - public function resetFreeShippingFlag() { - $this->freeShipping = false; + private function getSettings() { + $defaults = [ + 'plugin_enabled' => 1, + 'pricing_enabled' => 1, + 'upsell_enabled' => 1, + 'allow_multiple_rules' => 0, + 'strategy' => 'priority', + 'max_discount_percent' => 100, + 'min_product_price' => 0, + 'ab_testing_enabled' => 0, + 'cart_pricing_enabled' => 1, + 'scheduled_campaigns_enabled' => 1, + ]; + + return wp_parse_args(get_option('sodino_settings', []), $defaults); } private function getEnabledRules() { @@ -67,22 +111,29 @@ class PricingService { return floatval($price); } - private function getUserType() { - if (!is_user_logged_in()) { - return 'guest'; + private function ruleMatches($rule, $product = null) { + if (!$rule->enabled) { + return false; } - $user_id = get_current_user_id(); - $order_count = wc_get_customer_order_count($user_id); + if ($rule->usage_limit > 0 && $this->trackingService->getRuleUsageCount($rule->id) >= $rule->usage_limit) { + return false; + } - return $order_count > 0 ? 'returning' : 'new'; - } + if (!empty($rule->user_roles) && is_array($rule->user_roles)) { + if (!$this->userHasAllowedRole($rule->user_roles)) { + return false; + } + } - private function ruleMatches($rule, $product = null) { if (!$this->isRuleActive($rule)) { return false; } + if (empty($rule->conditions)) { + return true; + } + foreach ($rule->conditions as $condition) { if (!$this->evaluateCondition($condition, $product)) { return false; @@ -93,10 +144,6 @@ class PricingService { } private function isRuleActive($rule) { - if (!$rule->enabled) { - return false; - } - $now = current_time('Y-m-d H:i:s'); if (!empty($rule->start_date) && $now < $rule->start_date) { @@ -134,6 +181,32 @@ class PricingService { } } + private function getUserType() { + if (!is_user_logged_in()) { + return 'guest'; + } + + $user_id = get_current_user_id(); + $order_count = wc_get_customer_order_count($user_id); + + return $order_count > 0 ? 'returning' : 'new'; + } + + private function userHasAllowedRole($roles) { + if (!is_user_logged_in()) { + return false; + } + + $user = wp_get_current_user(); + foreach ($roles as $role) { + if (in_array($role, $user->roles, true)) { + return true; + } + } + + return false; + } + private function getCartTotal() { if (!WC()->cart) { return 0; @@ -170,20 +243,9 @@ class PricingService { private function applyActions($rule, $price) { foreach ($rule->actions as $action) { $price = $this->applyAction($action, $price); - if (($action['type'] ?? '') === 'free_shipping') { - $this->freeShipping = true; - } } - return $price; - } - private function ruleHasFreeShipping($rule) { - foreach ($rule->actions as $action) { - if (($action['type'] ?? '') === 'free_shipping') { - return true; - } - } - return false; + return $price; } private function applyAction($action, $price) { @@ -207,4 +269,29 @@ class PricingService { return $price; } } + + private function enforceLimits($originalPrice, $price, array $settings) { + $minPrice = max(0, floatval($settings['min_product_price'])); + $price = max($price, $minPrice); + + $maxDiscountPercent = floatval($settings['max_discount_percent']); + if ($maxDiscountPercent > 0 && $maxDiscountPercent < 100) { + $limit = $originalPrice * ($maxDiscountPercent / 100); + $price = max($originalPrice - $limit, $price); + } + + return $price; + } + + private function estimateRuleDiscount($rule, $price) { + foreach ($rule->actions as $action) { + if (($action['type'] ?? '') === 'discount_percent') { + return $price * floatval($action['value']) / 100; + } + if (($action['type'] ?? '') === 'discount_fixed') { + return floatval($action['value']); + } + } + return 0; + } } diff --git a/app/Services/TrackingService.php b/app/Services/TrackingService.php new file mode 100644 index 0000000..87121bb --- /dev/null +++ b/app/Services/TrackingService.php @@ -0,0 +1,137 @@ +eventRepository = $eventRepository; + } + + public function trackProductView($product_id) { + if (!$product_id) { + return; + } + + $session_key = 'sodino_viewed_' . intval($product_id); + if ($this->hasLogged($session_key)) { + return; + } + + $this->logEvent('product_view', [ + 'product_id' => $product_id, + ]); + $this->markLogged($session_key); + } + + public function trackAddToCart($product_id, $variation_id = null, $quantity = 1) { + if (!$product_id) { + return; + } + + $this->logEvent('add_to_cart', [ + 'product_id' => $product_id, + 'variation_id' => $variation_id, + 'value' => floatval($quantity), + ]); + } + + public function trackCheckoutStart() { + $this->logEvent('checkout_start', []); + } + + public function trackPurchase($order_id) { + if (!$order_id) { + return; + } + + $order = wc_get_order($order_id); + if (!$order) { + return; + } + + $total = floatval($order->get_total()); + $discount = 0; + if (method_exists($order, 'get_total_discount')) { + $discount = floatval($order->get_total_discount()); + } else { + foreach ($order->get_items() as $item) { + $discount += floatval($item->get_subtotal()) - floatval($item->get_total()); + } + } + + $this->logEvent('purchase', [ + 'value' => $total, + 'discount_value' => max(0, $discount), + ]); + } + + public function recordDiscountApplied($product, $original_price, $discounted_price, $rule_id = null) { + if (!$product || $original_price <= 0 || $discounted_price >= $original_price) { + return; + } + + $product_id = $product->get_id(); + $variation_id = $product->is_type('variation') ? $product_id : 0; + $discount_value = round($original_price - $discounted_price, 2); + + $key = 'discount_applied_' . $product_id . '_' . $rule_id . '_' . $discount_value; + if ($this->hasLogged($key)) { + return; + } + + $this->logEvent('discount_applied', [ + 'product_id' => $product_id, + 'variation_id' => $variation_id, + 'rule_id' => $rule_id, + 'value' => $discounted_price, + 'discount_value' => $discount_value, + ]); + $this->markLogged($key); + } + + public function getRuleUsageCount($rule_id) { + return $this->eventRepository->getRuleUsageCount($rule_id); + } + + private function logEvent($type, array $data = []) { + $event = [ + 'event_type' => $type, + 'product_id' => isset($data['product_id']) ? intval($data['product_id']) : null, + 'variation_id' => isset($data['variation_id']) ? intval($data['variation_id']) : null, + 'user_id' => get_current_user_id() ?: null, + 'session_id' => $this->getSessionId(), + 'rule_id' => isset($data['rule_id']) ? intval($data['rule_id']) : null, + 'value' => isset($data['value']) ? floatval($data['value']) : 0, + 'discount_value' => isset($data['discount_value']) ? floatval($data['discount_value']) : 0, + 'metadata' => isset($data['metadata']) ? wp_json_encode($data['metadata']) : null, + 'created_at' => current_time('mysql'), + ]; + + $this->eventRepository->insert($event); + } + + private function getSessionId() { + if (function_exists('WC') && WC()->session) { + $session_id = WC()->session->get('sodino_session_id'); + if (!$session_id) { + $session_id = uniqid('sodino_', true); + WC()->session->set('sodino_session_id', $session_id); + } + return $session_id; + } + + return 'guest_' . md5($_SERVER['REMOTE_ADDR'] . '|' . $_SERVER['HTTP_USER_AGENT']); + } + + private function hasLogged($key) { + return isset($this->loggedEvents[$key]); + } + + private function markLogged($key) { + $this->loggedEvents[$key] = true; + } +} diff --git a/app/Services/UpsellService.php b/app/Services/UpsellService.php new file mode 100644 index 0000000..88ea39d --- /dev/null +++ b/app/Services/UpsellService.php @@ -0,0 +1,149 @@ +upsellRepository = $upsellRepository; + } + + public function getActiveUpsells() { + if ($this->cache === null) { + $this->cache = $this->upsellRepository->getActive(); + } + return $this->cache; + } + + public function getMatchingUpsells($cart) { + if (!$cart || $cart->is_empty()) { + return []; + } + + $matches = []; + foreach ($this->getActiveUpsells() as $upsell) { + if ($this->cartMatchesTrigger($upsell, $cart) && !$this->isProductAlreadyInCart($cart, $upsell->target_product_id)) { + $matches[] = $upsell; + } + } + + usort($matches, function ($a, $b) { + return $b->priority <=> $a->priority; + }); + + return $matches; + } + + public function applyUpsellDiscount($product, $upsell) { + if (!$product || !$upsell) { + return 0; + } + + $price = floatval($product->get_price()); + if ($upsell->discount_type === 'percentage') { + return max(0, $price * (1 - floatval($upsell->discount_value) / 100)); + } + + if ($upsell->discount_type === 'fixed') { + return max(0, $price - floatval($upsell->discount_value)); + } + + return $price; + } + + public function getTriggerLabel($upsell) { + switch ($upsell->trigger_type) { + case 'product': + return __('محصول خاص', 'sodino'); + case 'category': + return __('دسته‌بندی', 'sodino'); + case 'cart_total': + return __('مبلغ سبد خرید', 'sodino'); + default: + return __('نامشخص', 'sodino'); + } + } + + public function getDiscountLabel($upsell) { + if ($upsell->discount_type === 'fixed') { + return sprintf('%s تومان', number_format_i18n($upsell->discount_value)); + } + + if ($upsell->discount_type === 'percentage') { + return sprintf('%s %%', esc_html($upsell->discount_value)); + } + + return __('بدون تخفیف', 'sodino'); + } + + private function cartMatchesTrigger($upsell, $cart) { + if (!$upsell->isActive()) { + return false; + } + + $triggerType = $upsell->trigger_type; + $triggerValue = $upsell->trigger_value; + + switch ($triggerType) { + case 'product': + return $this->cartContainsProduct($cart, intval($triggerValue)); + case 'category': + return $this->cartContainsCategory($cart, intval($triggerValue)); + case 'cart_total': + return floatval($cart->get_cart_contents_total()) >= floatval($triggerValue); + default: + return false; + } + } + + private function cartContainsProduct($cart, $productId) { + if (!$productId) { + return false; + } + + foreach ($cart->get_cart() as $cartItem) { + if ((int) $cartItem['product_id'] === $productId || (int) $cartItem['variation_id'] === $productId) { + return true; + } + } + + return false; + } + + private function cartContainsCategory($cart, $categoryId) { + if (!$categoryId) { + return false; + } + + foreach ($cart->get_cart() as $cartItem) { + $product = wc_get_product($cartItem['product_id']); + if (!$product) { + continue; + } + + $terms = wp_get_post_terms($product->get_id(), 'product_cat', ['fields' => 'ids']); + if (is_array($terms) && in_array($categoryId, $terms, true)) { + return true; + } + } + + return false; + } + + private function isProductAlreadyInCart($cart, $productId) { + if (!$productId) { + return false; + } + + foreach ($cart->get_cart() as $cartItem) { + if ((int) $cartItem['product_id'] === $productId || (int) $cartItem['variation_id'] === $productId) { + return true; + } + } + + return false; + } +} diff --git a/database/migrations.php b/database/migrations.php index 1c3e7aa..ac0d0ac 100644 --- a/database/migrations.php +++ b/database/migrations.php @@ -21,6 +21,8 @@ function sodino_create_tables() { conditions longtext NOT NULL, actions longtext NOT NULL, priority int(11) NOT NULL DEFAULT 10, + usage_limit int(11) NOT NULL DEFAULT 0, + user_roles varchar(255) DEFAULT '', start_date datetime NULL, end_date datetime NULL, enabled tinyint(1) DEFAULT 1, @@ -33,16 +35,34 @@ function sodino_create_tables() { PRIMARY KEY (id) ) $charset_collate;"; + // Events table + $events_table = $wpdb->prefix . 'sodino_events'; + $events_sql = "CREATE TABLE $events_table ( + id mediumint(9) NOT NULL AUTO_INCREMENT, + event_type varchar(100) NOT NULL, + product_id mediumint(9) DEFAULT NULL, + variation_id mediumint(9) DEFAULT NULL, + user_id bigint(20) DEFAULT NULL, + session_id varchar(255) DEFAULT NULL, + rule_id mediumint(9) DEFAULT NULL, + value decimal(10,2) DEFAULT 0, + discount_value decimal(10,2) DEFAULT 0, + metadata longtext DEFAULT NULL, + created_at datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) + ) $charset_collate;"; + // Upsell table $upsell_table = $wpdb->prefix . 'sodino_upsells'; $upsell_sql = "CREATE TABLE $upsell_table ( id mediumint(9) NOT NULL AUTO_INCREMENT, - name varchar(255) NOT NULL, - triggers longtext NOT NULL, - suggestions longtext NOT NULL, + title varchar(255) NOT NULL, + trigger_type varchar(50) NOT NULL, + trigger_value varchar(255) NOT NULL, + target_product_id bigint(20) NOT NULL DEFAULT 0, discount_type varchar(50) DEFAULT 'percentage', - discount_value varchar(50) DEFAULT '0', - enabled tinyint(1) DEFAULT 1, + discount_value decimal(10,2) DEFAULT 0, + status tinyint(1) DEFAULT 1, priority int(11) NOT NULL DEFAULT 10, created_at datetime DEFAULT CURRENT_TIMESTAMP, updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -51,8 +71,9 @@ function sodino_create_tables() { require_once(ABSPATH . 'wp-admin/includes/upgrade.php'); dbDelta($rules_sql); + dbDelta($events_sql); dbDelta($upsell_sql); // Add version option - add_option('sodino_db_version', '1.1'); + add_option('sodino_db_version', '1.2'); } \ No newline at end of file diff --git a/public/hooks/analytics-hooks.php b/public/hooks/analytics-hooks.php new file mode 100644 index 0000000..b1e4dff --- /dev/null +++ b/public/hooks/analytics-hooks.php @@ -0,0 +1,53 @@ +trackProductView($product_id); +} + +function sodino_track_add_to_cart($cart_item_key, $product_id, $quantity, $variation_id, $variation, $cart_item_data) { + sodino_get_tracking_service()->trackAddToCart($product_id, $variation_id, $quantity); +} + +function sodino_track_checkout_start() { + if (is_admin()) { + return; + } + + sodino_get_tracking_service()->trackCheckoutStart(); +} + +function sodino_track_purchase($order_id) { + sodino_get_tracking_service()->trackPurchase($order_id); +} diff --git a/public/hooks/pricing-hooks.php b/public/hooks/pricing-hooks.php index 2522de5..99bf896 100644 --- a/public/hooks/pricing-hooks.php +++ b/public/hooks/pricing-hooks.php @@ -5,12 +5,16 @@ if (!defined('ABSPATH')) { } use Sodino\Services\PricingService; +use Sodino\Services\TrackingService; use Sodino\Repositories\RuleRepository; +use Sodino\Repositories\EventRepository; // Initialize pricing service global $sodino_pricing_service; $ruleRepository = new RuleRepository(); -$sodino_pricing_service = new PricingService($ruleRepository); +$eventRepository = new EventRepository(); +$trackingService = new TrackingService($eventRepository); +$sodino_pricing_service = new PricingService($ruleRepository, $trackingService); // Hook into WooCommerce price filter add_filter('woocommerce_product_get_price', 'sodino_apply_dynamic_pricing', 10, 2); diff --git a/public/hooks/upsell-hooks.php b/public/hooks/upsell-hooks.php new file mode 100644 index 0000000..3766700 --- /dev/null +++ b/public/hooks/upsell-hooks.php @@ -0,0 +1,91 @@ + 1, + ]); + + if (empty($settings['upsell_enabled'])) { + return; + } + + global $sodino_upsell_service; + if (!isset($sodino_upsell_service)) { + return; + } + + $cart = WC()->cart; + if (!$cart || $cart->is_empty()) { + return; + } + + $upsells = $sodino_upsell_service->getMatchingUpsells($cart); + if (empty($upsells)) { + return; + } + + echo '
'; + echo '
'; + echo '
'; + echo '

' . esc_html__('پیشنهاد ویژه آپسل', 'sodino') . '

'; + echo '

' . esc_html__('این محصول را همراه خرید خود با تخفیف ویژه دریافت کنید', 'sodino') . '

'; + echo '
'; + echo '' . count($upsells) . ' ' . esc_html__('پیشنهاد فعال', 'sodino') . ''; + echo '
'; + echo '
'; + + foreach ($upsells as $upsell) { + $product = wc_get_product($upsell->target_product_id); + if (!$product) { + continue; + } + + $discountedPrice = $sodino_upsell_service->applyUpsellDiscount($product, $upsell); + $originalPrice = floatval($product->get_price()); + $priceHtml = wc_price($discountedPrice); + if ($discountedPrice < $originalPrice) { + $priceHtml .= ' ' . wc_price($originalPrice) . ''; + } + + $addToCartUrl = esc_url(add_query_arg('add-to-cart', $product->get_id(), wc_get_cart_url())); + $image = $product->get_image('woocommerce_thumbnail', ['class' => 'h-20 w-20 rounded-xl object-cover']); + + echo '
'; + echo '
'; + echo '
' . $image . '
'; + echo '
'; + echo '

' . esc_html($upsell->title) . '

'; + echo '

' . esc_html($product->get_name()) . '

'; + echo '
'; + echo '' . esc_html($sodino_upsell_service->getDiscountLabel($upsell)) . ''; + echo '' . esc_html($sodino_upsell_service->getTriggerLabel($upsell)) . ''; + echo '
'; + echo '
'; + echo '
'; + echo '
'; + echo '
' . $priceHtml . '
'; + echo '' . esc_html__('افزودن به سبد', 'sodino') . ''; + echo '
'; + echo '
'; + } + + echo '
'; + echo '
'; +} diff --git a/sodino.php b/sodino.php index 5e684d7..4be1f07 100644 --- a/sodino.php +++ b/sodino.php @@ -67,6 +67,9 @@ register_deactivation_hook(__FILE__, 'sodino_deactivate'); function sodino_deactivate() { // Flush rewrite rules flush_rewrite_rules(); + + // Clear analytics cron + wp_clear_scheduled_hook('sodino_hourly_analytics'); } // Bootstrap the plugin @@ -84,12 +87,41 @@ function sodino_init() { // Initialize public hooks require_once SODINO_PLUGIN_DIR . 'public/hooks/pricing-hooks.php'; + require_once SODINO_PLUGIN_DIR . 'public/hooks/analytics-hooks.php'; + require_once SODINO_PLUGIN_DIR . 'public/hooks/upsell-hooks.php'; + + // Schedule analytics aggregation if needed + sodino_schedule_analytics(); // Load text domain load_plugin_textdomain('sodino', false, dirname(SODINO_PLUGIN_BASENAME) . '/languages/'); } add_action('plugins_loaded', 'sodino_init'); +/** + * Schedule analytics cron job + */ +function sodino_schedule_analytics() { + if (!wp_next_scheduled('sodino_hourly_analytics')) { + wp_schedule_event(time(), 'hourly', 'sodino_hourly_analytics'); + } +} + +/** + * Run analytics aggregation cron job + */ +function sodino_run_analytics_aggregation() { + if (!class_exists('Sodino\Services\AnalyticsService')) { + return; + } + + $eventRepository = new Sodino\Repositories\EventRepository(); + $ruleRepository = new Sodino\Repositories\RuleRepository(); + $analyticsService = new Sodino\Services\AnalyticsService($eventRepository, $ruleRepository); + $analyticsService->primeCache(); +} +add_action('sodino_hourly_analytics', 'sodino_run_analytics_aggregation'); + // WooCommerce missing notice function sodino_woocommerce_missing_notice() { ?>