diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76cfe45 --- /dev/null +++ b/.gitignore @@ -0,0 +1,71 @@ +# WordPress +.htaccess +wp-config.php +wp-content/uploads/ +wp-content/blogs.dir/ +wp-content/upgrade/ +wp-content/backup-db/ +wp-content/advanced-cache.php +wp-content/wp-cache-config.php +wp-content/cache/ +wp-content/cache/supercache/ + +# WP-CLI +wp-cli.local.yml + +# Node +node_modules/ +npm-debug.log +yarn-error.log +package-lock.json +yarn.lock + +# Build +/dist/ +/build/ +*.min.js +*.min.css + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +# Composer +/vendor/ +composer.lock + +# Logs +*.log +error_log +debug.log + +# Temporary files +*.tmp +*.temp +*.cache + +# OS +Thumbs.db +.DS_Store + +# Backup files +*.bak +*.backup +*~ + +# Environment +.env +.env.local +.env.*.local + +# Testing +/tests/coverage/ +.phpunit.result.cache + +# Deployment +deploy.sh +.deployment diff --git a/admin/admin.php b/admin/admin.php index eae9802..87340f8 100644 --- a/admin/admin.php +++ b/admin/admin.php @@ -4,60 +4,196 @@ if (!defined('ABSPATH')) { exit; } +use Sodino\Controllers\RuleController; +use Sodino\Controllers\DashboardController; +use Sodino\Controllers\SettingsController; use Sodino\Controllers\AdminController; use Sodino\Repositories\BannerRepository; use Sodino\Repositories\RuleRepository; use Sodino\Repositories\UpsellRepository; +use Sodino\Repositories\EventRepository; -// Initialize admin +// Initialize repositories $ruleRepository = new RuleRepository(); $upsellRepository = new UpsellRepository(); $bannerRepository = new BannerRepository(); +$eventRepository = new EventRepository(); + +// Initialize controllers +$ruleController = new RuleController($ruleRepository); +$dashboardController = new DashboardController($eventRepository, $ruleRepository); +$settingsController = new SettingsController(); $adminController = new AdminController($ruleRepository, $upsellRepository, $bannerRepository); -// Add menu -add_action('admin_menu', [$adminController, 'addMenu']); +/** + * Add admin menu + */ +add_action('admin_menu', function() use ($adminController) { + add_menu_page( + __('سودینو', 'sodino'), + __('سودینو', 'sodino'), + 'manage_options', + 'sodino-dashboard', + [$adminController, 'dashboardPage'], + 'dashicons-money-alt', + 56 + ); -// Admin AJAX handlers + add_submenu_page( + 'sodino-dashboard', + __('داشبورد', 'sodino'), + __('داشبورد', 'sodino'), + 'manage_options', + 'sodino-dashboard', + [$adminController, 'dashboardPage'] + ); + + add_submenu_page( + 'sodino-dashboard', + __('قوانین قیمت‌گذاری', 'sodino'), + __('قوانین قیمت‌گذاری', 'sodino'), + 'manage_options', + 'sodino-rules', + [$adminController, 'rulesPage'] + ); + + add_submenu_page( + 'sodino-dashboard', + __('افزودن قانون', 'sodino'), + __('افزودن قانون', 'sodino'), + 'manage_options', + 'sodino-add-rule', + [$adminController, 'addRulePage'] + ); + + add_submenu_page( + 'sodino-dashboard', + __('آپسل (پیشنهاد فروش)', 'sodino'), + __('آپسل (پیشنهاد فروش)', 'sodino'), + 'manage_options', + 'sodino-upsells', + [$adminController, 'upsellsPage'] + ); + + add_submenu_page( + 'sodino-dashboard', + __('افزودن آپسل', 'sodino'), + __('افزودن آپسل', 'sodino'), + 'manage_options', + 'sodino-add-upsell', + [$adminController, 'addUpsellPage'] + ); + + add_submenu_page( + 'sodino-dashboard', + __('بنرهای هوشمند', 'sodino'), + __('بنرهای هوشمند', 'sodino'), + 'manage_options', + 'sodino-banners', + [$adminController, 'bannersPage'] + ); + + add_submenu_page( + 'sodino-dashboard', + __('افزودن بنر', 'sodino'), + __('افزودن بنر', 'sodino'), + 'manage_options', + 'sodino-add-banner', + [$adminController, 'addBannerPage'] + ); + + add_submenu_page( + 'sodino-dashboard', + __('تنظیمات', 'sodino'), + __('تنظیمات', 'sodino'), + 'manage_options', + 'sodino-settings', + [$adminController, 'settingsPage'] + ); +}); + +/** + * Admin AJAX handlers + */ add_action('wp_ajax_sodino_search_products', [$adminController, 'searchProductsAjax']); -// Enqueue admin assets -add_action('admin_enqueue_scripts', function($hook) use ($adminController) { +/** + * Enqueue admin assets + */ +add_action('admin_enqueue_scripts', function($hook) { if (strpos($hook, 'sodino') === false) { return; } - // Enqueue Tailwind via CDN script - wp_enqueue_script('sodino-tailwind', 'https://cdn.tailwindcss.com', [], null); + // Enqueue Tailwind via CDN + wp_enqueue_script('sodino-tailwind', 'https://cdn.tailwindcss.com', [], SODINO_VERSION); + // Admin CSS wp_enqueue_style('sodino-admin', plugin_dir_url(__FILE__) . 'css/admin.css', [], SODINO_VERSION); - if (strpos($hook, 'sodino_page_sodino-dashboard') !== false) { + // Dashboard specific scripts + if (strpos($hook, 'sodino-dashboard') !== false || 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); + wp_enqueue_script('sodino-dashboard-js', plugin_dir_url(__FILE__) . 'js/dashboard.js', ['sodino-chart-js'], SODINO_VERSION, 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); + // Upsell specific scripts + if (strpos($hook, 'sodino-add-upsell') !== false || strpos($hook, 'sodino_page_sodino-add-upsell') !== false) { + wp_enqueue_script('sodino-upsell-admin', plugin_dir_url(__FILE__) . 'js/upsell-admin.js', ['jquery'], SODINO_VERSION, true); wp_localize_script('sodino-upsell-admin', 'sodinoUpsellAdmin', [ 'nonce' => wp_create_nonce('sodino_search_products'), + 'ajaxUrl' => admin_url('admin-ajax.php') ]); } - if (strpos($hook, 'sodino_page_sodino-add-banner') !== false) { + // Banner specific scripts + if (strpos($hook, 'sodino-add-banner') !== false || strpos($hook, 'sodino_page_sodino-add-banner') !== false) { wp_enqueue_media(); wp_enqueue_script('sodino-banner-admin', plugin_dir_url(__FILE__) . 'js/banner-admin.js', ['jquery'], SODINO_VERSION, true); } }); -// 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']); -} -if (isset($_GET['page']) && strpos($_GET['page'], 'sodino') === 0 && isset($_GET['action']) && in_array($_GET['action'], ['delete_banner', 'toggle_banner_status'], true)) { - add_action('admin_init', [$adminController, 'handleBannerActions']); -} -// 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 +/** + * Handle admin actions + */ +add_action('admin_init', function() use ($ruleController, $settingsController, $adminController) { + $page = $_GET['page'] ?? ''; + $action = $_GET['action'] ?? ''; + + // Rule actions + if ($page === 'sodino-rules' && $action === 'delete') { + $ruleController->delete(); + } + + // Settings actions + if ($page === 'sodino-settings' && $action === 'clear_cache') { + $settingsController->clearCache(); + } + + // Banner actions + if (strpos($page, 'sodino') === 0 && in_array($action, ['delete_banner', 'toggle_banner_status'], true)) { + $adminController->handleBannerActions(); + } + + // Upsell actions + if (strpos($page, 'sodino') === 0 && in_array($action, ['delete_upsell', 'toggle_upsell_status'], true)) { + $adminController->handleUpsellActions(); + } +}); + +/** + * Show admin notices + */ +add_action('admin_notices', function() { + $notice = get_transient('sodino_admin_notice'); + + if ($notice) { + $class = $notice['type'] === 'error' ? 'notice-error' : 'notice-success'; + printf( + '

