input( 'id' ); if ( ! $id ) { wp_send_json_error( array( 'message' => __( 'Invalid ID.', 'ad-fuz' ) ) ); return; } $record = Campaigns::withAds( $id ); if ( ! $record ) { wp_send_json_error( array( 'message' => __( 'Campaign not found.', 'ad-fuz' ) ) ); return; } wp_send_json_success( array( 'campaign' => $record ) ); } // ------------------------------------------------------------------------- // Extra AJAX response data — sent back to JS after save // ------------------------------------------------------------------------- protected static function extraResponseData( int $id, array $data ): array { $record = Campaigns::withAds( $id ); if ( ! $record ) { return array(); } $slug = static::getControllerSlug(); return array( $slug => array( 'name' => Common::get( 'name', $record, '' ), 'status' => ucfirst( Common::get( 'status', $record, '' ) ), 'start_date' => Common::get( 'start_date', $record, '' ), 'end_date' => Common::get( 'end_date', $record, '' ), 'ads' => Common::get( 'ads', $record, array() ), ), ); } // ------------------------------------------------------------------------- // Render — entry point // ------------------------------------------------------------------------- public static function index(): void { Common::checkCapability( static::getCapabilities() ); $request = self::request(); $action = sanitize_text_field( wp_unslash( $request->input( 'action', 'get', 'list' ) ) ); match ( $action ) { 'create', 'edit' => static::renderForm( $request ), default => static::renderList( $request ), }; } // ------------------------------------------------------------------------- // Render — list // ------------------------------------------------------------------------- private static function renderList( $request ): void { $current_page = max( 1, (int) $request->input( 'paged', 'get', 1 ) ); $per_page = 20; $filters = array( 'status' => sanitize_text_field( $request->input( 'status', 'get', '' ) ), 'search' => sanitize_text_field( $request->input( 's', 'get', '' ) ), ); $result = Campaigns::paginate( $per_page, $current_page, $filters ); $status_rows = DB::get( TableName::ADFUZ_CAMPAIGNS() ) ->select( 'status', 'COUNT(*) as count' ) ->where( 'status', '!=', 'deleted' ) ->groupBy( 'status' ) ->get(); $stats = array( 'total' => $result['total'] ?? 0, 'active' => 0, 'paused' => 0, 'pending' => 0, 'expired' => 0, ); foreach ( $status_rows as $row ) { $key = strtolower( $row['status'] ); if ( isset( $stats[ $key ] ) ) { $stats[ $key ] = (int) $row['count']; } } Template::load( 'admin/campaign/index', array( 'campaigns' => $result['data'], 'pagination' => $result, 'stats' => $stats, 'filters' => $filters, ) ); } // ------------------------------------------------------------------------- // Render — create / edit form (admin-page route) // ------------------------------------------------------------------------- private static function renderForm( $request ): void { $id = (int) ( $request->input( 'id', 'get', 0 ) ); $campaign = $id ? Campaigns::withAds( $id ) : array(); if ( $id && ! $campaign ) { static::redirectWithErrors( array( __( 'Campaign not found.', 'ad-fuz' ) ), Url::campaignList() ); } $errors = static::getFormErrors(); $template_data = array( 'campaign' => $campaign, 'id' => $id, 'errors' => $errors, 'ads_options' => Ads::allActive(), ); /** * Filter the template data passed to the campaign form. * * Pro uses this to inject targeting data without touching the Free * controller: * - 'target_countries' => CampaignTargetCountry::byCampaign( $id ) * - 'target_devices' => CampaignTargetDevice::byCampaign( $id ) * - 'available_countries' => CountryList::all() * * @param array $template_data Data array passed to the template. * @param int $id Campaign ID being edited (0 for create). * @param array $campaign Campaign record (empty array for create). */ $template_data = apply_filters( 'adfuz/campaign/form_data', $template_data, $id, $campaign ); Template::load( 'admin/campaign/form', $template_data ); } // ------------------------------------------------------------------------- // Save — handles normal form submission via admin-post.php // ------------------------------------------------------------------------- public static function save(): void { Common::checkCapability( static::getCapabilities() ); Common::verifyNonce(); $request = self::request(); $id = (int) ( $request->input( 'id', 'post', 0 ) ); $form_url = $id ? Url::campaignEdit( $id ) : Url::campaignCreate(); $recordId = static::persistForm( $form_url ); static::redirectWithSuccess( __( 'Campaign saved successfully.', 'ad-fuz' ), Url::campaignEdit( $recordId ) ); } // ------------------------------------------------------------------------- // afterSave — Free owns: campaign_ads pivot // Pro hooks in via: do_action( 'adfuz/campaign/after_save' ) // ------------------------------------------------------------------------- protected static function afterSave( int $recordId, array $cleanData ): void { // 1. Persist the campaign_ads pivot (Free plugin owns this relation). static::saveCampaignAds( $recordId ); /** * Fires after a campaign record and its ads pivot rows have been saved. * * Pro (or any extension) hooks here to save targeting data * (countries, devices) or any other relational data without * modifying the Free controller. * * Hook: adfuz/campaign/after_save * * @param int $recordId The saved campaign ID. * @param array $cleanData Sanitised + validated field data from the form. */ do_action( 'adfuz/campaign/after_save', $recordId, $cleanData ); } // ------------------------------------------------------------------------- // afterDelete — Free owns: nothing structural to clean // Pro hooks in via: do_action( 'adfuz/campaign/after_delete' ) // ------------------------------------------------------------------------- protected static function afterDelete( int $recordId ): void { /** * Fires after a campaign record is deleted (soft or hard). * * Pro hooks here to clean up targeting rows: * - adfuz_campaign_target_countries * - adfuz_campaign_target_devices * * Hook: adfuz/campaign/after_delete * * @param int $recordId The deleted campaign ID. */ do_action( 'adfuz/campaign/after_delete', $recordId ); } // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- /** * Persists the campaign_ads pivot rows. * * Reads raw POST directly — these are repeating indexed fields that are * NOT part of the $cleanData schema (they live outside Validator scope). * * @param int $recordId The campaign ID to associate ads with. */ private static function saveCampaignAds( int $recordId ): void { $request = static::request(); $ads = $request->input( 'ads', 'post', array(), fn( $value ) => is_array( $value ) ? $value : array() ); $ad_ids = array_filter( array_map( 'absint', $ads['ad_id'] ?? array() ) ); $display_order = array_map( 'absint', $ads['display_order'] ?? array() ); DB::get( CampaignAd::TABLE ) ->where( 'campaign_id', $recordId ) ->delete(); foreach ( array_values( $ad_ids ) as $index => $ad_id ) { DB::get( CampaignAd::TABLE )->insert( array( 'campaign_id' => $recordId, 'ad_id' => $ad_id, 'display_order' => absint( $display_order[ $index ] ?? $index ), ) ); } } /** * Check expired campaigns and update status */ public static function check_expired_campaigns() { global $wpdb; $now = current_time( 'mysql' ); $expired_campaigns = array(); // Query for active campaigns where end_date has passed. $results = $wpdb->get_results( $wpdb->prepare( "SELECT id, name, end_date FROM {$wpdb->prefix}adfuz_campaigns WHERE status = 'active' AND end_date IS NOT NULL AND end_date < %s", $now ) ); if ( empty( $results ) ) { return; } foreach ( $results as $campaign ) { // Update campaign status to expired. $wpdb->update( $wpdb->prefix . 'adfuz_campaigns', array( 'status' => 'expired', 'updated_at' => $now, ), array( 'id' => $campaign->id ) ); $expired_campaigns[] = array( 'id' => $campaign->id, 'name' => $campaign->name, 'expired_at' => $campaign->end_date, ); } // Store expired campaign data in options table for admin notification. if ( ! empty( $expired_campaigns ) ) { $existing = get_option( 'adfuz_expired_campaigns_notification', array() ); $existing = is_array( $existing ) ? $existing : array(); // Merge new expired campaigns with existing ones, avoiding duplicates. $existing_ids = array_column( $existing, 'id' ); foreach ( $expired_campaigns as $campaign ) { if ( ! in_array( $campaign['id'], $existing_ids ) ) { $existing[] = $campaign; } } update_option( 'adfuz_expired_campaigns_notification', $existing, false ); } } /** * Handle dismiss notification AJAX request */ public static function dismiss_expired_notification() { Common::checkCapability( static::getCapabilities() ); Common::verifyNonce(); $campaign_id = isset( $_POST['campaign_id'] ) ? intval( $_POST['campaign_id'] ) : 0; if ( $campaign_id > 0 ) { // Remove specific campaign from notification. $notifications = get_option( 'adfuz_expired_campaigns_notification', array() ); if ( is_array( $notifications ) ) { $notifications = array_filter( $notifications, function ( $item ) use ( $campaign_id ) { return $item['id'] != $campaign_id; } ); update_option( 'adfuz_expired_campaigns_notification', array_values( $notifications ), false ); } } else { // Clear all notifications. delete_option( 'adfuz_expired_campaigns_notification' ); } wp_send_json_success(); } }