From 4928901a08ce05541972f4c3cc0a9bfaa579527c Mon Sep 17 00:00:00 2001 From: soheil khaledabadi Date: Sat, 2 May 2026 01:58:10 +0330 Subject: [PATCH] Init(Core): create and add project --- admin/admin.php | 28 ++++ admin/class-rules-list-table.php | 132 +++++++++++++++++ admin/css/admin.css | 22 +++ admin/views/rule-form.php | 54 +++++++ admin/views/rules-list.php | 15 ++ admin/views/settings.php | 12 ++ app/Controllers/AdminController.php | 158 +++++++++++++++++++++ app/Models/Rule.php | 85 +++++++++++ app/Repositories/RuleRepository.php | 76 ++++++++++ app/Services/PricingService.php | 210 ++++++++++++++++++++++++++++ database/migrations.php | 58 ++++++++ public/hooks/pricing-hooks.php | 34 +++++ readme.txt | 38 +++++ sodino.php | 100 +++++++++++++ 14 files changed, 1022 insertions(+) create mode 100644 admin/admin.php create mode 100644 admin/class-rules-list-table.php create mode 100644 admin/css/admin.css create mode 100644 admin/views/rule-form.php create mode 100644 admin/views/rules-list.php create mode 100644 admin/views/settings.php create mode 100644 app/Controllers/AdminController.php create mode 100644 app/Models/Rule.php create mode 100644 app/Repositories/RuleRepository.php create mode 100644 app/Services/PricingService.php create mode 100644 database/migrations.php create mode 100644 public/hooks/pricing-hooks.php create mode 100644 readme.txt create mode 100644 sodino.php diff --git a/admin/admin.php b/admin/admin.php new file mode 100644 index 0000000..77e5165 --- /dev/null +++ b/admin/admin.php @@ -0,0 +1,28 @@ + 'sodino_rule', + 'plural' => 'sodino_rules', + 'ajax' => false, + ]); + + $this->repository = $repository; + } + + public function get_columns() { + return [ + 'cb' => '', + 'name' => __('عنوان قانون', 'sodino'), + 'condition_type' => __('نوع کاربر', 'sodino'), + 'action_value' => __('درصد تخفیف', 'sodino'), + 'enabled' => __('وضعیت', 'sodino'), + 'actions' => __('عملیات', 'sodino'), + ]; + } + + protected function get_sortable_columns() { + return [ + 'name' => ['name', true], + ]; + } + + protected function column_cb($item) { + return sprintf('', $item->id); + } + + public function get_bulk_actions() { + return [ + 'delete' => __('حذف گروهی', 'sodino'), + ]; + } + + public function column_actions($item) { + $edit_url = admin_url('admin.php?page=sodino-add-rule&action=edit&id=' . $item->id); + $delete_url = wp_nonce_url(admin_url('admin.php?page=sodino-rules&action=delete&id=' . $item->id), 'delete_rule'); + + return sprintf( + '%s | %s', + esc_url($edit_url), + esc_html__('ویرایش', 'sodino'), + esc_url($delete_url), + esc_js(__('آیا از حذف این قانون مطمئن هستید؟', 'sodino')), + esc_html__('حذف', 'sodino') + ); + } + + public function column_name($item) { + $edit_url = admin_url('admin.php?page=sodino-add-rule&action=edit&id=' . $item->id); + $title = sprintf('%s', esc_url($edit_url), esc_html($item->name)); + return $title; + } + + public function column_condition_type($item) { + $value = __('کاربر جدید', 'sodino'); + if ($item->condition_value === 'returning') { + $value = __('کاربر بازگشتی', 'sodino'); + } + return esc_html($value); + } + + public function column_action_value($item) { + return sprintf('%s %%', esc_html($item->action_value)); + } + + public function column_enabled($item) { + return $item->enabled ? __('فعال', 'sodino') : __('غیرفعال', 'sodino'); + } + + public function column_default($item, $column_name) { + switch ($column_name) { + case 'name': + case 'condition_type': + case 'action_value': + case 'enabled': + case 'actions': + return ''; + default: + return ''; + } + } + + public function prepare_items() { + $columns = $this->get_columns(); + $hidden = []; + $sortable = $this->get_sortable_columns(); + + $this->_column_headers = [$columns, $hidden, $sortable]; + + $this->process_bulk_action(); + + $all_items = $this->repository->getAll(); + $current_page = $this->get_pagenum(); + $total_items = count($all_items); + + $this->items = array_slice($all_items, ($current_page - 1) * $this->items_per_page, $this->items_per_page); + + $this->set_pagination_args([ + 'total_items' => $total_items, + 'per_page' => $this->items_per_page, + 'total_pages' => ceil($total_items / $this->items_per_page), + ]); + } + + public function process_bulk_action() { + if ('delete' === $this->current_action()) { + $rule_ids = isset($_POST['rule_ids']) ? array_map('intval', $_POST['rule_ids']) : []; + if (!empty($rule_ids) && check_admin_referer('bulk-' . $this->_args['plural'])) { + foreach ($rule_ids as $id) { + $this->repository->delete($id); + } + } + } + } +} diff --git a/admin/css/admin.css b/admin/css/admin.css new file mode 100644 index 0000000..29a9b81 --- /dev/null +++ b/admin/css/admin.css @@ -0,0 +1,22 @@ +.wrap { + direction: rtl; +} + +.sodino-admin-table th, +.sodino-admin-table td { + text-align: right; +} + +.page-title-action { + float: left; +} + +.rtl .page-title-action { + float: left; +} + +input.regular-text, +select.regular-text, +input.small-text { + direction: rtl; +} diff --git a/admin/views/rule-form.php b/admin/views/rule-form.php new file mode 100644 index 0000000..3ddf185 --- /dev/null +++ b/admin/views/rule-form.php @@ -0,0 +1,54 @@ + +
+

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

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
%
+ + id ? __('به‌روزرسانی قانون', 'sodino') : __('افزودن قانون', 'sودino'), 'primary'); ?> +
+
\ No newline at end of file diff --git a/admin/views/rules-list.php b/admin/views/rules-list.php new file mode 100644 index 0000000..2fdd4aa --- /dev/null +++ b/admin/views/rules-list.php @@ -0,0 +1,15 @@ + +
+