%s

', + esc_attr($class), + esc_html($notice['message']) + ); + delete_transient('sodino_admin_notice'); + } +}); diff --git a/admin/components/header.php b/admin/components/header.php new file mode 100644 index 0000000..fec2818 --- /dev/null +++ b/admin/components/header.php @@ -0,0 +1,18 @@ + +
+
+
+
+
+

+

+
+
+
+
+
diff --git a/admin/components/layout.php b/admin/components/layout.php new file mode 100644 index 0000000..e5964ad --- /dev/null +++ b/admin/components/layout.php @@ -0,0 +1,181 @@ + +
+ + +
+
+ + +
+ +
+
+
+
+ +
+ +

+ + + +

+ + + + + +
+ 'bg-gradient-to-br from-blue-600 to-blue-700 text-white', + 'default' => 'bg-white border border-gray-200' + ]; + + $class = $classes[$type] ?? $classes['default']; + $text_class = $type === 'primary' ? 'text-white opacity-90' : 'text-gray-600'; + $value_class = $type === 'primary' ? 'text-white' : 'text-gray-900'; + ?> +
+

+
+
+ 'bg-blue-600 text-white hover:bg-blue-700', + 'secondary' => 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50', + 'danger' => 'bg-red-600 text-white hover:bg-red-700' + ]; + + $class = $classes[$type] ?? $classes['primary']; + ?> + + + + + + + + + 'text', + 'name' => '', + 'label' => '', + 'value' => '', + 'placeholder' => '', + 'required' => false, + 'description' => '', + 'options' => [], + 'class' => '' + ]; + + $args = wp_parse_args($args, $defaults); + extract($args); + + $required_attr = $required ? 'required' : ''; + $field_id = 'sodino_' . $name; + ?> +
+ + + + + + + + + + + + + + + + /> + + + +

+ +
+ __('داشبورد', 'sodino'), + 'sodino-rules' => __('قوانین', 'sodino'), + 'sodino-add-rule' => __('افزودن قانون', 'sodino'), + 'sodino-upsells' => __('آپسل (پیشنهاد فروش)', 'sodino'), + 'sodino-add-upsell' => __('افزودن آپسل', 'sodino'), + 'sodino-banners' => __('بنرهای هوشمند', 'sodino'), + 'sodino-add-banner' => __('افزودن بنر', 'sodino'), + 'sodino-settings' => __('تنظیمات', 'sodino'), +]; +?> + diff --git a/admin/views/settings.php b/admin/views/settings.php index 928df9c..1dc7e3b 100644 --- a/admin/views/settings.php +++ b/admin/views/settings.php @@ -4,197 +4,185 @@ if (!defined('ABSPATH')) { exit; } -$current_page = sanitize_text_field($_GET['page'] ?? 'sodino-settings'); -?> -
- -
-
-
-
-
-

-

+// Load components +require_once SODINO_PLUGIN_DIR . 'admin/components/layout.php'; + +sodino_admin_layout($current_page ?? 'sodino-settings', function() use ($settings) { + ?> + + + + +
+
+ + + +
+

+
+ 'checkbox', + 'name' => 'plugin_enabled', + 'label' => __('فعال‌سازی پلاگین', 'sodino'), + 'value' => $settings['plugin_enabled'] ?? 1, + 'description' => __('پلاگین سودینو را فعال یا غیرفعال کنید.', 'sodino') + ]); ?> + + 'checkbox', + 'name' => 'pricing_enabled', + 'label' => __('فعال‌سازی قیمت‌گذاری پویا', 'sodino'), + 'value' => $settings['pricing_enabled'] ?? 1, + 'description' => __('قیمت‌گذاری پویا بر اساس قوانین تعریف‌شده.', 'sodino') + ]); ?> + + 'checkbox', + 'name' => 'upsell_enabled', + 'label' => __('فعال‌سازی آپسل', 'sodino'), + 'value' => $settings['upsell_enabled'] ?? 1, + 'description' => __('نمایش پیشنهادات فروش به مشتریان.', 'sodino') + ]); ?> + + 'checkbox', + 'name' => 'banner_enabled', + 'label' => __('فعال‌سازی بنرهای هوشمند', 'sodino'), + 'value' => $settings['banner_enabled'] ?? 1, + 'description' => __('نمایش بنرهای هدفمند به کاربران.', 'sodino') + ]); ?> +
+
+ + +
+

+
+ 'checkbox', + 'name' => 'allow_multiple_rules', + 'label' => __('اجازه اعمال چند قانون همزمان', 'sodino'), + 'value' => $settings['allow_multiple_rules'] ?? 0, + 'description' => __('اگر فعال باشد، چند قانون می‌تواند روی یک محصول اعمال شود.', 'sodino') + ]); ?> + + 'select', + 'name' => 'strategy', + 'label' => __('استراتژی انتخاب قانون', 'sodino'), + 'value' => $settings['strategy'] ?? 'priority', + 'options' => [ + 'priority' => __('بر اساس اولویت', 'sodino'), + 'highest_discount' => __('بیشترین تخفیف', 'sodino'), + 'first_valid' => __('اولین قانون معتبر', 'sodino') + ], + 'description' => __('نحوه انتخاب قانون زمانی که چند قانون معتبر وجود دارد.', 'sodino') + ]); ?> + + 'number', + 'name' => 'max_discount_percent', + 'label' => __('حداکثر درصد تخفیف', 'sodino'), + 'value' => $settings['max_discount_percent'] ?? 100, + 'placeholder' => '100', + 'description' => __('حداکثر درصد تخفیفی که می‌تواند اعمال شود (0-100).', 'sodino') + ]); ?> + + 'number', + 'name' => 'min_product_price', + 'label' => __('حداقل قیمت محصول', 'sodino'), + 'value' => $settings['min_product_price'] ?? 0, + 'placeholder' => '0', + 'description' => __('حداقل قیمتی که یک محصول می‌تواند داشته باشد.', 'sodino') + ]); ?> + + 'checkbox', + 'name' => 'cart_pricing_enabled', + 'label' => __('قیمت‌گذاری در سبد خرید', 'sodino'), + 'value' => $settings['cart_pricing_enabled'] ?? 1, + 'description' => __('اعمال قیمت‌گذاری پویا در صفحه سبد خرید.', 'sodino') + ]); ?> +
+
+ + +
+

