-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
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 '
';
+ }
+
+ 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() {
?>