+ + + +
+ display(); ?> +
+
\ No newline at end of file diff --git a/admin/views/settings.php b/admin/views/settings.php new file mode 100644 index 0000000..65ac7b1 --- /dev/null +++ b/admin/views/settings.php @@ -0,0 +1,12 @@ + +
+

+
+

+
+
diff --git a/app/Controllers/AdminController.php b/app/Controllers/AdminController.php new file mode 100644 index 0000000..79eb9ba --- /dev/null +++ b/app/Controllers/AdminController.php @@ -0,0 +1,158 @@ +ruleRepository = $ruleRepository; + } + + /** + * Handle admin menu + */ + public function addMenu() { + add_menu_page( + __('قیمت‌یار', 'sodino'), + __('قیمت‌یار', 'sodino'), + 'manage_options', + 'sodino-rules', + [$this, 'rulesPage'], + 'dashicons-money-alt', + 56 + ); + + add_submenu_page( + 'sodino-rules', + __('قوانین قیمت‌گذاری', 'sodino'), + __('قوانین قیمت‌گذاری', 'sودino'), + 'manage_options', + 'sodino-rules', + [$this, 'rulesPage'] + ); + + add_submenu_page( + 'sodino-rules', + __('افزودن قانون', 'sودino'), + __('افزودن قانون', 'sودino'), + 'manage_options', + 'sodino-add-rule', + [$this, 'addRulePage'] + ); + + add_submenu_page( + 'sodino-rules', + __('تنظیمات', 'sodino'), + __('تنظیمات', 'sودینو'), + 'manage_options', + 'sodino-settings', + [$this, 'settingsPage'] + ); + } + + /** + * Rules admin page + */ + public function rulesPage() { + $this->listRulesPage(); + } + + /** + * List rules page + */ + private function listRulesPage() { + require_once SODINO_PLUGIN_DIR . 'admin/class-rules-list-table.php'; + + $rulesTable = new \Sodino_Rules_List_Table($this->ruleRepository); + $rulesTable->prepare_items(); + + include SODINO_PLUGIN_DIR . 'admin/views/rules-list.php'; + } + + /** + * Add or edit rule page + */ + public function addRulePage() { + if (isset($_GET['action']) && $_GET['action'] === 'edit') { + return $this->editRulePage(); + } + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $this->saveRule(); + } else { + $rule = new Rule(); + include SODINO_PLUGIN_DIR . 'admin/views/rule-form.php'; + } + } + + /** + * Settings page + */ + public function settingsPage() { + include SODINO_PLUGIN_DIR . 'admin/views/settings.php'; + } + + /** + * Edit rule page + */ + private function editRulePage() { + $id = isset($_GET['id']) ? (int) $_GET['id'] : 0; + $rule = $this->ruleRepository->getById($id); + + if (!$rule) { + wp_die(__('Rule not found', 'sodino')); + } + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $this->saveRule($rule); + } else { + include SODINO_PLUGIN_DIR . 'admin/views/rule-form.php'; + } + } + + /** + * Save rule + */ + private function saveRule($rule = null) { + if (!isset($_POST['gheymatyar_rule_nonce']) || !wp_verify_nonce($_POST['gheymatyar_rule_nonce'], 'gheymatyar_save_rule')) { + wp_die(__('خطای امنیتی رخ داد.', 'sodino')); + } + + if (!$rule) { + $rule = new Rule(); + } + + $rule->name = sanitize_text_field($_POST['name'] ?? ''); + $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'); + $rule->action_value = sanitize_text_field($_POST['action_value'] ?? '0'); + $rule->enabled = isset($_POST['enabled']) ? 1 : 0; + + $this->ruleRepository->save($rule); + + wp_safe_redirect(admin_url('admin.php?page=sodino-rules')); + exit; + } + + /** + * Handle delete action + */ + public function handleDelete() { + 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); + + wp_redirect(admin_url('admin.php?page=sodino-rules')); + exit; + } +} \ No newline at end of file diff --git a/app/Models/Rule.php b/app/Models/Rule.php new file mode 100644 index 0000000..c0b9444 --- /dev/null +++ b/app/Models/Rule.php @@ -0,0 +1,85 @@ +id = $data['id'] ?? null; + $this->name = $data['name'] ?? ''; + $this->conditions = $this->parseJsonField($data['conditions'] ?? '[]'); + $this->actions = $this->parseJsonField($data['actions'] ?? '[]'); + $this->priority = isset($data['priority']) ? (int) $data['priority'] : 10; + $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) { + if (is_array($value)) { + return $value; + } + + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : []; + } + + /** + * Convert to array + */ + public function toArray() { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'conditions' => wp_json_encode($this->conditions), + 'actions' => wp_json_encode($this->actions), + 'priority' => $this->priority, + '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 diff --git a/app/Repositories/RuleRepository.php b/app/Repositories/RuleRepository.php new file mode 100644 index 0000000..2bf1eef --- /dev/null +++ b/app/Repositories/RuleRepository.php @@ -0,0 +1,76 @@ +table_name = $wpdb->prefix . 'sodino_rules'; + } + + /** + * 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; + } + + /** + * 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; + } + + /** + * 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; + } + + /** + * Save rule + */ + public function save(Rule $rule) { + global $wpdb; + $data = $rule->toArray(); + unset($data['id'], $data['created_at'], $data['updated_at']); + + if ($rule->id) { + $wpdb->update($this->table_name, $data, ['id' => $rule->id]); + return $rule->id; + } else { + $wpdb->insert($this->table_name, $data); + return $wpdb->insert_id; + } + } + + /** + * Delete rule + */ + public function delete($id) { + global $wpdb; + return $wpdb->delete($this->table_name, ['id' => $id]); + } +} \ No newline at end of file diff --git a/app/Services/PricingService.php b/app/Services/PricingService.php new file mode 100644 index 0000000..f7a525c --- /dev/null +++ b/app/Services/PricingService.php @@ -0,0 +1,210 @@ +ruleRepository = $ruleRepository; + } + + /** + * Apply dynamic pricing to a product price + */ + public function applyDynamicPricing($price, $product) { + $price = $this->normalizePrice($price); + $rules = $this->getEnabledRules(); + $matchedRule = null; + + foreach ($rules as $rule) { + if ($this->ruleMatches($rule, $product)) { + if ($matchedRule === null || $rule->priority > $matchedRule->priority) { + $matchedRule = $rule; + } + } + } + + if ($matchedRule) { + $price = $this->applyActions($matchedRule, $price); + } + + return max(0, $price); + } + + public function shouldApplyFreeShipping() { + $rules = $this->getEnabledRules(); + foreach ($rules as $rule) { + if ($this->ruleMatches($rule, null) && $this->ruleHasFreeShipping($rule)) { + return true; + } + } + return false; + } + + public function resetFreeShippingFlag() { + $this->freeShipping = false; + } + + 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 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 ruleMatches($rule, $product = null) { + if (!$this->isRuleActive($rule)) { + return false; + } + + foreach ($rule->conditions as $condition) { + if (!$this->evaluateCondition($condition, $product)) { + return false; + } + } + + return true; + } + + 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) { + 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; + + switch ($type) { + case 'user_type': + return $this->getUserType() === $value; + case 'cart_total_min': + return $this->getCartTotal() >= floatval($value); + case 'cart_total_max': + return $this->getCartTotal() <= floatval($value); + case 'cart_item_count_min': + return $this->getCartItemCount() >= intval($value); + case 'cart_item_count_max': + return $this->getCartItemCount() <= intval($value); + case 'product_category': + return $this->productHasCategory($product, (array) $value); + case 'product_ids': + return $this->productIsInIds($product, (array) $value); + default: + return true; + } + } + + private function getCartTotal() { + if (!WC()->cart) { + return 0; + } + + return floatval(WC()->cart->get_cart_contents_total()); + } + + private function getCartItemCount() { + if (!WC()->cart) { + return 0; + } + + return WC()->cart->get_cart_contents_count(); + } + + private function productHasCategory($product, $categories) { + if (!$product || empty($categories)) { + return false; + } + + $product_cats = wp_get_post_terms($product->get_id(), 'product_cat', ['fields' => 'ids']); + return (bool) array_intersect($product_cats, $categories); + } + + private function productIsInIds($product, $ids) { + if (!$product || empty($ids)) { + return false; + } + + return in_array($product->get_id(), $ids, true); + } + + 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; + } + + private function applyAction($action, $price) { + $type = $action['type'] ?? ''; + $value = isset($action['value']) ? floatval($action['value']) : 0; + + switch ($type) { + case 'discount_percent': + if ($value <= 0) { + return $price; + } + return $price * (1 - $value / 100); + case 'discount_fixed': + if ($value <= 0) { + return $price; + } + return $price - $value; + case 'free_shipping': + return $price; + default: + return $price; + } + } +} diff --git a/database/migrations.php b/database/migrations.php new file mode 100644 index 0000000..1c3e7aa --- /dev/null +++ b/database/migrations.php @@ -0,0 +1,58 @@ +get_charset_collate(); + + // Rules table + $rules_table = $wpdb->prefix . 'sodino_rules'; + $rules_sql = "CREATE TABLE $rules_table ( + id mediumint(9) NOT NULL AUTO_INCREMENT, + name varchar(255) NOT NULL, + conditions longtext NOT NULL, + actions longtext NOT NULL, + priority int(11) NOT NULL DEFAULT 10, + 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) + ) $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, + discount_type varchar(50) DEFAULT 'percentage', + discount_value varchar(50) DEFAULT '0', + enabled 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, + PRIMARY KEY (id) + ) $charset_collate;"; + + require_once(ABSPATH . 'wp-admin/includes/upgrade.php'); + dbDelta($rules_sql); + dbDelta($upsell_sql); + + // Add version option + add_option('sodino_db_version', '1.1'); +} \ No newline at end of file diff --git a/public/hooks/pricing-hooks.php b/public/hooks/pricing-hooks.php new file mode 100644 index 0000000..2522de5 --- /dev/null +++ b/public/hooks/pricing-hooks.php @@ -0,0 +1,34 @@ +applyDynamicPricing($price, $product); +} + +function sodino_apply_to_cart_item($price, $cart_item, $cart_item_key) { + global $sodino_pricing_service; + $product = $cart_item['data']; + return wc_price($sodino_pricing_service->applyDynamicPricing($product->get_price(), $product)); +} \ No newline at end of file diff --git a/readme.txt b/readme.txt new file mode 100644 index 0000000..3b5d4b9 --- /dev/null +++ b/readme.txt @@ -0,0 +1,38 @@ +=== Sodino === +Contributors: yourname +Tags: woocommerce, pricing, dynamic pricing, revenue optimization +Requires at least: 5.0 +Tested up to: 6.0 +Requires PHP: 7.4 +Stable tag: 1.0.0 +License: GPLv2 or later +License URI: https://www.gnu.org/licenses/gpl-2.0.html + +Smart Pricing & Revenue Optimization plugin for WooCommerce. + +== Description == + +Sodino dynamically adjusts WooCommerce product prices based on user behavior and predefined rules. + +Features: +- Dynamic pricing based on user type (new vs returning) +- Rule-based system for discounts +- Admin panel to manage rules +- MVC architecture for extensibility + +== Installation == + +1. Upload the plugin files to the `/wp-content/plugins/sodino` directory, or install the plugin through the WordPress plugins screen directly. +2. Activate the plugin through the 'Plugins' screen in WordPress. +3. Use the Sodino menu in the admin to configure rules. + +== Frequently Asked Questions == + += Does it work with variable products? = + +Yes, it applies to all product types. + +== Changelog == + += 1.0.0 = +* Initial release. \ No newline at end of file diff --git a/sodino.php b/sodino.php new file mode 100644 index 0000000..5e684d7 --- /dev/null +++ b/sodino.php @@ -0,0 +1,100 @@ + +
+

+
+