From 32c065e4b6b4200427df47dc018086286b1c462f Mon Sep 17 00:00:00 2001 From: soheil khaledabadi Date: Tue, 5 May 2026 01:03:05 +0330 Subject: [PATCH] feat: Add banner management functionality - Implemented a new Banner model to represent banner data. - Created a BannerRepository for database interactions related to banners. - Developed a BannerService to handle business logic for banners. - Added admin views for listing and adding banners. - Integrated banner hooks for frontend rendering and click tracking. - Created frontend styles and scripts for banner display and interaction. - Updated database migrations to include a new banners table. - Enhanced AdminController to manage banner actions and pages. --- admin/admin.php | 13 +- admin/class-banner-list-table.php | 142 +++++++++++++++++ admin/css/admin.css | 91 +++++++++++ admin/js/banner-admin.js | 75 +++++++++ admin/views/banner-form.php | 219 ++++++++++++++++++++++++++ admin/views/banner-list.php | 66 ++++++++ app/Controllers/AdminController.php | 124 ++++++++++++++- app/Models/Banner.php | 64 ++++++++ app/Repositories/BannerRepository.php | 81 ++++++++++ app/Services/BannerService.php | 111 +++++++++++++ database/migrations.php | 25 ++- public/css/banner-frontend.css | 112 +++++++++++++ public/hooks/banner-hooks.php | 165 +++++++++++++++++++ public/js/banner-frontend.js | 65 ++++++++ sodino.php | 1 + 15 files changed, 1350 insertions(+), 4 deletions(-) create mode 100644 admin/class-banner-list-table.php create mode 100644 admin/js/banner-admin.js create mode 100644 admin/views/banner-form.php create mode 100644 admin/views/banner-list.php create mode 100644 app/Models/Banner.php create mode 100644 app/Repositories/BannerRepository.php create mode 100644 app/Services/BannerService.php create mode 100644 public/css/banner-frontend.css create mode 100644 public/hooks/banner-hooks.php create mode 100644 public/js/banner-frontend.js diff --git a/admin/admin.php b/admin/admin.php index eceba5c..eae9802 100644 --- a/admin/admin.php +++ b/admin/admin.php @@ -5,13 +5,15 @@ if (!defined('ABSPATH')) { } use Sodino\Controllers\AdminController; +use Sodino\Repositories\BannerRepository; use Sodino\Repositories\RuleRepository; use Sodino\Repositories\UpsellRepository; // Initialize admin $ruleRepository = new RuleRepository(); $upsellRepository = new UpsellRepository(); -$adminController = new AdminController($ruleRepository, $upsellRepository); +$bannerRepository = new BannerRepository(); +$adminController = new AdminController($ruleRepository, $upsellRepository, $bannerRepository); // Add menu add_action('admin_menu', [$adminController, 'addMenu']); @@ -41,13 +43,20 @@ add_action('admin_enqueue_scripts', function($hook) use ($adminController) { 'nonce' => wp_create_nonce('sodino_search_products'), ]); } + + if (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']); diff --git a/admin/class-banner-list-table.php b/admin/class-banner-list-table.php new file mode 100644 index 0000000..64d7e9c --- /dev/null +++ b/admin/class-banner-list-table.php @@ -0,0 +1,142 @@ + 'sodino_banner', + 'plural' => 'sodino_banners', + 'ajax' => false, + ]); + + $this->repository = $repository; + } + + public function get_columns() { + return [ + 'cb' => '', + 'title' => __('عنوان', 'sodino'), + 'position' => __('محل نمایش', 'sodino'), + 'display_type' => __('نوع نمایش', 'sodino'), + 'schedule' => __('زمان‌بندی', 'sodino'), + 'status' => __('وضعیت', 'sodino'), + 'stats' => __('آمار', 'sodino'), + 'actions' => __('عملیات', 'sodino'), + ]; + } + + protected function get_sortable_columns() { + return [ + 'priority' => ['priority', true], + 'title' => ['title', true], + ]; + } + + protected function column_cb($item) { + return sprintf('', $item->id); + } + + public function get_bulk_actions() { + return [ + 'delete' => __('حذف گروهی', 'sodino'), + ]; + } + + public function column_title($item) { + $edit_url = admin_url('admin.php?page=sodino-add-banner&action=edit&id=' . $item->id); + return sprintf('%s', esc_url($edit_url), esc_html($item->title)); + } + + public function column_position($item) { + $map = [ + 'top' => __('بالای سایت', 'sodino'), + 'middle' => __('وسط محتوا', 'sodino'), + 'bottom' => __('پایین', 'sodino'), + 'product_page' => __('صفحه محصول', 'sodino'), + 'cart' => __('سبد خرید', 'sodino'), + ]; + return $map[$item->position] ?? __('نامشخص', 'sodino'); + } + + public function column_display_type($item) { + $map = [ + 'inline' => __('درون صفحه', 'sodino'), + 'popup' => __('پاپ‌آپ', 'sodino'), + 'floating_bar' => __('نوار شناور', 'sodino'), + ]; + return $map[$item->display_type] ?? __('نامشخص', 'sodino'); + } + + public function column_schedule($item) { + if (!empty($item->start_time) || !empty($item->end_time)) { + $start = $item->start_time ? date_i18n('Y/m/d H:i', strtotime($item->start_time)) : __('بدون شروع', 'sodino'); + $end = $item->end_time ? date_i18n('Y/m/d H:i', strtotime($item->end_time)) : __('بدون پایان', 'sodino'); + return sprintf('%s
%s', esc_html($start), esc_html($end)); + } + return __('بدون محدودیت زمانی', 'sodino'); + } + + public function column_status($item) { + return $item->status ? __('فعال', 'sodino') : __('غیرفعال', 'sodino'); + } + + public function column_stats($item) { + $ctr = $item->impressions > 0 ? round(($item->clicks / $item->impressions) * 100, 2) : 0; + return sprintf('