+
+ 'checkbox', + 'name' => 'cache_enabled', + 'label' => __('فعال‌سازی کش', 'sodino'), + 'value' => $settings['cache_enabled'] ?? 1, + 'description' => __('استفاده از کش برای بهبود عملکرد.', 'sodino') + ]); ?> + + 'number', + 'name' => 'cache_duration', + 'label' => __('مدت زمان کش (ثانیه)', 'sodino'), + 'value' => $settings['cache_duration'] ?? 3600, + 'placeholder' => '3600', + 'description' => __('مدت زمان نگهداری داده‌ها در کش (پیش‌فرض: 3600 ثانیه = 1 ساعت).', 'sodino') + ]); ?> + +
+ +
-
-
-
-
- - +
- -
- -
-

-

-
- - -
-
-
- - - -
-
-

-
-
-
- - - - - - -
-
-

-

-
-
-
- -

-
-
- -

-
-
- -

-
-
-
- - -
-
-

-

-
-
-
- -

-
-
- - -

-
-
-
- - -
-
-

-

-
-
-
- - -

-
-
- - -

-
-
-
- - -
-
-

-

-
-
-
- -

-
-
- -

-
-
- -

-
-
-
- - -
- -
- -
-
+ +
+ +
+
-
+ diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php new file mode 100644 index 0000000..1d90048 --- /dev/null +++ b/app/Controllers/BaseController.php @@ -0,0 +1,94 @@ + $message, + 'type' => $type + ], 30); + } + wp_safe_redirect($url); + exit; + } + + /** + * Get sanitized POST data + */ + protected function getPostData($key, $default = '') { + return isset($_POST[$key]) ? sanitize_text_field($_POST[$key]) : $default; + } + + /** + * Get sanitized GET data + */ + protected function getQueryData($key, $default = '') { + return isset($_GET[$key]) ? sanitize_text_field($_GET[$key]) : $default; + } + + /** + * Validate data + */ + protected function validate(array $data) { + return Validator::make($data); + } + + /** + * Render view + */ + protected function render($view, $data = []) { + extract($data); + $view_file = SODINO_PLUGIN_DIR . 'admin/views/' . $view . '.php'; + + if (file_exists($view_file)) { + include $view_file; + } else { + wp_die(sprintf(__('View file not found: %s', 'sodino'), $view)); + } + } + + /** + * Check user capability + */ + protected function checkCapability($capability = 'manage_options') { + if (!current_user_can($capability)) { + wp_die(__('شما دسترسی لازم برای این عملیات را ندارید.', 'sodino')); + } + } + + /** + * Show admin notice + */ + public function showAdminNotice() { + $notice = get_transient('sodino_admin_notice'); + + if ($notice) { + $class = $notice['type'] === 'error' ? 'notice-error' : 'notice-success'; + printf( + '

%s

', + esc_attr($class), + esc_html($notice['message']) + ); + delete_transient('sodino_admin_notice'); + } + } +} diff --git a/app/Controllers/DashboardController.php b/app/Controllers/DashboardController.php new file mode 100644 index 0000000..7387005 --- /dev/null +++ b/app/Controllers/DashboardController.php @@ -0,0 +1,48 @@ +analyticsService = new AnalyticsService($eventRepository, $ruleRepository); + } + + /** + * Dashboard page + */ + public function index() { + $this->checkCapability(); + + $filters = [ + 'range' => $this->getQueryData('range', '7d'), + 'start_date' => $this->getQueryData('start_date', ''), + 'end_date' => $this->getQueryData('end_date', ''), + 'product_id' => intval($this->getQueryData('product_id', 0)), + 'category_id' => intval($this->getQueryData('category_id', 0)), + ]; + + if (!empty($filters['product_id'])) { + $filters['product_ids'] = [$filters['product_id']]; + } + + $dashboardData = $this->analyticsService->getDashboardData($filters); + $productOptions = $this->analyticsService->getProductOptions(); + $categoryOptions = $this->analyticsService->getCategoryOptions(); + + $this->render('dashboard', [ + 'dashboardData' => $dashboardData, + 'productOptions' => $productOptions, + 'categoryOptions' => $categoryOptions, + 'filters' => $filters, + 'current_page' => 'sodino-dashboard' + ]); + } +} diff --git a/app/Controllers/RuleController.php b/app/Controllers/RuleController.php new file mode 100644 index 0000000..01c5103 --- /dev/null +++ b/app/Controllers/RuleController.php @@ -0,0 +1,189 @@ +ruleRepository = $ruleRepository; + } + + /** + * List rules page + */ + public function index() { + $this->checkCapability(); + + require_once SODINO_PLUGIN_DIR . 'admin/class-rules-list-table.php'; + $rulesTable = new \Sodino_Rules_List_Table($this->ruleRepository); + $rulesTable->prepare_items(); + + $this->render('rules-list', [ + 'rulesTable' => $rulesTable, + 'current_page' => 'sodino-rules' + ]); + } + + /** + * Create rule page + */ + public function create() { + $this->checkCapability(); + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + return $this->store(); + } + + $rule = new Rule(); + $this->render('rule-form', [ + 'rule' => $rule, + 'current_page' => 'sodino-add-rule' + ]); + } + + /** + * Edit rule page + */ + public function edit() { + $this->checkCapability(); + + $id = isset($_GET['id']) ? (int) $_GET['id'] : 0; + $rule = $this->ruleRepository->getById($id); + + if (!$rule) { + $this->redirect( + admin_url('admin.php?page=sodino-rules'), + __('قانون یافت نشد.', 'sodino'), + 'error' + ); + } + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + return $this->update($rule); + } + + $this->render('rule-form', [ + 'rule' => $rule, + 'current_page' => 'sodino-add-rule' + ]); + } + + /** + * Store new rule + */ + private function store() { + $this->verifyNonce('sodino_rule_nonce', 'sodino_save_rule'); + + $validator = $this->validate($_POST); + $validator->required('name', __('نام قانون الزامی است.', 'sodino')) + ->numeric('priority') + ->min('priority', 1) + ->numeric('usage_limit') + ->min('usage_limit', 0); + + if ($validator->fails()) { + $this->redirect( + admin_url('admin.php?page=sodino-add-rule'), + $validator->firstError(), + 'error' + ); + } + + $rule = new Rule(); + $this->fillRuleFromPost($rule); + $this->ruleRepository->save($rule); + + $this->redirect( + admin_url('admin.php?page=sodino-rules'), + __('قانون با موفقیت ایجاد شد.', 'sodino') + ); + } + + /** + * Update existing rule + */ + private function update($rule) { + $this->verifyNonce('sodino_rule_nonce', 'sodino_save_rule'); + + $validator = $this->validate($_POST); + $validator->required('name', __('نام قانون الزامی است.', 'sodino')) + ->numeric('priority') + ->min('priority', 1) + ->numeric('usage_limit') + ->min('usage_limit', 0); + + if ($validator->fails()) { + $this->redirect( + admin_url('admin.php?page=sodino-add-rule&action=edit&id=' . $rule->id), + $validator->firstError(), + 'error' + ); + } + + $this->fillRuleFromPost($rule); + $this->ruleRepository->save($rule); + + $this->redirect( + admin_url('admin.php?page=sodino-rules'), + __('قانون با موفقیت به‌روزرسانی شد.', 'sodino') + ); + } + + /** + * Delete rule + */ + public function delete() { + $this->checkCapability(); + + if (!isset($_GET['_wpnonce']) || !wp_verify_nonce($_GET['_wpnonce'], 'delete_rule')) { + wp_die(__('خطای امنیتی رخ داد.', 'sodino')); + } + + $id = isset($_GET['id']) ? (int) $_GET['id'] : 0; + $this->ruleRepository->delete($id); + + $this->redirect( + admin_url('admin.php?page=sodino-rules'), + __('قانون با موفقیت حذف شد.', 'sodino') + ); + } + + /** + * Fill rule from POST data + */ + private function fillRuleFromPost($rule) { + $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->start_date = !empty($_POST['start_date']) ? sanitize_text_field($_POST['start_date']) : null; + $rule->end_date = !empty($_POST['end_date']) ? sanitize_text_field($_POST['end_date']) : null; + $rule->enabled = isset($_POST['enabled']) ? 1 : 0; + + // Parse conditions + if (isset($_POST['conditions']) && is_array($_POST['conditions'])) { + $rule->conditions = array_map(function($condition) { + return [ + 'type' => sanitize_text_field($condition['type'] ?? ''), + 'value' => sanitize_text_field($condition['value'] ?? '') + ]; + }, $_POST['conditions']); + } + + // Parse actions + if (isset($_POST['actions']) && is_array($_POST['actions'])) { + $rule->actions = array_map(function($action) { + return [ + 'type' => sanitize_text_field($action['type'] ?? ''), + 'value' => sanitize_text_field($action['value'] ?? '') + ]; + }, $_POST['actions']); + } + } +} diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php new file mode 100644 index 0000000..1dacb27 --- /dev/null +++ b/app/Controllers/SettingsController.php @@ -0,0 +1,87 @@ +settings = Settings::getInstance(); + $this->cache = Cache::getInstance(); + } + + /** + * Settings page + */ + public function index() { + $this->checkCapability(); + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + return $this->save(); + } + + $settings = $this->settings->all(); + $this->render('settings', [ + 'settings' => $settings, + 'current_page' => 'sodino-settings' + ]); + } + + /** + * Save settings + */ + private function save() { + $this->verifyNonce('sodino_settings_nonce', 'sodino_save_settings'); + + $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, + 'banner_enabled' => isset($_POST['banner_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, + 'cache_enabled' => isset($_POST['cache_enabled']) ? 1 : 0, + 'cache_duration' => max(60, intval($_POST['cache_duration'] ?? 3600)), + 'debug_mode' => isset($_POST['debug_mode']) ? 1 : 0, + ]; + + $this->settings->update($settings); + + // Clear cache when settings change + $this->cache->clearAll(); + + $this->redirect( + admin_url('admin.php?page=sodino-settings'), + __('تنظیمات با موفقیت ذخیره شد.', 'sodino') + ); + } + + /** + * Clear cache action + */ + public function clearCache() { + $this->checkCapability(); + + if (!isset($_GET['_wpnonce']) || !wp_verify_nonce($_GET['_wpnonce'], 'clear_cache')) { + wp_die(__('خطای امنیتی رخ داد.', 'sodino')); + } + + $this->cache->clearAll(); + + $this->redirect( + admin_url('admin.php?page=sodino-settings'), + __('کش با موفقیت پاک شد.', 'sodino') + ); + } +} diff --git a/app/Core/Cache.php b/app/Core/Cache.php new file mode 100644 index 0000000..66cf705 --- /dev/null +++ b/app/Core/Cache.php @@ -0,0 +1,137 @@ +buildKey($key, $group); + + // Check memory cache first + if (isset($this->memory_cache[$full_key])) { + return $this->memory_cache[$full_key]; + } + + // Check WordPress transient + $value = get_transient($full_key); + + if ($value !== false) { + $this->memory_cache[$full_key] = $value; + return $value; + } + + return false; + } + + /** + * Set cached value + */ + public function set($key, $value, $expiration = 3600, $group = 'sodino') { + $full_key = $this->buildKey($key, $group); + + // Set in memory cache + $this->memory_cache[$full_key] = $value; + + // Set in WordPress transient + return set_transient($full_key, $value, $expiration); + } + + /** + * Delete cached value + */ + public function delete($key, $group = 'sodino') { + $full_key = $this->buildKey($key, $group); + + // Remove from memory cache + unset($this->memory_cache[$full_key]); + + // Remove from WordPress transient + return delete_transient($full_key); + } + + /** + * Clear all cache for a group + */ + public function clearGroup($group = 'sodino') { + global $wpdb; + + // Clear memory cache for group + foreach ($this->memory_cache as $key => $value) { + if (strpos($key, "sodino_{$group}_") === 0) { + unset($this->memory_cache[$key]); + } + } + + // Clear transients for group + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", + $wpdb->esc_like('_transient_sodino_' . $group . '_') . '%' + ) + ); + + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", + $wpdb->esc_like('_transient_timeout_sodino_' . $group . '_') . '%' + ) + ); + + return true; + } + + /** + * Clear all Sodino cache + */ + public function clearAll() { + global $wpdb; + + // Clear memory cache + $this->memory_cache = []; + + // Clear all Sodino transients + $wpdb->query( + "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_sodino_%' OR option_name LIKE '_transient_timeout_sodino_%'" + ); + + return true; + } + + /** + * Remember pattern - get from cache or execute callback + */ + public function remember($key, $callback, $expiration = 3600, $group = 'sodino') { + $value = $this->get($key, $group); + + if ($value !== false) { + return $value; + } + + $value = call_user_func($callback); + $this->set($key, $value, $expiration, $group); + + return $value; + } + + /** + * Build cache key + */ + private function buildKey($key, $group) { + return "sodino_{$group}_{$key}"; + } +} diff --git a/app/Core/Settings.php b/app/Core/Settings.php new file mode 100644 index 0000000..5fe6748 --- /dev/null +++ b/app/Core/Settings.php @@ -0,0 +1,125 @@ + 1, + 'pricing_enabled' => 1, + 'upsell_enabled' => 1, + 'banner_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, + 'cache_enabled' => 1, + 'cache_duration' => 3600, + 'debug_mode' => 0, + ]; + + public static function getInstance() { + if (self::$instance === null) { + self::$instance = new self(); + } + return self::$instance; + } + + /** + * Get all settings + */ + public function all() { + if ($this->settings === null) { + $this->settings = wp_parse_args( + get_option($this->option_name, []), + $this->defaults + ); + } + return $this->settings; + } + + /** + * Get single setting + */ + public function get($key, $default = null) { + $settings = $this->all(); + return $settings[$key] ?? $default ?? $this->defaults[$key] ?? null; + } + + /** + * Set single setting + */ + public function set($key, $value) { + $settings = $this->all(); + $settings[$key] = $value; + $this->settings = $settings; + return update_option($this->option_name, $settings); + } + + /** + * Update multiple settings + */ + public function update(array $settings) { + $current = $this->all(); + $this->settings = array_merge($current, $settings); + return update_option($this->option_name, $this->settings); + } + + /** + * Reset to defaults + */ + public function reset() { + $this->settings = $this->defaults; + return update_option($this->option_name, $this->defaults); + } + + /** + * Check if plugin is enabled + */ + public function isEnabled() { + return (bool) $this->get('plugin_enabled'); + } + + /** + * Check if pricing is enabled + */ + public function isPricingEnabled() { + return $this->isEnabled() && (bool) $this->get('pricing_enabled'); + } + + /** + * Check if upsell is enabled + */ + public function isUpsellEnabled() { + return $this->isEnabled() && (bool) $this->get('upsell_enabled'); + } + + /** + * Check if banner is enabled + */ + public function isBannerEnabled() { + return $this->isEnabled() && (bool) $this->get('banner_enabled'); + } + + /** + * Check if cache is enabled + */ + public function isCacheEnabled() { + return (bool) $this->get('cache_enabled'); + } + + /** + * Check if debug mode is enabled + */ + public function isDebugMode() { + return (bool) $this->get('debug_mode'); + } +} diff --git a/app/Core/Validator.php b/app/Core/Validator.php new file mode 100644 index 0000000..c0bacb7 --- /dev/null +++ b/app/Core/Validator.php @@ -0,0 +1,132 @@ +data = $data; + } + + /** + * Validate required field + */ + public function required($field, $message = null) { + if (!isset($this->data[$field]) || empty($this->data[$field])) { + $this->errors[$field][] = $message ?? sprintf(__('فیلد %s الزامی است.', 'sodino'), $field); + } + return $this; + } + + /** + * Validate numeric field + */ + public function numeric($field, $message = null) { + if (isset($this->data[$field]) && !is_numeric($this->data[$field])) { + $this->errors[$field][] = $message ?? sprintf(__('فیلد %s باید عدد باشد.', 'sodino'), $field); + } + return $this; + } + + /** + * Validate min value + */ + public function min($field, $min, $message = null) { + if (isset($this->data[$field]) && $this->data[$field] < $min) { + $this->errors[$field][] = $message ?? sprintf(__('فیلد %s باید حداقل %s باشد.', 'sodino'), $field, $min); + } + return $this; + } + + /** + * Validate max value + */ + public function max($field, $max, $message = null) { + if (isset($this->data[$field]) && $this->data[$field] > $max) { + $this->errors[$field][] = $message ?? sprintf(__('فیلد %s باید حداکثر %s باشد.', 'sodino'), $field, $max); + } + return $this; + } + + /** + * Validate email + */ + public function email($field, $message = null) { + if (isset($this->data[$field]) && !filter_var($this->data[$field], FILTER_VALIDATE_EMAIL)) { + $this->errors[$field][] = $message ?? sprintf(__('فیلد %s باید یک ایمیل معتبر باشد.', 'sodino'), $field); + } + return $this; + } + + /** + * Validate URL + */ + public function url($field, $message = null) { + if (isset($this->data[$field]) && !filter_var($this->data[$field], FILTER_VALIDATE_URL)) { + $this->errors[$field][] = $message ?? sprintf(__('فیلد %s باید یک URL معتبر باشد.', 'sodino'), $field); + } + return $this; + } + + /** + * Validate in array + */ + public function in($field, array $values, $message = null) { + if (isset($this->data[$field]) && !in_array($this->data[$field], $values, true)) { + $this->errors[$field][] = $message ?? sprintf(__('مقدار فیلد %s نامعتبر است.', 'sodino'), $field); + } + return $this; + } + + /** + * Custom validation + */ + public function custom($field, callable $callback, $message = null) { + if (isset($this->data[$field]) && !call_user_func($callback, $this->data[$field])) { + $this->errors[$field][] = $message ?? sprintf(__('فیلد %s نامعتبر است.', 'sodino'), $field); + } + return $this; + } + + /** + * Check if validation passed + */ + public function passes() { + return empty($this->errors); + } + + /** + * Check if validation failed + */ + public function fails() { + return !$this->passes(); + } + + /** + * Get all errors + */ + public function errors() { + return $this->errors; + } + + /** + * Get first error + */ + public function firstError() { + foreach ($this->errors as $field => $messages) { + return $messages[0] ?? ''; + } + return ''; + } + + /** + * Static factory method + */ + public static function make(array $data) { + return new self($data); + } +} diff --git a/app/Models/Rule.php b/app/Models/Rule.php index aa6cc91..58f9a90 100644 --- a/app/Models/Rule.php +++ b/app/Models/Rule.php @@ -11,14 +11,11 @@ class Rule { public $actions; public $priority; public $usage_limit; + public $usage_count; public $user_roles; public $start_date; public $end_date; public $enabled; - public $condition_type; - public $condition_value; - public $action_type; - public $action_value; public $created_at; public $updated_at; @@ -32,28 +29,13 @@ class Rule { $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->usage_count = isset($data['usage_count']) ? (int) $data['usage_count'] : 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; - $this->condition_type = $data['condition_type'] ?? ''; - $this->condition_value = $data['condition_value'] ?? ''; - $this->action_type = $data['action_type'] ?? ''; - $this->action_value = $data['action_value'] ?? ''; $this->created_at = $data['created_at'] ?? null; $this->updated_at = $data['updated_at'] ?? null; - - if (empty($this->conditions) && !empty($this->condition_type)) { - $this->conditions = [ - ['type' => $this->condition_type, 'value' => $this->condition_value], - ]; - } - - if (empty($this->actions) && !empty($this->action_type)) { - $this->actions = [ - ['type' => $this->action_type, 'value' => $this->action_value], - ]; - } } private function parseJsonField($value) { @@ -88,16 +70,41 @@ class Rule { 'actions' => wp_json_encode($this->actions), 'priority' => $this->priority, 'usage_limit' => $this->usage_limit, + 'usage_count' => $this->usage_count, '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, - 'condition_type' => $this->condition_type, - 'condition_value' => $this->condition_value, - 'action_type' => $this->action_type, - 'action_value' => $this->action_value, 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, ]; } -} \ No newline at end of file + + /** + * Check if rule is active + */ + public function isActive() { + if (!$this->enabled) { + return false; + } + + $now = current_time('mysql'); + + if (!empty($this->start_date) && $now < $this->start_date) { + return false; + } + + if (!empty($this->end_date) && $now > $this->end_date) { + return false; + } + + return true; + } + + /** + * Check if usage limit reached + */ + public function hasReachedLimit() { + return $this->usage_limit > 0 && $this->usage_count >= $this->usage_limit; + } +} diff --git a/app/Repositories/RuleRepository.php b/app/Repositories/RuleRepository.php index 2bf1eef..6a76af3 100644 --- a/app/Repositories/RuleRepository.php +++ b/app/Repositories/RuleRepository.php @@ -2,51 +2,80 @@ namespace Sodino\Repositories; use Sodino\Models\Rule; +use Sodino\Core\Cache; /** * Rule Repository */ class RuleRepository { private $table_name; + private $cache; + private $cache_group = 'rules'; + private $cache_duration = 3600; public function __construct() { global $wpdb; $this->table_name = $wpdb->prefix . 'sodino_rules'; + $this->cache = Cache::getInstance(); } /** * Get all rules */ public function getAll() { - global $wpdb; - $results = $wpdb->get_results("SELECT * FROM {$this->table_name} ORDER BY priority DESC, id ASC", ARRAY_A); - $rules = []; - foreach ($results as $result) { - $rules[] = new Rule($result); - } - return $rules; + return $this->cache->remember('all_rules', function() { + global $wpdb; + $results = $wpdb->get_results( + "SELECT * FROM {$this->table_name} ORDER BY priority DESC, id ASC", + ARRAY_A + ); + $rules = []; + foreach ($results as $result) { + $rules[] = new Rule($result); + } + return $rules; + }, $this->cache_duration, $this->cache_group); } /** * Get rule by ID */ 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 Rule($result) : null; + return $this->cache->remember("rule_{$id}", function() use ($id) { + global $wpdb; + $result = $wpdb->get_row( + $wpdb->prepare("SELECT * FROM {$this->table_name} WHERE id = %d", $id), + ARRAY_A + ); + return $result ? new Rule($result) : null; + }, $this->cache_duration, $this->cache_group); } /** * Get enabled rules */ public function getEnabled() { - global $wpdb; - $results = $wpdb->get_results("SELECT * FROM {$this->table_name} WHERE enabled = 1 ORDER BY priority DESC, id ASC", ARRAY_A); - $rules = []; - foreach ($results as $result) { - $rules[] = new Rule($result); - } - return $rules; + return $this->cache->remember('enabled_rules', function() { + global $wpdb; + $now = current_time('mysql'); + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$this->table_name} + WHERE enabled = 1 + AND (start_date IS NULL OR start_date <= %s) + AND (end_date IS NULL OR end_date >= %s) + ORDER BY priority DESC, id ASC", + $now, + $now + ), + ARRAY_A + ); + $rules = []; + foreach ($results as $result) { + $rules[] = new Rule($result); + } + return $rules; + }, $this->cache_duration, $this->cache_group); } /** @@ -59,11 +88,16 @@ class RuleRepository { if ($rule->id) { $wpdb->update($this->table_name, $data, ['id' => $rule->id]); - return $rule->id; + $id = $rule->id; } else { $wpdb->insert($this->table_name, $data); - return $wpdb->insert_id; + $id = $wpdb->insert_id; } + + // Clear cache + $this->clearCache(); + + return $id; } /** @@ -71,6 +105,31 @@ class RuleRepository { */ public function delete($id) { global $wpdb; - return $wpdb->delete($this->table_name, ['id' => $id]); + $result = $wpdb->delete($this->table_name, ['id' => $id]); + + // Clear cache + $this->clearCache(); + + return $result; } -} \ No newline at end of file + + /** + * Increment usage count + */ + public function incrementUsage($id) { + global $wpdb; + return $wpdb->query( + $wpdb->prepare( + "UPDATE {$this->table_name} SET usage_count = usage_count + 1 WHERE id = %d", + $id + ) + ); + } + + /** + * Clear cache + */ + private function clearCache() { + $this->cache->clearGroup($this->cache_group); + } +} diff --git a/app/Services/PricingService.php b/app/Services/PricingService.php index 782dfcf..68a0096 100644 --- a/app/Services/PricingService.php +++ b/app/Services/PricingService.php @@ -3,20 +3,24 @@ namespace Sodino\Services; use Sodino\Repositories\RuleRepository; use Sodino\Services\TrackingService; +use Sodino\Core\Settings; +use Sodino\Core\Cache; class PricingService { private $ruleRepository; private $trackingService; - private $rulesCache = null; + private $settings; + private $cache; public function __construct(RuleRepository $ruleRepository, TrackingService $trackingService) { $this->ruleRepository = $ruleRepository; $this->trackingService = $trackingService; + $this->settings = Settings::getInstance(); + $this->cache = Cache::getInstance(); } public function applyDynamicPricing($price, $product) { - $settings = $this->getSettings(); - if (empty($settings['plugin_enabled']) || empty($settings['pricing_enabled'])) { + if (!$this->settings->isPricingEnabled()) { return $price; } @@ -25,43 +29,58 @@ class PricingService { } $price = $this->normalizePrice($price); - if (!$settings['cart_pricing_enabled'] && is_cart()) { + + if (!$this->settings->get('cart_pricing_enabled') && is_cart()) { return $price; } $originalPrice = $price; - $rules = $this->getEnabledRules(); - $matchedRules = []; + $rules = $this->getApplicableRules($product); - foreach ($rules as $rule) { - if ($this->ruleMatches($rule, $product)) { - $matchedRules[] = $rule; - } - } - - if (empty($matchedRules)) { + if (empty($rules)) { return $price; } - if (!$settings['allow_multiple_rules']) { - $chosenRule = $this->chooseRule($matchedRules, $price, $settings['strategy']); - $matchedRules = $chosenRule ? [$chosenRule] : []; + if (!$this->settings->get('allow_multiple_rules')) { + $chosenRule = $this->chooseRule($rules, $price); + $rules = $chosenRule ? [$chosenRule] : []; } - foreach ($matchedRules as $rule) { + foreach ($rules as $rule) { $oldPrice = $price; - $price = $this->applyActions($rule, $price); + $price = $this->applyRuleActions($rule, $price); + if ($price < $oldPrice) { $this->trackingService->recordDiscountApplied($product, $oldPrice, $price, $rule->id); + $this->ruleRepository->incrementUsage($rule->id); } } - $price = $this->enforceLimits($originalPrice, $price, $settings); + $price = $this->enforceLimits($originalPrice, $price); return max(0, $price); } - private function chooseRule(array $rules, $price, $strategy) { + private function getApplicableRules($product) { + $cache_key = 'applicable_rules_' . ($product ? $product->get_id() : 'all'); + + return $this->cache->remember($cache_key, function() use ($product) { + $rules = $this->ruleRepository->getEnabled(); + $applicable = []; + + foreach ($rules as $rule) { + if ($this->ruleMatches($rule, $product)) { + $applicable[] = $rule; + } + } + + return $applicable; + }, 300, 'pricing'); + } + + private function chooseRule(array $rules, $price) { + $strategy = $this->settings->get('strategy', 'priority'); + if ($strategy === 'highest_discount') { usort($rules, function ($a, $b) use ($price) { return $this->estimateRuleDiscount($b, $price) <=> $this->estimateRuleDiscount($a, $price); @@ -79,44 +98,19 @@ class PricingService { return $rules[0] ?? null; } - 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() { - if ($this->rulesCache === null) { - $this->rulesCache = $this->ruleRepository->getEnabled(); - } - return $this->rulesCache; - } - private function normalizePrice($price) { if ($price === '' || $price === null) { return 0.0; } - return floatval($price); } private function ruleMatches($rule, $product = null) { - if (!$rule->enabled) { + if (!$rule->isActive()) { return false; } - if ($rule->usage_limit > 0 && $this->trackingService->getRuleUsageCount($rule->id) >= $rule->usage_limit) { + if ($rule->hasReachedLimit()) { return false; } @@ -126,10 +120,6 @@ class PricingService { } } - if (!$this->isRuleActive($rule)) { - return false; - } - if (empty($rule->conditions)) { return true; } @@ -143,20 +133,6 @@ class PricingService { return true; } - private function isRuleActive($rule) { - $now = current_time('Y-m-d H:i:s'); - - if (!empty($rule->start_date) && $now < $rule->start_date) { - return false; - } - - if (!empty($rule->end_date) && $now > $rule->end_date) { - return false; - } - - return true; - } - private function evaluateCondition($condition, $product = null) { $type = $condition['type'] ?? ''; $value = $condition['value'] ?? null; @@ -203,24 +179,21 @@ class PricingService { return true; } } - return false; } private function getCartTotal() { - if (!WC()->cart) { + if (!function_exists('WC') || !WC()->cart) { return 0; } - - return floatval(WC()->cart->get_cart_contents_total()); + return floatval(WC()->cart->get_subtotal()); } private function getCartItemCount() { - if (!WC()->cart) { + if (!function_exists('WC') || !WC()->cart) { return 0; } - - return WC()->cart->get_cart_contents_count(); + return intval(WC()->cart->get_cart_contents_count()); } private function productHasCategory($product, $categories) { @@ -229,7 +202,11 @@ class PricingService { } $product_cats = wp_get_post_terms($product->get_id(), 'product_cat', ['fields' => 'ids']); - return (bool) array_intersect($product_cats, $categories); + if (is_wp_error($product_cats)) { + return false; + } + + return (bool) array_intersect($product_cats, array_map('intval', $categories)); } private function productIsInIds($product, $ids) { @@ -237,14 +214,13 @@ class PricingService { return false; } - return in_array($product->get_id(), $ids, true); + return in_array($product->get_id(), array_map('intval', $ids), true); } - private function applyActions($rule, $price) { + private function applyRuleActions($rule, $price) { foreach ($rule->actions as $action) { $price = $this->applyAction($action, $price); } - return $price; } @@ -263,6 +239,8 @@ class PricingService { return $price; } return $price - $value; + case 'set_price': + return $value > 0 ? $value : $price; case 'free_shipping': return $price; default: @@ -270,14 +248,15 @@ class PricingService { } } - private function enforceLimits($originalPrice, $price, array $settings) { - $minPrice = max(0, floatval($settings['min_product_price'])); + private function enforceLimits($originalPrice, $price) { + $minPrice = max(0, floatval($this->settings->get('min_product_price', 0))); $price = max($price, $minPrice); - $maxDiscountPercent = floatval($settings['max_discount_percent']); + $maxDiscountPercent = floatval($this->settings->get('max_discount_percent', 100)); if ($maxDiscountPercent > 0 && $maxDiscountPercent < 100) { - $limit = $originalPrice * ($maxDiscountPercent / 100); - $price = max($originalPrice - $limit, $price); + $maxDiscount = $originalPrice * ($maxDiscountPercent / 100); + $minAllowedPrice = $originalPrice - $maxDiscount; + $price = max($minAllowedPrice, $price); } return $price; diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..fe86e0c --- /dev/null +++ b/composer.json @@ -0,0 +1,39 @@ +{ + "name": "sodino/wordpress-plugin", + "description": "افزونه هوشمند قیمت‌گذاری و بهینه‌سازی درآمد برای ووکامرس", + "type": "wordpress-plugin", + "license": "GPL-2.0-or-later", + "version": "2.0.0", + "authors": [ + { + "name": "Your Name", + "email": "your.email@example.com" + } + ], + "require": { + "php": ">=7.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.0", + "squizlabs/php_codesniffer": "^3.6" + }, + "autoload": { + "psr-4": { + "Sodino\\": "app/" + } + }, + "autoload-dev": { + "psr-4": { + "Sodino\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit", + "phpcs": "phpcs --standard=WordPress app/", + "phpcbf": "phpcbf --standard=WordPress app/" + }, + "config": { + "optimize-autoloader": true, + "sort-packages": true + } +} diff --git a/database/migrations.php b/database/migrations.php index eada823..4ba1201 100644 --- a/database/migrations.php +++ b/database/migrations.php @@ -7,11 +7,11 @@ if (!defined('ABSPATH')) { /** * Database migrations for Sodino plugin */ - function sodino_create_tables() { global $wpdb; $charset_collate = $wpdb->get_charset_collate(); + $current_version = get_option('sodino_db_version', '0'); // Rules table $rules_table = $wpdb->prefix . 'sodino_rules'; @@ -22,23 +22,22 @@ function sodino_create_tables() { actions longtext NOT NULL, priority int(11) NOT NULL DEFAULT 10, usage_limit int(11) NOT NULL DEFAULT 0, + usage_count int(11) NOT NULL DEFAULT 0, user_roles varchar(255) DEFAULT '', start_date datetime NULL, end_date datetime NULL, enabled tinyint(1) DEFAULT 1, - condition_type varchar(100) DEFAULT NULL, - condition_value varchar(255) DEFAULT NULL, - action_type varchar(100) DEFAULT NULL, - action_value varchar(255) DEFAULT NULL, created_at datetime DEFAULT CURRENT_TIMESTAMP, updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (id) + PRIMARY KEY (id), + KEY enabled_priority (enabled, priority), + KEY start_end_dates (start_date, end_date) ) $charset_collate;"; // Events table $events_table = $wpdb->prefix . 'sodino_events'; $events_sql = "CREATE TABLE $events_table ( - id mediumint(9) NOT NULL AUTO_INCREMENT, + id bigint(20) NOT NULL AUTO_INCREMENT, event_type varchar(100) NOT NULL, product_id mediumint(9) DEFAULT NULL, variation_id mediumint(9) DEFAULT NULL, @@ -49,7 +48,12 @@ function sodino_create_tables() { discount_value decimal(10,2) DEFAULT 0, metadata longtext DEFAULT NULL, created_at datetime DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (id) + PRIMARY KEY (id), + KEY event_type_created (event_type, created_at), + KEY product_id (product_id), + KEY rule_id (rule_id), + KEY session_id (session_id), + KEY created_at (created_at) ) $charset_collate;"; // Upsell table @@ -64,9 +68,13 @@ function sodino_create_tables() { discount_value decimal(10,2) DEFAULT 0, status tinyint(1) DEFAULT 1, priority int(11) NOT NULL DEFAULT 10, + impressions bigint(20) NOT NULL DEFAULT 0, + conversions bigint(20) NOT NULL DEFAULT 0, created_at datetime DEFAULT CURRENT_TIMESTAMP, updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (id) + PRIMARY KEY (id), + KEY status_priority (status, priority), + KEY trigger_type (trigger_type) ) $charset_collate;"; // Banner table @@ -88,7 +96,25 @@ function sodino_create_tables() { impressions bigint(20) NOT NULL DEFAULT 0, clicks bigint(20) NOT NULL DEFAULT 0, created_at datetime DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (id) + updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY status_priority (status, priority), + KEY position (position), + KEY start_end_time (start_time, end_time) + ) $charset_collate;"; + + // Analytics cache table + $analytics_table = $wpdb->prefix . 'sodino_analytics_cache'; + $analytics_sql = "CREATE TABLE $analytics_table ( + id bigint(20) NOT NULL AUTO_INCREMENT, + cache_key varchar(255) NOT NULL, + cache_value longtext NOT NULL, + cache_group varchar(100) NOT NULL DEFAULT 'general', + expires_at datetime NOT NULL, + created_at datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY cache_key_group (cache_key, cache_group), + KEY expires_at (expires_at) ) $charset_collate;"; require_once(ABSPATH . 'wp-admin/includes/upgrade.php'); @@ -96,7 +122,42 @@ function sodino_create_tables() { dbDelta($events_sql); dbDelta($upsell_sql); dbDelta($banner_sql); + dbDelta($analytics_sql); + + // Run migrations + sodino_run_migrations($current_version); // Add version option - update_option('sodino_db_version', '1.3'); -} \ No newline at end of file + update_option('sodino_db_version', '2.0'); +} + +/** + * Run incremental migrations + */ +function sodino_run_migrations($from_version) { + global $wpdb; + + // Migration from 1.x to 2.0 + if (version_compare($from_version, '2.0', '<')) { + // Add usage_count column if not exists + $rules_table = $wpdb->prefix . 'sodino_rules'; + $column_exists = $wpdb->get_results("SHOW COLUMNS FROM {$rules_table} LIKE 'usage_count'"); + + if (empty($column_exists)) { + $wpdb->query("ALTER TABLE {$rules_table} ADD COLUMN usage_count int(11) NOT NULL DEFAULT 0 AFTER usage_limit"); + } + + // Remove deprecated columns + $deprecated_columns = ['condition_type', 'condition_value', 'action_type', 'action_value']; + foreach ($deprecated_columns as $col) { + $col_exists = $wpdb->get_results("SHOW COLUMNS FROM {$rules_table} LIKE '{$col}'"); + if (!empty($col_exists)) { + $wpdb->query("ALTER TABLE {$rules_table} DROP COLUMN {$col}"); + } + } + + // Add indexes for better performance + $wpdb->query("ALTER TABLE {$rules_table} ADD INDEX enabled_priority (enabled, priority)"); + $wpdb->query("ALTER TABLE {$rules_table} ADD INDEX start_end_dates (start_date, end_date)"); + } +} diff --git a/readme.txt b/readme.txt index 3b5d4b9..44f741f 100644 --- a/readme.txt +++ b/readme.txt @@ -1,5 +1,5 @@ === Sodino === -Contributors: yourname +Contributors: Soheil khaledabadi Tags: woocommerce, pricing, dynamic pricing, revenue optimization Requires at least: 5.0 Tested up to: 6.0 diff --git a/sodino.php b/sodino.php index 50d9366..d058a1d 100644 --- a/sodino.php +++ b/sodino.php @@ -3,7 +3,7 @@ * Plugin Name: Sodino (سودینو) * Plugin URI: https://example.com/sodino * Description: افزونه هوشمند قیمت‌گذاری و بهینه‌سازی درآمد برای ووکامرس. قیمت محصولات را بر اساس رفتار کاربر و قوانین تعریف‌شده به صورت پویا تنظیم می‌کند. - * Version: 1.0.0 + * Version: 2.0.0 * Author: Your Name * License: GPL v2 or later * Text Domain: sodino @@ -20,7 +20,7 @@ if (!defined('ABSPATH')) { } // Define plugin constants -define('SODINO_VERSION', '1.0.0'); +define('SODINO_VERSION', '2.0.0'); define('SODINO_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('SODINO_PLUGIN_URL', plugin_dir_url(__FILE__)); define('SODINO_PLUGIN_BASENAME', plugin_basename(__FILE__)); @@ -60,6 +60,18 @@ function sodino_activate() { // Flush rewrite rules if needed flush_rewrite_rules(); + + // Set default settings + if (!get_option('sodino_settings')) { + update_option('sodino_settings', [ + 'plugin_enabled' => 1, + 'pricing_enabled' => 1, + 'upsell_enabled' => 1, + 'banner_enabled' => 1, + 'cache_enabled' => 1, + 'cache_duration' => 3600, + ]); + } } // Deactivation hook @@ -70,6 +82,10 @@ function sodino_deactivate() { // Clear analytics cron wp_clear_scheduled_hook('sodino_hourly_analytics'); + + // Clear all cache + $cache = \Sodino\Core\Cache::getInstance(); + $cache->clearAll(); } // Bootstrap the plugin @@ -79,6 +95,9 @@ function sodino_init() { add_action('admin_notices', 'sodino_woocommerce_missing_notice'); return; } + + // Load text domain + load_plugin_textdomain('sodino', false, dirname(SODINO_PLUGIN_BASENAME) . '/languages/'); // Initialize admin if (is_admin()) { @@ -86,19 +105,35 @@ 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'; - require_once SODINO_PLUGIN_DIR . 'public/hooks/banner-hooks.php'; + sodino_init_public_hooks(); // 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'); +/** + * Initialize public hooks + */ +function sodino_init_public_hooks() { + $settings = \Sodino\Core\Settings::getInstance(); + + if ($settings->isPricingEnabled()) { + require_once SODINO_PLUGIN_DIR . 'public/hooks/pricing-hooks.php'; + } + + if ($settings->isUpsellEnabled()) { + require_once SODINO_PLUGIN_DIR . 'public/hooks/upsell-hooks.php'; + } + + if ($settings->isBannerEnabled()) { + require_once SODINO_PLUGIN_DIR . 'public/hooks/banner-hooks.php'; + } + + // Always load analytics + require_once SODINO_PLUGIN_DIR . 'public/hooks/analytics-hooks.php'; +} + /** * Schedule analytics cron job */ @@ -130,4 +165,13 @@ function sodino_woocommerce_missing_notice() {

' . __('تنظیمات', 'sodino') . ''; + array_unshift($links, $settings_link); + return $links; +});