diff --git a/includes/Api/CampaignsController.php b/includes/Api/CampaignsController.php new file mode 100644 index 0000000..ed4c93a --- /dev/null +++ b/includes/Api/CampaignsController.php @@ -0,0 +1,320 @@ + 'GET', + 'callback' => [__CLASS__, 'get_campaigns'], + 'permission_callback' => [__CLASS__, 'check_admin_permission'], + ]); + + // Create campaign + register_rest_route(self::API_NAMESPACE, '/campaigns', [ + 'methods' => 'POST', + 'callback' => [__CLASS__, 'create_campaign'], + 'permission_callback' => [__CLASS__, 'check_admin_permission'], + ]); + + // Get single campaign + register_rest_route(self::API_NAMESPACE, '/campaigns/(?P\d+)', [ + 'methods' => 'GET', + 'callback' => [__CLASS__, 'get_campaign'], + 'permission_callback' => [__CLASS__, 'check_admin_permission'], + ]); + + // Update campaign + register_rest_route(self::API_NAMESPACE, '/campaigns/(?P\d+)', [ + 'methods' => 'PUT', + 'callback' => [__CLASS__, 'update_campaign'], + 'permission_callback' => [__CLASS__, 'check_admin_permission'], + ]); + + // Delete campaign + register_rest_route(self::API_NAMESPACE, '/campaigns/(?P\d+)', [ + 'methods' => 'DELETE', + 'callback' => [__CLASS__, 'delete_campaign'], + 'permission_callback' => [__CLASS__, 'check_admin_permission'], + ]); + + // Send campaign + register_rest_route(self::API_NAMESPACE, '/campaigns/(?P\d+)/send', [ + 'methods' => 'POST', + 'callback' => [__CLASS__, 'send_campaign'], + 'permission_callback' => [__CLASS__, 'check_admin_permission'], + ]); + + // Send test email + register_rest_route(self::API_NAMESPACE, '/campaigns/(?P\d+)/test', [ + 'methods' => 'POST', + 'callback' => [__CLASS__, 'send_test_email'], + 'permission_callback' => [__CLASS__, 'check_admin_permission'], + ]); + + // Preview campaign + register_rest_route(self::API_NAMESPACE, '/campaigns/(?P\d+)/preview', [ + 'methods' => 'GET', + 'callback' => [__CLASS__, 'preview_campaign'], + 'permission_callback' => [__CLASS__, 'check_admin_permission'], + ]); + } + + /** + * Check admin permission + */ + public static function check_admin_permission() { + return current_user_can('manage_options'); + } + + /** + * Get all campaigns + */ + public static function get_campaigns(WP_REST_Request $request) { + $campaigns = CampaignManager::get_all(); + + return new WP_REST_Response([ + 'success' => true, + 'data' => $campaigns, + ]); + } + + /** + * Create campaign + */ + public static function create_campaign(WP_REST_Request $request) { + $data = [ + 'title' => $request->get_param('title'), + 'subject' => $request->get_param('subject'), + 'content' => $request->get_param('content'), + 'status' => $request->get_param('status') ?: 'draft', + 'scheduled_at' => $request->get_param('scheduled_at'), + ]; + + $campaign_id = CampaignManager::create($data); + + if (is_wp_error($campaign_id)) { + return new WP_REST_Response([ + 'success' => false, + 'error' => $campaign_id->get_error_message(), + ], 400); + } + + $campaign = CampaignManager::get($campaign_id); + + return new WP_REST_Response([ + 'success' => true, + 'data' => $campaign, + ], 201); + } + + /** + * Get single campaign + */ + public static function get_campaign(WP_REST_Request $request) { + $campaign_id = (int) $request->get_param('id'); + $campaign = CampaignManager::get($campaign_id); + + if (!$campaign) { + return new WP_REST_Response([ + 'success' => false, + 'error' => __('Campaign not found', 'woonoow'), + ], 404); + } + + return new WP_REST_Response([ + 'success' => true, + 'data' => $campaign, + ]); + } + + /** + * Update campaign + */ + public static function update_campaign(WP_REST_Request $request) { + $campaign_id = (int) $request->get_param('id'); + + $data = []; + + if ($request->has_param('title')) { + $data['title'] = $request->get_param('title'); + } + if ($request->has_param('subject')) { + $data['subject'] = $request->get_param('subject'); + } + if ($request->has_param('content')) { + $data['content'] = $request->get_param('content'); + } + if ($request->has_param('status')) { + $data['status'] = $request->get_param('status'); + } + if ($request->has_param('scheduled_at')) { + $data['scheduled_at'] = $request->get_param('scheduled_at'); + } + + $result = CampaignManager::update($campaign_id, $data); + + if (is_wp_error($result)) { + return new WP_REST_Response([ + 'success' => false, + 'error' => $result->get_error_message(), + ], 400); + } + + $campaign = CampaignManager::get($campaign_id); + + return new WP_REST_Response([ + 'success' => true, + 'data' => $campaign, + ]); + } + + /** + * Delete campaign + */ + public static function delete_campaign(WP_REST_Request $request) { + $campaign_id = (int) $request->get_param('id'); + + $result = CampaignManager::delete($campaign_id); + + if (!$result) { + return new WP_REST_Response([ + 'success' => false, + 'error' => __('Failed to delete campaign', 'woonoow'), + ], 400); + } + + return new WP_REST_Response([ + 'success' => true, + 'message' => __('Campaign deleted', 'woonoow'), + ]); + } + + /** + * Send campaign + */ + public static function send_campaign(WP_REST_Request $request) { + $campaign_id = (int) $request->get_param('id'); + + $result = CampaignManager::send($campaign_id); + + if (!$result['success']) { + return new WP_REST_Response([ + 'success' => false, + 'error' => $result['error'], + ], 400); + } + + return new WP_REST_Response([ + 'success' => true, + 'message' => sprintf( + __('Campaign sent to %d recipients (%d failed)', 'woonoow'), + $result['sent'], + $result['failed'] + ), + 'sent' => $result['sent'], + 'failed' => $result['failed'], + 'total' => $result['total'], + ]); + } + + /** + * Send test email + */ + public static function send_test_email(WP_REST_Request $request) { + $campaign_id = (int) $request->get_param('id'); + $email = sanitize_email($request->get_param('email')); + + if (!is_email($email)) { + return new WP_REST_Response([ + 'success' => false, + 'error' => __('Invalid email address', 'woonoow'), + ], 400); + } + + $result = CampaignManager::send_test($campaign_id, $email); + + if (!$result) { + return new WP_REST_Response([ + 'success' => false, + 'error' => __('Failed to send test email', 'woonoow'), + ], 400); + } + + return new WP_REST_Response([ + 'success' => true, + 'message' => sprintf(__('Test email sent to %s', 'woonoow'), $email), + ]); + } + + /** + * Preview campaign + */ + public static function preview_campaign(WP_REST_Request $request) { + $campaign_id = (int) $request->get_param('id'); + $campaign = CampaignManager::get($campaign_id); + + if (!$campaign) { + return new WP_REST_Response([ + 'success' => false, + 'error' => __('Campaign not found', 'woonoow'), + ], 404); + } + + // Use reflection to call private render method or make it public + // For now, return a simple preview + $renderer = \WooNooW\Core\Notifications\EmailRenderer::instance(); + $template = $renderer->get_template_settings('newsletter_campaign', 'customer'); + + $content = $campaign['content']; + $subject = $campaign['subject'] ?: $campaign['title']; + + if ($template) { + $content = str_replace('{content}', $campaign['content'], $template['body']); + $content = str_replace('{campaign_title}', $campaign['title'], $content); + } + + // Replace placeholders + $site_name = get_bloginfo('name'); + $content = str_replace(['{site_name}', '{store_name}'], $site_name, $content); + $content = str_replace('{site_url}', home_url(), $content); + $content = str_replace('{subscriber_email}', 'subscriber@example.com', $content); + $content = str_replace('{unsubscribe_url}', '#unsubscribe', $content); + $content = str_replace('{current_date}', date_i18n(get_option('date_format')), $content); + $content = str_replace('{current_year}', date('Y'), $content); + + // Render with design template + $design_path = $renderer->get_design_template(); + if (file_exists($design_path)) { + $content = $renderer->render_html($design_path, $content, $subject, [ + 'site_name' => $site_name, + 'site_url' => home_url(), + ]); + } + + return new WP_REST_Response([ + 'success' => true, + 'subject' => $subject, + 'html' => $content, + ]); + } +} diff --git a/includes/Api/Routes.php b/includes/Api/Routes.php index afe423d..e15cb2a 100644 --- a/includes/Api/Routes.php +++ b/includes/Api/Routes.php @@ -23,6 +23,7 @@ use WooNooW\Api\CustomersController; use WooNooW\Api\NewsletterController; use WooNooW\Api\ModulesController; use WooNooW\Api\ModuleSettingsController; +use WooNooW\Api\CampaignsController; use WooNooW\Frontend\ShopController; use WooNooW\Frontend\CartController as FrontendCartController; use WooNooW\Frontend\AccountController; @@ -125,6 +126,9 @@ class Routes { // Newsletter controller NewsletterController::register_routes(); + // Campaigns controller + CampaignsController::register_routes(); + // Modules controller $modules_controller = new ModulesController(); $modules_controller->register_routes(); diff --git a/includes/Core/Bootstrap.php b/includes/Core/Bootstrap.php index cab1cae..b97f553 100644 --- a/includes/Core/Bootstrap.php +++ b/includes/Core/Bootstrap.php @@ -22,6 +22,7 @@ use WooNooW\Core\DataStores\OrderStore; use WooNooW\Core\MediaUpload; use WooNooW\Core\Notifications\PushNotificationHandler; use WooNooW\Core\Notifications\EmailManager; +use WooNooW\Core\Campaigns\CampaignManager; use WooNooW\Core\ActivityLog\ActivityLogTable; use WooNooW\Branding; use WooNooW\Frontend\Assets as FrontendAssets; @@ -40,6 +41,7 @@ class Bootstrap { MediaUpload::init(); PushNotificationHandler::init(); EmailManager::instance(); // Initialize custom email system + CampaignManager::init(); // Initialize campaigns CPT // Frontend (customer-spa) FrontendAssets::init(); diff --git a/includes/Core/Campaigns/CampaignManager.php b/includes/Core/Campaigns/CampaignManager.php new file mode 100644 index 0000000..85d8b30 --- /dev/null +++ b/includes/Core/Campaigns/CampaignManager.php @@ -0,0 +1,483 @@ + [ + 'name' => __('Campaigns', 'woonoow'), + 'singular_name' => __('Campaign', 'woonoow'), + ], + 'public' => false, + 'show_ui' => false, + 'show_in_rest' => false, + 'supports' => ['title'], + 'capability_type' => 'post', + 'map_meta_cap' => true, + ]); + } + + /** + * Create a new campaign + * + * @param array $data Campaign data + * @return int|WP_Error Campaign ID or error + */ + public static function create($data) { + $post_data = [ + 'post_type' => self::POST_TYPE, + 'post_status' => 'publish', + 'post_title' => sanitize_text_field($data['title'] ?? 'Untitled Campaign'), + ]; + + $campaign_id = wp_insert_post($post_data, true); + + if (is_wp_error($campaign_id)) { + return $campaign_id; + } + + // Save meta fields + self::update_meta($campaign_id, $data); + + return $campaign_id; + } + + /** + * Update campaign + * + * @param int $campaign_id Campaign ID + * @param array $data Campaign data + * @return bool|WP_Error + */ + public static function update($campaign_id, $data) { + $post = get_post($campaign_id); + + if (!$post || $post->post_type !== self::POST_TYPE) { + return new \WP_Error('invalid_campaign', __('Campaign not found', 'woonoow')); + } + + // Update title if provided + if (isset($data['title'])) { + wp_update_post([ + 'ID' => $campaign_id, + 'post_title' => sanitize_text_field($data['title']), + ]); + } + + // Update meta fields + self::update_meta($campaign_id, $data); + + return true; + } + + /** + * Update campaign meta + * + * @param int $campaign_id + * @param array $data + */ + private static function update_meta($campaign_id, $data) { + $meta_fields = [ + 'subject' => '_wnw_subject', + 'content' => '_wnw_content', + 'status' => '_wnw_status', + 'scheduled_at' => '_wnw_scheduled_at', + ]; + + foreach ($meta_fields as $key => $meta_key) { + if (isset($data[$key])) { + $value = $data[$key]; + + // Sanitize based on field type + if ($key === 'content') { + $value = wp_kses_post($value); + } elseif ($key === 'scheduled_at') { + $value = sanitize_text_field($value); + } elseif ($key === 'status') { + $allowed = ['draft', 'scheduled', 'sending', 'sent', 'failed']; + $value = in_array($value, $allowed) ? $value : 'draft'; + } else { + $value = sanitize_text_field($value); + } + + update_post_meta($campaign_id, $meta_key, $value); + } + } + + // Set default status if not provided + if (!get_post_meta($campaign_id, '_wnw_status', true)) { + update_post_meta($campaign_id, '_wnw_status', 'draft'); + } + } + + /** + * Get campaign by ID + * + * @param int $campaign_id + * @return array|null + */ + public static function get($campaign_id) { + $post = get_post($campaign_id); + + if (!$post || $post->post_type !== self::POST_TYPE) { + return null; + } + + return self::format_campaign($post); + } + + /** + * Get all campaigns + * + * @param array $args Query args + * @return array + */ + public static function get_all($args = []) { + $defaults = [ + 'post_type' => self::POST_TYPE, + 'post_status' => 'any', + 'posts_per_page' => -1, + 'orderby' => 'date', + 'order' => 'DESC', + ]; + + $query_args = wp_parse_args($args, $defaults); + $query_args['post_type'] = self::POST_TYPE; // Force post type + + $posts = get_posts($query_args); + + return array_map([__CLASS__, 'format_campaign'], $posts); + } + + /** + * Format campaign post to array + * + * @param WP_Post $post + * @return array + */ + private static function format_campaign($post) { + return [ + 'id' => $post->ID, + 'title' => $post->post_title, + 'subject' => get_post_meta($post->ID, '_wnw_subject', true), + 'content' => get_post_meta($post->ID, '_wnw_content', true), + 'status' => get_post_meta($post->ID, '_wnw_status', true) ?: 'draft', + 'scheduled_at' => get_post_meta($post->ID, '_wnw_scheduled_at', true), + 'sent_at' => get_post_meta($post->ID, '_wnw_sent_at', true), + 'recipient_count' => (int) get_post_meta($post->ID, '_wnw_recipient_count', true), + 'sent_count' => (int) get_post_meta($post->ID, '_wnw_sent_count', true), + 'failed_count' => (int) get_post_meta($post->ID, '_wnw_failed_count', true), + 'created_at' => $post->post_date, + 'updated_at' => $post->post_modified, + ]; + } + + /** + * Delete campaign + * + * @param int $campaign_id + * @return bool + */ + public static function delete($campaign_id) { + $post = get_post($campaign_id); + + if (!$post || $post->post_type !== self::POST_TYPE) { + return false; + } + + return wp_delete_post($campaign_id, true) !== false; + } + + /** + * Send campaign + * + * @param int $campaign_id + * @return array Result with sent/failed counts + */ + public static function send($campaign_id) { + $campaign = self::get($campaign_id); + + if (!$campaign) { + return ['success' => false, 'error' => __('Campaign not found', 'woonoow')]; + } + + if ($campaign['status'] === 'sent') { + return ['success' => false, 'error' => __('Campaign already sent', 'woonoow')]; + } + + // Get subscribers + $subscribers = self::get_subscribers(); + + if (empty($subscribers)) { + return ['success' => false, 'error' => __('No subscribers to send to', 'woonoow')]; + } + + // Update status to sending + update_post_meta($campaign_id, '_wnw_status', 'sending'); + update_post_meta($campaign_id, '_wnw_recipient_count', count($subscribers)); + + $sent = 0; + $failed = 0; + + // Get email template + $template = self::render_campaign_email($campaign); + + // Send in batches + $batch_size = 50; + $batches = array_chunk($subscribers, $batch_size); + + foreach ($batches as $batch) { + foreach ($batch as $subscriber) { + $email = $subscriber['email']; + + // Replace subscriber-specific variables + $body = str_replace('{subscriber_email}', $email, $template['body']); + $body = str_replace('{unsubscribe_url}', self::get_unsubscribe_url($email), $body); + + // Send email + $result = wp_mail( + $email, + $template['subject'], + $body, + ['Content-Type: text/html; charset=UTF-8'] + ); + + if ($result) { + $sent++; + } else { + $failed++; + } + } + + // Small delay between batches + if (count($batches) > 1) { + sleep(2); + } + } + + // Update campaign stats + update_post_meta($campaign_id, '_wnw_sent_count', $sent); + update_post_meta($campaign_id, '_wnw_failed_count', $failed); + update_post_meta($campaign_id, '_wnw_sent_at', current_time('mysql')); + update_post_meta($campaign_id, '_wnw_status', $failed > 0 && $sent === 0 ? 'failed' : 'sent'); + + return [ + 'success' => true, + 'sent' => $sent, + 'failed' => $failed, + 'total' => count($subscribers), + ]; + } + + /** + * Send test email + * + * @param int $campaign_id + * @param string $email Test email address + * @return bool + */ + public static function send_test($campaign_id, $email) { + $campaign = self::get($campaign_id); + + if (!$campaign) { + return false; + } + + $template = self::render_campaign_email($campaign); + + // Replace subscriber-specific variables + $body = str_replace('{subscriber_email}', $email, $template['body']); + $body = str_replace('{unsubscribe_url}', '#', $body); + + return wp_mail( + $email, + '[TEST] ' . $template['subject'], + $body, + ['Content-Type: text/html; charset=UTF-8'] + ); + } + + /** + * Render campaign email using EmailRenderer + * + * @param array $campaign + * @return array ['subject' => string, 'body' => string] + */ + private static function render_campaign_email($campaign) { + $renderer = \WooNooW\Core\Notifications\EmailRenderer::instance(); + + // Get the campaign email template + $template = $renderer->get_template_settings('newsletter_campaign', 'customer'); + + // Fallback if no template configured + if (!$template) { + $subject = $campaign['subject'] ?: $campaign['title']; + $body = $campaign['content']; + } else { + $subject = $template['subject'] ?: $campaign['subject']; + + // Replace {content} with campaign content + $body = str_replace('{content}', $campaign['content'], $template['body']); + + // Replace {campaign_title} + $body = str_replace('{campaign_title}', $campaign['title'], $body); + } + + // Replace common variables + $site_name = get_bloginfo('name'); + $site_url = home_url(); + + $subject = str_replace(['{site_name}', '{store_name}'], $site_name, $subject); + $body = str_replace(['{site_name}', '{store_name}'], $site_name, $body); + $body = str_replace('{site_url}', $site_url, $body); + $body = str_replace('{current_date}', date_i18n(get_option('date_format')), $body); + $body = str_replace('{current_year}', date('Y'), $body); + + // Render through email design template + $design_path = $renderer->get_design_template(); + if (file_exists($design_path)) { + $body = $renderer->render_html($design_path, $body, $subject, [ + 'site_name' => $site_name, + 'site_url' => $site_url, + ]); + } + + return [ + 'subject' => $subject, + 'body' => $body, + ]; + } + + /** + * Get subscribers + * + * @return array + */ + private static function get_subscribers() { + // Check if using custom table + $use_table = !get_option('woonoow_newsletter_limit_enabled', true); + + if ($use_table && self::has_subscribers_table()) { + global $wpdb; + $table = $wpdb->prefix . 'woonoow_subscribers'; + return $wpdb->get_results( + "SELECT email, user_id FROM {$table} WHERE status = 'active'", + ARRAY_A + ); + } + + // Use wp_options storage + $subscribers = get_option('woonoow_newsletter_subscribers', []); + return array_filter($subscribers, function($sub) { + return ($sub['status'] ?? 'active') === 'active'; + }); + } + + /** + * Check if subscribers table exists + * + * @return bool + */ + private static function has_subscribers_table() { + global $wpdb; + $table = $wpdb->prefix . 'woonoow_subscribers'; + return $wpdb->get_var("SHOW TABLES LIKE '{$table}'") === $table; + } + + /** + * Get unsubscribe URL + * + * @param string $email + * @return string + */ + private static function get_unsubscribe_url($email) { + $token = wp_hash($email . 'woonoow_unsubscribe'); + return add_query_arg([ + 'woonoow_unsubscribe' => 1, + 'email' => urlencode($email), + 'token' => $token, + ], home_url()); + } + + /** + * Process scheduled campaigns (WP-Cron) + */ + public static function process_scheduled_campaigns() { + // Only if scheduling is enabled + if (!get_option('woonoow_campaign_scheduling_enabled', false)) { + return; + } + + $campaigns = self::get_all([ + 'meta_query' => [ + [ + 'key' => '_wnw_status', + 'value' => 'scheduled', + ], + [ + 'key' => '_wnw_scheduled_at', + 'value' => current_time('mysql'), + 'compare' => '<=', + 'type' => 'DATETIME', + ], + ], + ]); + + foreach ($campaigns as $campaign) { + self::send($campaign['id']); + } + } + + /** + * Enable scheduling (registers cron) + */ + public static function enable_scheduling() { + if (!wp_next_scheduled(self::CRON_HOOK)) { + wp_schedule_event(time(), 'hourly', self::CRON_HOOK); + } + } + + /** + * Disable scheduling (clears cron) + */ + public static function disable_scheduling() { + wp_clear_scheduled_hook(self::CRON_HOOK); + } +} diff --git a/includes/Core/Notifications/EventRegistry.php b/includes/Core/Notifications/EventRegistry.php index 1623617..b26fdec 100644 --- a/includes/Core/Notifications/EventRegistry.php +++ b/includes/Core/Notifications/EventRegistry.php @@ -63,6 +63,21 @@ class EventRegistry { 'wc_email' => '', 'enabled' => true, ], + 'newsletter_campaign' => [ + 'id' => 'newsletter_campaign', + 'label' => __('Newsletter Campaign', 'woonoow'), + 'description' => __('Master email design template for newsletter campaigns', 'woonoow'), + 'category' => 'marketing', + 'recipient_type' => 'customer', + 'wc_email' => '', + 'enabled' => true, + 'variables' => [ + '{content}' => __('Campaign content', 'woonoow'), + '{campaign_title}' => __('Campaign title', 'woonoow'), + '{subscriber_email}' => __('Subscriber email', 'woonoow'), + '{unsubscribe_url}' => __('Unsubscribe link', 'woonoow'), + ], + ], // ===== ORDER INITIATION ===== 'order_placed' => [