%s: %s

%s: %s

CTR: %s%%

', + __('نمایش', 'sodino'), esc_html(number_format_i18n($item->impressions)), + __('کلیک', 'sodino'), esc_html(number_format_i18n($item->clicks)), + esc_html(number_format_i18n($ctr)) + ); + } + + public function column_actions($item) { + $edit_url = admin_url('admin.php?page=sodino-add-banner&action=edit&id=' . $item->id); + $toggle_url = wp_nonce_url(admin_url('admin.php?page=sodino-banners&action=toggle_banner_status&id=' . $item->id), 'toggle_banner_status'); + $delete_url = wp_nonce_url(admin_url('admin.php?page=sodino-banners&action=delete_banner&id=' . $item->id), 'delete_banner'); + $toggle_label = $item->status ? __('غیرفعال کردن', 'sodino') : __('فعال کردن', 'sodino'); + + return sprintf( + '%s | %s | %s', + esc_url($edit_url), esc_html__('ویرایش', 'sodino'), + esc_url($toggle_url), esc_html($toggle_label), + esc_url($delete_url), esc_js(__('آیا از حذف این بنر مطمئن هستید؟', 'sodino')), + esc_html__('حذف', 'sodino') + ); + } + + public function prepare_items() { + $this->_column_headers = [$this->get_columns(), [], $this->get_sortable_columns()]; + $this->process_bulk_action(); + + $all_items = $this->repository->getAll(); + $current_page = $this->get_pagenum(); + $total_items = count($all_items); + $this->items = array_slice($all_items, ($current_page - 1) * $this->items_per_page, $this->items_per_page); + + $this->set_pagination_args([ + 'total_items' => $total_items, + 'per_page' => $this->items_per_page, + 'total_pages' => ceil($total_items / $this->items_per_page), + ]); + } + + public function process_bulk_action() { + if ('delete' === $this->current_action()) { + $banner_ids = isset($_POST['banner_ids']) ? array_map('intval', $_POST['banner_ids']) : []; + if (!empty($banner_ids) && check_admin_referer('bulk-' . $this->_args['plural'])) { + foreach ($banner_ids as $id) { + $this->repository->delete($id); + } + } + } + } +} diff --git a/admin/css/admin.css b/admin/css/admin.css index ee219ec..50c73be 100644 --- a/admin/css/admin.css +++ b/admin/css/admin.css @@ -294,3 +294,94 @@ #sodino-app .sd-chart-card { min-height: 300px; } + +#sodino-app { + min-height: 100vh; + background: radial-gradient(circle at top right, rgba(99, 102, 241, 0.14), transparent 22%), + radial-gradient(circle at bottom left, rgba(59, 130, 246, 0.08), transparent 15%), + #f8fafc; + padding: 30px 16px 50px; + color: #0f172a; +} + +#sodino-app .bg-white.rounded-lg.shadow-sm.border, +#sodino-app .bg-white.rounded-2xl.border { + background: rgba(255, 255, 255, 0.98); + border-color: rgba(148, 163, 184, 0.70); + box-shadow: 0 18px 35px rgba(15, 23, 42, 0.10); +} + +#sodino-app .bg-white.rounded-lg.shadow-sm.border, +#sodino-app .bg-white.rounded-2xl.border, +#sodino-app .bg-gray-50, +#sodino-app .sd-sidebar, +#sodino-app .sd-card, +#sodino-app .sd-chart-card { + border-width: 1px; + border-style: solid; +} + +#sodino-app .bg-gray-50 { + background: #f7fbff !important; + border-color: rgba(148, 163, 184, 0.35); +} + +#sodino-app .shadow-sm { + box-shadow: 0 14px 30px rgba(15, 23, 42, 0.09) !important; +} + +#sodino-app .sd-sidebar, +#sodino-app .sd-card, +#sodino-app .sd-chart-card { + border-color: rgba(148, 163, 184, 0.65); +} + +#sodino-app .rounded-lg, +#sodino-app .rounded-2xl, +#sodino-app .rounded-md { + border-radius: 1.3rem !important; +} + +#sodino-app .space-y-2 > a { + display: block; + padding: 0.95rem 1rem; + border-radius: 1rem; + transition: all 0.18s ease-in-out; + border: 1px solid transparent; +} + +#sodino-app .space-y-2 > a:hover { + background: rgba(99, 102, 241, 0.1); +} + +#sodino-app .text-gray-500, +#sodino-app .text-gray-600, +#sodino-app .text-gray-700 { + color: #334155 !important; +} + +#sodino-app h1, +#sodino-app h2, +#sodino-app h3, +#sodino-app h4 { + letter-spacing: -0.02em; +} + +#sodino-app .bg-white.rounded-lg.shadow-sm.border p, +#sodino-app .bg-white.rounded-2xl.border p { + color: #475569; +} + +#sodino-app .bg-white.rounded-lg.shadow-sm.border .inline-flex, +#sodino-app .bg-white.rounded-2xl.border .inline-flex { + box-shadow: 0 10px 22px rgba(15, 23, 42, 0.06); +} + +#sodino-app table thead th { + color: #334155 !important; +} + +#sodino-app .wp-list-table .column-name, +#sodino-app .wp-list-table .column-title { + font-weight: 600; +} diff --git a/admin/js/banner-admin.js b/admin/js/banner-admin.js new file mode 100644 index 0000000..8471907 --- /dev/null +++ b/admin/js/banner-admin.js @@ -0,0 +1,75 @@ +(function () { + const contentTypeRadios = document.querySelectorAll('.sodino-banner-content-type'); + const contentGroups = document.querySelectorAll('.sodino-banner-content-group'); + const mediaButton = document.getElementById('sodino-banner-image-upload'); + const imageInput = document.getElementById('content_value_image'); + const presetButtons = document.querySelectorAll('.sodino-banner-preset'); + const startInput = document.getElementById('start_time'); + const endInput = document.getElementById('end_time'); + + function toggleFields() { + const selectedType = document.querySelector('.sodino-banner-content-type:checked'); + const type = selectedType ? selectedType.value : 'image'; + contentGroups.forEach((group) => { + group.style.display = group.dataset.type === type ? 'block' : 'none'; + }); + } + + if (contentTypeRadios.length) { + contentTypeRadios.forEach((radio) => radio.addEventListener('change', toggleFields)); + toggleFields(); + } + + if (mediaButton && imageInput && window.wp && wp.media) { + mediaButton.addEventListener('click', function () { + const mediaFrame = wp.media({ + title: 'انتخاب تصویر بنر', + button: { text: 'انتخاب' }, + multiple: false, + }); + + mediaFrame.on('select', function () { + const attachment = mediaFrame.state().get('selection').first().toJSON(); + imageInput.value = attachment.url; + }); + + mediaFrame.open(); + }); + } + + const setDatetimeLocal = (element, date) => { + if (!element) return; + const tzOffset = date.getTimezoneOffset() * 60000; + const localISO = new Date(date - tzOffset).toISOString().slice(0, 16); + element.value = localISO; + }; + + if (presetButtons.length) { + presetButtons.forEach((button) => { + button.addEventListener('click', function () { + const preset = this.dataset.preset; + const now = new Date(); + if (preset === 'today') { + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0); + const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59); + setDatetimeLocal(startInput, start); + setDatetimeLocal(endInput, end); + } + if (preset === 'weekend') { + const friday = new Date(now); + friday.setDate(friday.getDate() + ((5 - friday.getDay() + 7) % 7)); + const saturday = new Date(friday); + saturday.setDate(friday.getDate() + 1); + setDatetimeLocal(startInput, new Date(friday.getFullYear(), friday.getMonth(), friday.getDate(), 0, 0)); + setDatetimeLocal(endInput, new Date(saturday.getFullYear(), saturday.getMonth(), saturday.getDate(), 23, 59)); + } + if (preset === 'hourly') { + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 18, 0); + const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 0); + setDatetimeLocal(startInput, start); + setDatetimeLocal(endInput, end); + } + }); + }); + } +})(); diff --git a/admin/views/banner-form.php b/admin/views/banner-form.php new file mode 100644 index 0000000..6cbab9a --- /dev/null +++ b/admin/views/banner-form.php @@ -0,0 +1,219 @@ + +
+
+
+
+
+

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

+

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

+

+
+ +
+
+ + +
+ +
+ +
+ __('تصویر', 'sodino'), 'html' => __('HTML', 'sodino'), 'shortcode' => __('شورت‌کد', 'sodino')]; ?> + $label) : ?> + + +
+
+
+ +
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ +
+
+

+

+
+ +
+
+ + +
+
+ +
+ __('داخل صفحه', 'sodino'), 'popup' => __('پاپ‌آپ', 'sodino'), 'floating_bar' => __('نوار شناور', 'sodino')]; ?> + $label) : ?> + + +
+
+
+
+ +
+
+

+

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

+

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

+

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

+

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

+

+
+ + + +
+
+ +
+
+ + display(); ?> +
+
+
+
+
+
diff --git a/app/Controllers/AdminController.php b/app/Controllers/AdminController.php index 013f83f..613dae2 100644 --- a/app/Controllers/AdminController.php +++ b/app/Controllers/AdminController.php @@ -3,8 +3,10 @@ namespace Sodino\Controllers; use Sodino\Repositories\RuleRepository; use Sodino\Repositories\UpsellRepository; +use Sodino\Repositories\BannerRepository; use Sodino\Models\Rule; use Sodino\Models\Upsell; +use Sodino\Models\Banner; /** * Admin Controller @@ -12,10 +14,12 @@ use Sodino\Models\Upsell; class AdminController { private $ruleRepository; private $upsellRepository; + private $bannerRepository; - public function __construct(RuleRepository $ruleRepository, UpsellRepository $upsellRepository) { + public function __construct(RuleRepository $ruleRepository, UpsellRepository $upsellRepository, BannerRepository $bannerRepository) { $this->ruleRepository = $ruleRepository; $this->upsellRepository = $upsellRepository; + $this->bannerRepository = $bannerRepository; } /** @@ -68,6 +72,24 @@ class AdminController { [$this, 'addUpsellPage'] ); + add_submenu_page( + 'sodino-rules', + __('بنرهای هوشمند', 'sodino'), + __('بنرهای هوشمند', 'sodino'), + 'manage_options', + 'sodino-banners', + [$this, 'bannersPage'] + ); + + add_submenu_page( + 'sodino-rules', + __('افزودن بنر', 'sodino'), + __('افزودن بنر', 'sodino'), + 'manage_options', + 'sodino-add-banner', + [$this, 'addBannerPage'] + ); + add_submenu_page( 'sodino-rules', __('قیمت رقبا (به‌زودی)', 'sodino'), @@ -192,6 +214,29 @@ class AdminController { } } + /** + * Banners list page + */ + public function bannersPage() { + $this->listBannersPage(); + } + + /** + * Add or edit banner page + */ + public function addBannerPage() { + if (isset($_GET['action']) && $_GET['action'] === 'edit') { + return $this->editBannerPage(); + } + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $this->saveBanner(); + } else { + $banner = new Banner(); + include SODINO_PLUGIN_DIR . 'admin/views/banner-form.php'; + } + } + /** * Competitor price page */ @@ -206,6 +251,28 @@ class AdminController { include SODINO_PLUGIN_DIR . 'admin/views/upsell-list.php'; } + private function listBannersPage() { + require_once SODINO_PLUGIN_DIR . 'admin/class-banner-list-table.php'; + $bannerTable = new \Sodino_Banner_List_Table($this->bannerRepository); + $bannerTable->prepare_items(); + include SODINO_PLUGIN_DIR . 'admin/views/banner-list.php'; + } + + private function editBannerPage() { + $id = isset($_GET['id']) ? (int) $_GET['id'] : 0; + $banner = $this->bannerRepository->getById($id); + + if (!$banner) { + wp_die(__('بنر پیدا نشد', 'sodino')); + } + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $this->saveBanner($banner); + } else { + include SODINO_PLUGIN_DIR . 'admin/views/banner-form.php'; + } + } + private function editUpsellPage() { $id = isset($_GET['id']) ? (int) $_GET['id'] : 0; $upsell = $this->upsellRepository->getById($id); @@ -245,6 +312,34 @@ class AdminController { exit; } + private function saveBanner($banner = null) { + if (!isset($_POST['sodino_banner_nonce']) || !wp_verify_nonce($_POST['sodino_banner_nonce'], 'sodino_save_banner')) { + wp_die(__('خطای امنیتی رخ داد.', 'sodino')); + } + + if (!$banner) { + $banner = new Banner(); + } + + $banner->title = sanitize_text_field($_POST['title'] ?? ''); + $banner->content_type = sanitize_text_field($_POST['content_type'] ?? 'image'); + $banner->content_value = isset($_POST['content_value']) ? wp_kses_post($_POST['content_value']) : ''; + $banner->link_url = esc_url_raw($_POST['link_url'] ?? ''); + $banner->position = sanitize_text_field($_POST['position'] ?? 'top'); + $banner->display_type = sanitize_text_field($_POST['display_type'] ?? 'inline'); + $banner->start_time = sanitize_text_field($_POST['start_time'] ?? ''); + $banner->end_time = sanitize_text_field($_POST['end_time'] ?? ''); + $banner->user_target = sanitize_text_field($_POST['user_target'] ?? 'all'); + $banner->device_target = sanitize_text_field($_POST['device_target'] ?? 'all'); + $banner->priority = max(1, intval($_POST['priority'] ?? 10)); + $banner->status = isset($_POST['status']) ? 1 : 0; + + $this->bannerRepository->save($banner); + + wp_safe_redirect(admin_url('admin.php?page=sodino-banners')); + 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; @@ -272,6 +367,33 @@ class AdminController { } } + public function handleBannerActions() { + if (!isset($_GET['_wpnonce']) || !in_array($_GET['action'], ['delete_banner', 'toggle_banner_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_banner') { + $this->bannerRepository->delete($id); + wp_safe_redirect(admin_url('admin.php?page=sodino-banners')); + exit; + } + + if ($_GET['action'] === 'toggle_banner_status') { + $banner = $this->bannerRepository->getById($id); + if ($banner) { + $banner->status = $banner->status ? 0 : 1; + $this->bannerRepository->save($banner); + } + wp_safe_redirect(admin_url('admin.php?page=sodino-banners')); + exit; + } + } + public function searchProductsAjax() { if (!check_ajax_referer('sodino_search_products', 'security', false)) { wp_send_json([]); diff --git a/app/Models/Banner.php b/app/Models/Banner.php new file mode 100644 index 0000000..3c52fc6 --- /dev/null +++ b/app/Models/Banner.php @@ -0,0 +1,64 @@ +id = $data['id'] ?? null; + $this->title = $data['title'] ?? ''; + $this->content_type = $data['content_type'] ?? 'image'; + $this->content_value = $data['content_value'] ?? ''; + $this->link_url = $data['link_url'] ?? ''; + $this->position = $data['position'] ?? 'top'; + $this->display_type = $data['display_type'] ?? 'inline'; + $this->start_time = $data['start_time'] ?? null; + $this->end_time = $data['end_time'] ?? null; + $this->user_target = $data['user_target'] ?? 'all'; + $this->device_target = $data['device_target'] ?? 'all'; + $this->priority = isset($data['priority']) ? (int) $data['priority'] : 10; + $this->status = isset($data['status']) ? (int) $data['status'] : 1; + $this->impressions = isset($data['impressions']) ? (int) $data['impressions'] : 0; + $this->clicks = isset($data['clicks']) ? (int) $data['clicks'] : 0; + $this->created_at = $data['created_at'] ?? null; + } + + public function toArray() { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'content_type' => $this->content_type, + 'content_value' => $this->content_value, + 'link_url' => $this->link_url, + 'position' => $this->position, + 'display_type' => $this->display_type, + 'start_time' => $this->start_time, + 'end_time' => $this->end_time, + 'user_target' => $this->user_target, + 'device_target' => $this->device_target, + 'priority' => $this->priority, + 'status' => $this->status, + 'impressions' => $this->impressions, + 'clicks' => $this->clicks, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Repositories/BannerRepository.php b/app/Repositories/BannerRepository.php new file mode 100644 index 0000000..07e231d --- /dev/null +++ b/app/Repositories/BannerRepository.php @@ -0,0 +1,81 @@ +table_name = $wpdb->prefix . 'sodino_banners'; + } + + public function getAll() { + global $wpdb; + $results = $wpdb->get_results("SELECT * FROM {$this->table_name} ORDER BY priority DESC, id ASC", ARRAY_A); + $banners = []; + foreach ($results as $result) { + $banners[] = new Banner($result); + } + return $banners; + } + + 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 Banner($result) : null; + } + + public function getEnabled() { + global $wpdb; + $results = $wpdb->get_results("SELECT * FROM {$this->table_name} WHERE status = 1 ORDER BY priority DESC, id ASC", ARRAY_A); + $banners = []; + foreach ($results as $result) { + $banners[] = new Banner($result); + } + return $banners; + } + + public function save(Banner $banner) { + global $wpdb; + $data = $banner->toArray(); + unset($data['id'], $data['created_at']); + + if ($banner->id) { + $wpdb->update($this->table_name, $data, ['id' => $banner->id]); + $this->clearCache(); + return $banner->id; + } + + $wpdb->insert($this->table_name, $data); + $this->clearCache(); + return $wpdb->insert_id; + } + + public function delete($id) { + global $wpdb; + $result = $wpdb->delete($this->table_name, ['id' => $id]); + $this->clearCache(); + return $result; + } + + public function incrementImpression($id) { + global $wpdb; + $wpdb->query($wpdb->prepare("UPDATE {$this->table_name} SET impressions = impressions + 1 WHERE id = %d", $id)); + $this->clearCache(); + } + + public function incrementClick($id) { + global $wpdb; + $wpdb->query($wpdb->prepare("UPDATE {$this->table_name} SET clicks = clicks + 1 WHERE id = %d", $id)); + $this->clearCache(); + } + + public function clearCache() { + wp_cache_flush(); + } +} diff --git a/app/Services/BannerService.php b/app/Services/BannerService.php new file mode 100644 index 0000000..3f915a8 --- /dev/null +++ b/app/Services/BannerService.php @@ -0,0 +1,111 @@ +repository = $repository; + } + + public function getActiveBanners(array $context = []) { + $position = $context['position'] ?? ''; + $cache_key = $this->getCacheKey($position, $context); + $banners = wp_cache_get($cache_key, 'sodino_banners'); + + if ($banners === false) { + $banners = array_filter($this->repository->getEnabled(), function (Banner $banner) use ($context) { + return $this->filterByPosition($banner, $context) + && $this->filterByTime($banner) + && $this->filterByUserType($banner) + && $this->filterByDevice($banner); + }); + + usort($banners, function (Banner $a, Banner $b) { + return $b->priority <=> $a->priority; + }); + + wp_cache_set($cache_key, $banners, 'sodino_banners', MINUTE_IN_SECONDS); + } + + $limit = isset($context['limit']) ? (int) $context['limit'] : 1; + return $limit > 0 ? array_slice($banners, 0, $limit) : $banners; + } + + public function filterByTime(Banner $banner) { + $now = current_time('timestamp'); + + if (!empty($banner->start_time)) { + $start = strtotime($banner->start_time); + if ($start !== false && $now < $start) { + return false; + } + } + + if (!empty($banner->end_time)) { + $end = strtotime($banner->end_time); + if ($end !== false && $now > $end) { + return false; + } + } + + return true; + } + + public function filterByUserType(Banner $banner) { + if ($banner->user_target === 'all') { + return true; + } + + $userType = is_user_logged_in() ? 'returning' : 'new'; + + return $banner->user_target === $userType; + } + + public function filterByDevice(Banner $banner) { + if ($banner->device_target === 'all') { + return true; + } + + $device = wp_is_mobile() ? 'mobile' : 'desktop'; + return $banner->device_target === $device; + } + + public function filterByPosition(Banner $banner, array $context) { + if (empty($context['position'])) { + return false; + } + + if ($banner->position !== $context['position']) { + return false; + } + + if ($banner->position === 'product_page' && !is_singular('product')) { + return false; + } + + if ($banner->position === 'cart' && !is_cart()) { + return false; + } + + return true; + } + + public function increaseImpression($bannerId) { + $this->repository->incrementImpression($bannerId); + } + + public function increaseClick($bannerId) { + $this->repository->incrementClick($bannerId); + } + + private function getCacheKey($position, array $context) { + return 'sodino_active_banners_' . md5($position . '|' . serialize($context)); + } +} diff --git a/database/migrations.php b/database/migrations.php index ac0d0ac..eada823 100644 --- a/database/migrations.php +++ b/database/migrations.php @@ -69,11 +69,34 @@ function sodino_create_tables() { PRIMARY KEY (id) ) $charset_collate;"; + // Banner table + $banner_table = $wpdb->prefix . 'sodino_banners'; + $banner_sql = "CREATE TABLE $banner_table ( + id mediumint(9) NOT NULL AUTO_INCREMENT, + title varchar(255) NOT NULL, + content_type varchar(50) NOT NULL DEFAULT 'image', + content_value longtext NOT NULL, + link_url varchar(255) DEFAULT NULL, + position varchar(50) NOT NULL DEFAULT 'top', + display_type varchar(50) NOT NULL DEFAULT 'inline', + start_time datetime DEFAULT NULL, + end_time datetime DEFAULT NULL, + user_target varchar(50) NOT NULL DEFAULT 'all', + device_target varchar(50) NOT NULL DEFAULT 'all', + priority int(11) NOT NULL DEFAULT 10, + status tinyint(1) NOT NULL DEFAULT 1, + impressions bigint(20) NOT NULL DEFAULT 0, + clicks bigint(20) NOT NULL DEFAULT 0, + created_at datetime DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id) + ) $charset_collate;"; + require_once(ABSPATH . 'wp-admin/includes/upgrade.php'); dbDelta($rules_sql); dbDelta($events_sql); dbDelta($upsell_sql); + dbDelta($banner_sql); // Add version option - add_option('sodino_db_version', '1.2'); + update_option('sodino_db_version', '1.3'); } \ No newline at end of file diff --git a/public/css/banner-frontend.css b/public/css/banner-frontend.css new file mode 100644 index 0000000..fcd1207 --- /dev/null +++ b/public/css/banner-frontend.css @@ -0,0 +1,112 @@ +.sodino-banner { + direction: rtl; + position: relative; + z-index: 9999; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +.sodino-banner-wrap { + margin: 0 auto; + max-width: 1200px; + padding: 18px 22px; + background: #ffffff; + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 1rem; + box-shadow: 0 18px 35px rgba(15, 23, 42, 0.08); +} + +.sodino-banner-top { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + width: calc(100% - 32px); + max-width: 1100px; +} + +.sodino-banner-bottom { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + width: calc(100% - 32px); + max-width: 1100px; +} + +.sodino-banner-floating_bar { + position: fixed; + left: 50%; + transform: translateX(-50%); + width: calc(100% - 32px); + max-width: 1100px; +} + +.sodino-banner-position-top.sodino-banner-floating_bar, +.sodino-banner-position-middle.sodino-banner-floating_bar { + top: 20px; +} + +.sodino-banner-position-bottom.sodino-banner-floating_bar, +.sodino-banner-position-cart.sodino-banner-floating_bar, +.sodino-banner-position-product_page.sodino-banner-floating_bar { + bottom: 20px; +} + +.sodino-banner-popup { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + max-width: 95%; + width: 680px; + background: #ffffff; + box-shadow: 0 30px 60px rgba(15, 23, 42, 0.12); +} + +.sodino-banner-close { + position: absolute; + top: 14px; + right: 16px; + background: rgba(15, 23, 42, 0.06); + border: none; + color: #0f172a; + width: 36px; + height: 36px; + border-radius: 9999px; + font-size: 1.2rem; + cursor: pointer; +} + +.sodino-banner-content { + display: flex; + justify-content: center; + align-items: center; + gap: 20px; + flex-wrap: wrap; +} + +.sodino-banner-image { + width: 100%; + height: auto; + border-radius: 1rem; + display: block; +} + +.sodino-banner-link { + display: inline-block; +} + +@media (max-width: 768px) { + .sodino-banner-top, + .sodino-banner-bottom, + .sodino-banner-floating_bar, + .sodino-banner-popup { + width: calc(100% - 20px); + left: 50%; + transform: translateX(-50%); + } + + .sodino-banner-popup { + max-width: 95%; + } +} diff --git a/public/hooks/banner-hooks.php b/public/hooks/banner-hooks.php new file mode 100644 index 0000000..f7d7f61 --- /dev/null +++ b/public/hooks/banner-hooks.php @@ -0,0 +1,165 @@ + admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce('sodino_banner_click'), + ]); + wp_enqueue_script('sodino-banner-frontend'); +} + +function sodino_get_banner_html($banner) { + global $sodino_banner_service; + $html = ''; + $content = ''; + + switch ($banner->content_type) { + case 'image': + $image = esc_url($banner->content_value); + if (!empty($banner->link_url)) { + $content = sprintf('%s', esc_url($banner->link_url), esc_attr($banner->id), '' . esc_attr($banner->title) . ''); + } else { + $content = '' . esc_attr($banner->title) . ''; + } + break; + case 'shortcode': + $content = do_shortcode(wp_kses_post($banner->content_value)); + break; + case 'html': + default: + $content = wp_kses_post($banner->content_value); + break; + } + + $linkAttributes = ''; + if (!empty($banner->link_url) && $banner->content_type !== 'image') { + $linkAttributes = sprintf(' data-banner-id="%d" href="%s" class="sodino-banner-link"', esc_attr($banner->id), esc_url($banner->link_url)); + } + + $closeButton = ''; + $wrapperClass = 'sodino-banner-wrap sodino-banner-' . esc_attr($banner->display_type) . ' sodino-banner-position-' . esc_attr($banner->position); + + $style = $banner->display_type === 'popup' ? 'style="display:none;"' : ''; + $html .= '
'; + if ($banner->display_type === 'popup' || $banner->display_type === 'floating_bar') { + $html .= $closeButton; + } + $html .= '
'; + if ($banner->content_type !== 'image' && !empty($banner->link_url)) { + $html .= '' . $content . ''; + } else { + $html .= $content; + } + $html .= '
'; + $html .= '
'; + + return $html; +} + +function sodino_render_banner_position($position) { + global $sodino_banner_service; + + if (!isset($sodino_banner_service)) { + return ''; + } + + $banners = $sodino_banner_service->getActiveBanners(['position' => $position, 'limit' => 1]); + if (empty($banners)) { + return ''; + } + + $banner = reset($banners); + $sodino_banner_service->increaseImpression($banner->id); + return sodino_get_banner_html($banner); +} + +function sodino_render_top_banner() { + if (is_admin()) { + return; + } + + echo sodino_render_banner_position('top'); +} + +function sodino_render_middle_banner($content) { + if (is_admin() || !is_singular() || !in_the_loop() || is_feed()) { + return $content; + } + + $banner = sodino_render_banner_position('middle'); + if (empty($banner)) { + return $content; + } + + return $banner . $content; +} + +function sodino_render_bottom_banner() { + if (is_admin()) { + return; + } + + echo sodino_render_banner_position('bottom'); +} + +function sodino_render_product_banner() { + if (is_admin()) { + return; + } + + echo sodino_render_banner_position('product_page'); +} + +function sodino_render_cart_banner() { + if (is_admin()) { + return; + } + + echo sodino_render_banner_position('cart'); +} + +function sodino_handle_banner_click() { + if (!isset($_POST['banner_id']) || !isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'sodino_banner_click')) { + wp_send_json_error(); + } + + $bannerId = intval($_POST['banner_id']); + if (!$bannerId) { + wp_send_json_error(); + } + + global $sodino_banner_service; + if (!isset($sodino_banner_service)) { + wp_send_json_error(); + } + + $sodino_banner_service->increaseClick($bannerId); + wp_send_json_success(); +} diff --git a/public/js/banner-frontend.js b/public/js/banner-frontend.js new file mode 100644 index 0000000..5f87fa1 --- /dev/null +++ b/public/js/banner-frontend.js @@ -0,0 +1,65 @@ +(function () { + const closeButtons = document.querySelectorAll('.sodino-banner-close'); + const clicked = new Set(); + + function getCookie(name) { + const value = '; ' + document.cookie; + const parts = value.split('; ' + name + '='); + if (parts.length === 2) { + return parts.pop().split(';').shift(); + } + return null; + } + + function sendClick(bannerId) { + if (!bannerId || clicked.has(bannerId) || !window.sodinoBannerFrontend) { + return; + } + + clicked.add(bannerId); + + fetch(window.sodinoBannerFrontend.ajaxUrl, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, + body: 'action=sodino_banner_click&banner_id=' + encodeURIComponent(bannerId) + '&security=' + encodeURIComponent(window.sodinoBannerFrontend.nonce), + }); + } + + document.addEventListener('click', function (event) { + const target = event.target.closest('.sodino-banner-link'); + if (target) { + const bannerId = target.dataset.bannerId; + sendClick(bannerId); + } + }); + + closeButtons.forEach(function (button) { + button.addEventListener('click', function () { + const wrapper = this.closest('.sodino-banner-wrap'); + if (!wrapper) { + return; + } + wrapper.style.display = 'none'; + const bannerId = wrapper.dataset.bannerId; + if (bannerId) { + document.cookie = 'sodino_banner_' + bannerId + '=hidden; path=/; max-age=' + 60 * 60 * 24; + } + }); + }); + + document.querySelectorAll('.sodino-banner-wrap').forEach(function (banner) { + const bannerId = banner.dataset.bannerId; + if (getCookie('sodino_banner_' + bannerId) === 'hidden') { + banner.style.display = 'none'; + } + + if (banner.classList.contains('sodino-banner-popup')) { + setTimeout(function () { + if (getCookie('sodino_banner_' + bannerId) !== 'hidden') { + banner.style.display = 'block'; + } + }, 2000); + } + }); +})(); diff --git a/sodino.php b/sodino.php index 4be1f07..50d9366 100644 --- a/sodino.php +++ b/sodino.php @@ -89,6 +89,7 @@ function sodino_init() { 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'; // Schedule analytics aggregation if needed sodino_schedule_analytics();