From efbdcce08ac5357be7bf44236dac7ae6af20288f Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Wed, 18 Feb 2026 21:23:53 +0100 Subject: [PATCH] feat: Add XML file management functionality - Created XmlFiles control class for handling XML file views and regeneration. - Implemented method to retrieve clients with XML feeds in the factory class. - Added database migration to include google_merchant_account_id in clients table. - Created migrations for products_keyword_planner_terms and products_merchant_sync_log tables. - Added campaign_keywords table migration for managing campaign keyword data. - Developed main view template for displaying XML files and their statuses. - Introduced a debug script for analyzing product URLs and their statuses. --- TODO.md | 0 autoload/controls/class.CampaignTerms.php | 571 +++++- autoload/controls/class.Clients.php | 2 + autoload/controls/class.Cron.php | 1202 +++++++++-- autoload/controls/class.Products.php | 511 ++++- autoload/controls/class.Users.php | 319 ++- autoload/controls/class.XmlFiles.php | 38 + autoload/factory/class.Campaigns.php | 75 + autoload/factory/class.Products.php | 500 ++++- autoload/factory/class.XmlFiles.php | 43 + autoload/services/class.ClaudeApi.php | 54 +- autoload/services/class.GoogleAdsApi.php | 1821 ++++++++++++++++- autoload/services/class.OpenAiApi.php | 185 +- autoload/view/class.Users.php | 3 +- config.php | 4 + cron.php | 4 + docs/database.sql | 26 + docs/memory.md | 6 + index.php | 1 + layout/style.css | 1680 +-------------- layout/style.css.map | 2 +- layout/style.scss | 1137 +++++++++- libraries/adspro-dialog.css | 7 +- libraries/adspro-dialog.js | 39 +- .../007_clients_merchant_account_id.sql | 2 + .../008_products_keyword_planner_terms.sql | 15 + migrations/009_products_merchant_sync_log.sql | 20 + migrations/010_campaign_keywords.sql | 24 + templates/campaign_terms/main_view.php | 1681 +++++++++++---- templates/clients/main_view.php | 24 +- templates/products/main_view.php | 514 ++++- templates/products/product_history.php | 117 +- templates/site/layout-logged.php | 63 +- templates/users/settings.php | 606 ++++-- templates/xml_files/main_view.php | 67 + tmp/debug_products_urls.php | 30 + 36 files changed, 8778 insertions(+), 2615 deletions(-) create mode 100644 TODO.md create mode 100644 autoload/controls/class.XmlFiles.php create mode 100644 autoload/factory/class.XmlFiles.php create mode 100644 migrations/007_clients_merchant_account_id.sql create mode 100644 migrations/008_products_keyword_planner_terms.sql create mode 100644 migrations/009_products_merchant_sync_log.sql create mode 100644 migrations/010_campaign_keywords.sql create mode 100644 templates/xml_files/main_view.php create mode 100644 tmp/debug_products_urls.php diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..e69de29 diff --git a/autoload/controls/class.CampaignTerms.php b/autoload/controls/class.CampaignTerms.php index 14a07ec..971e79a 100644 --- a/autoload/controls/class.CampaignTerms.php +++ b/autoload/controls/class.CampaignTerms.php @@ -3,6 +3,72 @@ namespace controls; class CampaignTerms { + static private function is_google_ads_debug_enabled() + { + return \services\GoogleAdsApi::get_setting( 'google_ads_debug_enabled' ) !== '0'; + } + + static private function with_optional_debug( $payload, $debug_data ) + { + if ( self::is_google_ads_debug_enabled() ) + { + $payload['debug'] = $debug_data; + } + + return $payload; + } + + static private function normalize_ai_recommendations( $raw_json, $rows_by_id ) + { + $decoded = json_decode( (string) $raw_json, true ); + $items = is_array( $decoded ) && isset( $decoded['items'] ) && is_array( $decoded['items'] ) ? $decoded['items'] : []; + $recommendations = []; + + foreach ( $items as $item ) + { + $id = (int) ( $item['id'] ?? 0 ); + if ( $id <= 0 || !isset( $rows_by_id[$id] ) ) + { + continue; + } + + $action_raw = strtolower( trim( (string) ( $item['action'] ?? '' ) ) ); + $action = $action_raw === 'exclude' ? 'exclude' : 'keep'; + $reason = trim( (string) ( $item['reason'] ?? '' ) ); + if ( $reason === '' ) + { + $reason = $action === 'exclude' ? 'Niska trafnosc lub slabe wyniki.' : 'Fraza zostaje bez zmian.'; + } + + if ( function_exists( 'mb_substr' ) ) + { + $reason = mb_substr( $reason, 0, 120 ); + } + else + { + $reason = substr( $reason, 0, 120 ); + } + + $row = $rows_by_id[$id]; + + $recommendations[] = [ + 'id' => $id, + 'search_term_id' => $id, + 'phrase' => trim( (string) ( $row['search_term'] ?? ( $item['phrase'] ?? '' ) ) ), + 'action' => $action, + 'reason' => $reason, + 'ad_group_name' => (string) ( $row['ad_group_name'] ?? '' ), + 'clicks_all_time' => (float) ( $row['clicks_all_time'] ?? 0 ), + 'cost_all_time' => (float) ( $row['cost_all_time'] ?? 0 ), + 'conversions_all_time' => (float) ( $row['conversions_all_time'] ?? 0 ), + 'conversion_value_all_time' => (float) ( $row['conversion_value_all_time'] ?? 0 ), + 'roas_all_time' => (float) ( $row['roas_all_time'] ?? 0 ) + ]; + } + + return $recommendations; + } + static public function main_view() { return \Tpl::view( 'campaign_terms/main_view', [ @@ -38,13 +104,14 @@ class CampaignTerms if ( $campaign_id <= 0 ) { - echo json_encode( [ 'search_terms' => [], 'negative_keywords' => [] ] ); + echo json_encode( [ 'search_terms' => [], 'negative_keywords' => [], 'keywords' => [] ] ); exit; } echo json_encode( [ 'search_terms' => \factory\Campaigns::get_campaign_search_terms( $campaign_id, $ad_group_id ), - 'negative_keywords' => \factory\Campaigns::get_campaign_negative_keywords( $campaign_id, $ad_group_id ) + 'negative_keywords' => \factory\Campaigns::get_campaign_negative_keywords( $campaign_id, $ad_group_id ), + 'keywords' => \factory\Campaigns::get_campaign_keywords( $campaign_id, $ad_group_id ) ] ); exit; } @@ -58,7 +125,15 @@ class CampaignTerms if ( $search_term_id <= 0 ) { - echo json_encode( [ 'success' => false, 'message' => 'Nie podano frazy do wykluczenia.' ] ); + echo json_encode( self::with_optional_debug( [ + 'success' => false, + 'message' => 'Nie podano frazy do wykluczenia.', + ], [ + 'search_term_id' => $search_term_id, + 'match_type_raw' => (string) \S::get( 'match_type' ), + 'scope_raw' => (string) \S::get( 'scope' ), + 'manual_keyword_text' => $manual_keyword_text + ] ) ); exit; } @@ -74,7 +149,15 @@ class CampaignTerms $context = \factory\Campaigns::get_search_term_context( $search_term_id ); if ( !$context ) { - echo json_encode( [ 'success' => false, 'message' => 'Nie znaleziono danych frazy.' ] ); + echo json_encode( self::with_optional_debug( [ + 'success' => false, + 'message' => 'Nie znaleziono danych frazy.', + ], [ + 'search_term_id' => $search_term_id, + 'manual_keyword_text' => $manual_keyword_text, + 'scope' => $scope, + 'match_type' => $match_type + ] ) ); exit; } @@ -97,26 +180,36 @@ class CampaignTerms if ( $missing_data ) { - echo json_encode( [ + echo json_encode( self::with_optional_debug( [ 'success' => false, 'message' => 'Brak wymaganych danych Google Ads dla tej frazy.', - 'debug' => [ - 'customer_id' => $customer_id, - 'campaign_external_id' => $campaign_external_id, - 'ad_group_external_id' => $ad_group_external_id, - 'keyword_text' => $keyword_text, - 'keyword_source' => $keyword_source, - 'scope' => $scope, - 'context' => $context - ] - ] ); + ], [ + 'customer_id' => $customer_id, + 'campaign_external_id' => $campaign_external_id, + 'ad_group_external_id' => $ad_group_external_id, + 'keyword_text' => $keyword_text, + 'keyword_source' => $keyword_source, + 'scope' => $scope, + 'context' => $context + ] ) ); exit; } $api = new \services\GoogleAdsApi(); if ( !$api -> is_configured() ) { - echo json_encode( [ 'success' => false, 'message' => 'Google Ads API nie jest skonfigurowane.' ] ); + echo json_encode( self::with_optional_debug( [ + 'success' => false, + 'message' => 'Google Ads API nie jest skonfigurowane.', + ], [ + 'search_term_id' => $search_term_id, + 'customer_id' => $customer_id, + 'campaign_external_id' => $campaign_external_id, + 'ad_group_external_id' => $ad_group_external_id, + 'keyword_text' => $keyword_text, + 'match_type' => $match_type, + 'scope' => $scope + ] ) ); exit; } @@ -132,21 +225,46 @@ class CampaignTerms if ( !( $api_result['success'] ?? false ) ) { $last_error = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ); - echo json_encode( [ + echo json_encode( self::with_optional_debug( [ 'success' => false, 'message' => 'Nie udalo sie zapisac frazy wykluczajacej w Google Ads.', - 'error' => $last_error, - 'debug' => [ - 'customer_id' => $customer_id, - 'campaign_external_id' => $campaign_external_id, - 'ad_group_external_id' => $ad_group_external_id, - 'keyword_text' => $keyword_text, - 'keyword_source' => $keyword_source, - 'match_type' => $match_type, - 'scope' => $scope, - 'api_result' => $api_result - ] - ] ); + 'error' => $last_error + ], [ + 'customer_id' => $customer_id, + 'campaign_external_id' => $campaign_external_id, + 'ad_group_external_id' => $ad_group_external_id, + 'keyword_text' => $keyword_text, + 'keyword_source' => $keyword_source, + 'match_type' => $match_type, + 'scope' => $scope, + 'api_result' => $api_result + ] ) ); + exit; + } + + $verification = $api_result['verification'] ?? null; + $verification_found = true; + if ( is_array( $verification ) && array_key_exists( 'found', $verification ) ) + { + $verification_found = (bool) $verification['found']; + } + + if ( !$verification_found && !( $api_result['duplicate'] ?? false ) ) + { + echo json_encode( self::with_optional_debug( [ + 'success' => false, + 'message' => 'Google Ads API nie potwierdzilo dodania frazy po operacji create.', + ], [ + 'customer_id' => $customer_id, + 'campaign_external_id' => $campaign_external_id, + 'ad_group_external_id' => $ad_group_external_id, + 'keyword_text' => $keyword_text, + 'keyword_source' => $keyword_source, + 'match_type' => $match_type, + 'scope' => $scope, + 'api_result' => $api_result, + 'verification' => $verification + ] ) ); exit; } @@ -160,24 +278,395 @@ class CampaignTerms $scope_label = $scope === 'campaign' ? 'kampanii' : 'grupy reklam'; - echo json_encode( [ + echo json_encode( self::with_optional_debug( [ 'success' => true, 'message' => ( $api_result['duplicate'] ?? false ) ? 'Fraza byla juz wykluczona na poziomie ' . $scope_label . '.' : 'Fraza zostala dodana do wykluczajacych na poziomie ' . $scope_label . '.', 'duplicate' => (bool) ( $api_result['duplicate'] ?? false ), 'match_type' => $match_type, + 'scope' => $scope + ], [ + 'customer_id' => $customer_id, + 'campaign_external_id' => $campaign_external_id, + 'ad_group_external_id' => $ad_group_external_id, + 'keyword_text' => $keyword_text, + 'keyword_source' => $keyword_source, 'scope' => $scope, - 'debug' => [ - 'customer_id' => $customer_id, - 'campaign_external_id' => $campaign_external_id, - 'ad_group_external_id' => $ad_group_external_id, - 'keyword_text' => $keyword_text, - 'keyword_source' => $keyword_source, - 'scope' => $scope, - 'api_response' => $api_result['response'] ?? null, - 'sent_operation' => $api_result['sent_operation'] ?? null, - 'verification' => $api_result['verification'] ?? null - ] + 'api_response' => $api_result['response'] ?? null, + 'sent_operation' => $api_result['sent_operation'] ?? null, + 'verification' => $api_result['verification'] ?? null + ] ) ); + exit; + } + + static public function analyze_search_terms_with_ai() + { + $campaign_id = (int) \S::get( 'campaign_id' ); + $ad_group_id = (int) \S::get( 'ad_group_id' ); + $search_term_ids_raw = \S::get( 'search_term_ids' ); + + if ( $campaign_id <= 0 ) + { + echo json_encode( [ 'success' => false, 'message' => 'Wybierz kampanie.' ] ); + exit; + } + + if ( \services\GoogleAdsApi::get_setting( 'openai_enabled' ) === '0' ) + { + echo json_encode( [ 'success' => false, 'message' => 'OpenAI jest wylaczone. Wlacz je w Ustawieniach.' ] ); + exit; + } + + if ( !\services\OpenAiApi::is_configured() ) + { + echo json_encode( [ 'success' => false, 'message' => 'Klucz API OpenAI nie jest skonfigurowany. Przejdz do Ustawien.' ] ); + exit; + } + + $rows = \factory\Campaigns::get_campaign_search_terms( $campaign_id, $ad_group_id ); + + $ids_filter = []; + if ( is_array( $search_term_ids_raw ) ) + { + foreach ( $search_term_ids_raw as $id_raw ) + { + $id = (int) $id_raw; + if ( $id > 0 ) + { + $ids_filter[$id] = true; + } + } + } + elseif ( $search_term_ids_raw !== null && $search_term_ids_raw !== '' ) + { + $id = (int) $search_term_ids_raw; + if ( $id > 0 ) + { + $ids_filter[$id] = true; + } + } + + if ( !empty( $ids_filter ) ) + { + $rows = array_values( array_filter( $rows, function( $row ) use ( $ids_filter ) + { + $id = (int) ( $row['id'] ?? 0 ); + return $id > 0 && isset( $ids_filter[$id] ); + } ) ); + } + + if ( empty( $rows ) ) + { + echo json_encode( [ 'success' => false, 'message' => 'Brak fraz do analizy.' ] ); + exit; + } + + $rows = array_slice( $rows, 0, 150 ); + + $rows_by_id = []; + foreach ( $rows as $row ) + { + $id = (int) ( $row['id'] ?? 0 ); + if ( $id > 0 ) + { + $rows_by_id[$id] = $row; + } + } + + $campaign_name = ''; + $campaign_type = ''; + if ( !empty( $rows ) ) + { + $campaign_name = trim( (string) ( $rows[0]['campaign_name'] ?? '' ) ); + $campaign_type = trim( (string) ( $rows[0]['advertising_channel_type'] ?? '' ) ); + } + + $ad_group_name = ''; + if ( $ad_group_id > 0 && !empty( $rows ) ) + { + $ad_group_name = trim( (string) ( $rows[0]['ad_group_name'] ?? '' ) ); + } + + $result = \services\OpenAiApi::suggest_negative_keywords_to_exclude( $rows, [ + 'campaign_name' => $campaign_name, + 'campaign_type' => $campaign_type, + 'ad_group_name' => $ad_group_name, + 'ad_group_id' => $ad_group_id + ] ); + + if ( ( $result['status'] ?? 'error' ) !== 'ok' ) + { + echo json_encode( [ + 'success' => false, + 'message' => (string) ( $result['message'] ?? 'Blad analizy OpenAI.' ) + ] ); + exit; + } + + $raw_json = (string) ( $result['suggestion'] ?? '' ); + $recommendations = self::normalize_ai_recommendations( $raw_json, $rows_by_id ); + $exclude_count = count( array_filter( $recommendations, function( $item ) + { + return ( $item['action'] ?? '' ) === 'exclude'; + } ) ); + + if ( empty( $recommendations ) ) + { + echo json_encode( [ + 'success' => false, + 'message' => 'Nie udalo sie sparsowac odpowiedzi AI.', + 'raw' => $raw_json + ] ); + exit; + } + + echo json_encode( [ + 'success' => true, + 'message' => 'Analiza zakonczona. Proponowane wykluczenia: ' . $exclude_count . '.', + 'analyzed_count' => count( $rows ), + 'exclude_count' => $exclude_count, + 'recommendations' => $recommendations ] ); exit; } + + static private function delete_negative_keyword_row( $negative_keyword_id ) + { + $negative_keyword_id = (int) $negative_keyword_id; + + if ( $negative_keyword_id <= 0 ) + { + return [ 'success' => false, 'message' => 'Nie podano frazy do usuniecia.' ]; + } + + $context = \factory\Campaigns::get_negative_keyword_context( $negative_keyword_id ); + if ( !$context ) + { + return [ 'success' => false, 'message' => 'Nie znaleziono danych frazy wykluczajacej.' ]; + } + + $customer_id = trim( (string) ( $context['google_ads_customer_id'] ?? '' ) ); + $scope = strtolower( trim( (string) ( $context['scope'] ?? 'campaign' ) ) ); + $match_type = strtoupper( trim( (string) ( $context['match_type'] ?? 'PHRASE' ) ) ); + $keyword_text = trim( (string) ( $context['keyword_text'] ?? '' ) ); + $campaign_external_id = trim( (string) ( $context['external_campaign_id'] ?? '' ) ); + $ad_group_external_id = trim( (string) ( $context['external_ad_group_id'] ?? '' ) ); + + if ( !in_array( $scope, [ 'campaign', 'ad_group' ], true ) ) + { + $scope = 'campaign'; + } + + $missing_data = ( $customer_id === '' || $keyword_text === '' ); + if ( $scope === 'campaign' && $campaign_external_id === '' ) + { + $missing_data = true; + } + if ( $scope === 'ad_group' && $ad_group_external_id === '' ) + { + $missing_data = true; + } + + if ( $missing_data ) + { + return self::with_optional_debug( [ + 'success' => false, + 'message' => 'Brak wymaganych danych Google Ads dla tej frazy.' + ], [ + 'context' => $context + ] ); + } + + $api = new \services\GoogleAdsApi(); + if ( !$api -> is_configured() ) + { + return self::with_optional_debug( [ + 'success' => false, + 'message' => 'Google Ads API nie jest skonfigurowane.' + ], [ + 'customer_id' => $customer_id, + 'scope' => $scope, + 'campaign_external_id' => $campaign_external_id, + 'ad_group_external_id' => $ad_group_external_id, + 'keyword_text' => $keyword_text, + 'match_type' => $match_type + ] ); + } + + if ( $scope === 'campaign' ) + { + $api_result = $api -> remove_negative_keyword_from_campaign( $customer_id, $campaign_external_id, $keyword_text, $match_type ); + } + else + { + $api_result = $api -> remove_negative_keyword_from_ad_group( $customer_id, $ad_group_external_id, $keyword_text, $match_type ); + } + + if ( !( $api_result['success'] ?? false ) ) + { + $last_error = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ); + return self::with_optional_debug( [ + 'success' => false, + 'message' => 'Nie udalo sie usunac frazy wykluczajacej w Google Ads.', + 'error' => $last_error + ], [ + 'customer_id' => $customer_id, + 'scope' => $scope, + 'campaign_external_id' => $campaign_external_id, + 'ad_group_external_id' => $ad_group_external_id, + 'keyword_text' => $keyword_text, + 'match_type' => $match_type, + 'api_result' => $api_result + ] ); + } + + \factory\Campaigns::delete_campaign_negative_keyword( $negative_keyword_id ); + + $removed = (int) ( $api_result['removed'] ?? 0 ); + $scope_label = $scope === 'campaign' ? 'kampanii' : 'grupy reklam'; + $message = $removed > 0 + ? 'Fraza zostala usunieta z wykluczajacych na poziomie ' . $scope_label . '.' + : 'Fraza nie byla juz obecna w Google Ads. Usunieto lokalny wpis.'; + + return self::with_optional_debug( [ + 'success' => true, + 'message' => $message, + 'removed' => $removed, + 'negative_keyword_id' => $negative_keyword_id + ], [ + 'customer_id' => $customer_id, + 'scope' => $scope, + 'campaign_external_id' => $campaign_external_id, + 'ad_group_external_id' => $ad_group_external_id, + 'keyword_text' => $keyword_text, + 'match_type' => $match_type, + 'api_result' => $api_result + ] ); + } + + static public function delete_negative_keyword() + { + $negative_keyword_id = (int) \S::get( 'negative_keyword_id' ); + $result = self::delete_negative_keyword_row( $negative_keyword_id ); + echo json_encode( $result ); + exit; + } + + static public function delete_negative_keywords() + { + $negative_keyword_ids_raw = \S::get( 'negative_keyword_ids' ); + + if ( !is_array( $negative_keyword_ids_raw ) ) + { + $negative_keyword_ids_raw = [ $negative_keyword_ids_raw ]; + } + + $negative_keyword_ids = []; + foreach ( $negative_keyword_ids_raw as $id_raw ) + { + $id = (int) $id_raw; + if ( $id > 0 ) + { + $negative_keyword_ids[] = $id; + } + } + + $negative_keyword_ids = array_values( array_unique( $negative_keyword_ids ) ); + + if ( empty( $negative_keyword_ids ) ) + { + echo json_encode( [ 'success' => false, 'message' => 'Nie podano fraz do usuniecia.' ] ); + exit; + } + + $deleted_count = 0; + $failed = []; + $debug = []; + $total_count = count( $negative_keyword_ids ); + + foreach ( $negative_keyword_ids as $negative_keyword_id ) + { + $result = self::delete_negative_keyword_row( $negative_keyword_id ); + + if ( $result['success'] ?? false ) + { + $deleted_count++; + + $debug[] = [ + 'id' => $negative_keyword_id, + 'success' => true, + 'message' => (string) ( $result['message'] ?? '' ), + 'debug' => $result['debug'] ?? null + ]; + continue; + } + + $failed[] = [ + 'id' => $negative_keyword_id, + 'message' => (string) ( $result['message'] ?? 'Nieznany blad' ), + 'error' => (string) ( $result['error'] ?? '' ), + 'debug' => $result['debug'] ?? null + ]; + + $debug[] = [ + 'id' => $negative_keyword_id, + 'success' => false, + 'message' => (string) ( $result['message'] ?? 'Nieznany blad' ), + 'error' => (string) ( $result['error'] ?? '' ), + 'debug' => $result['debug'] ?? null + ]; + } + + $failed_count = count( $failed ); + + if ( $deleted_count === $total_count ) + { + $response = [ + 'success' => true, + 'message' => 'Usunieto zaznaczone frazy wykluczajace (' . $deleted_count . ').', + 'deleted_count' => $deleted_count, + 'failed_count' => 0 + ]; + if ( self::is_google_ads_debug_enabled() ) + { + $response['debug'] = $debug; + } + + echo json_encode( $response ); + exit; + } + + if ( $deleted_count > 0 ) + { + $response = [ + 'success' => true, + 'partial' => true, + 'message' => 'Usunieto ' . $deleted_count . ' z ' . $total_count . ' zaznaczonych fraz wykluczajacych.', + 'deleted_count' => $deleted_count, + 'failed_count' => $failed_count, + 'failed' => $failed + ]; + if ( self::is_google_ads_debug_enabled() ) + { + $response['debug'] = $debug; + } + + echo json_encode( $response ); + exit; + } + + $response = [ + 'success' => false, + 'message' => 'Nie udalo sie usunac zaznaczonych fraz wykluczajacych.', + 'deleted_count' => 0, + 'failed_count' => $failed_count, + 'failed' => $failed + ]; + if ( self::is_google_ads_debug_enabled() ) + { + $response['debug'] = $debug; + } + + echo json_encode( $response ); + exit; + } } diff --git a/autoload/controls/class.Clients.php b/autoload/controls/class.Clients.php index 9f041fb..e096f7d 100644 --- a/autoload/controls/class.Clients.php +++ b/autoload/controls/class.Clients.php @@ -15,6 +15,7 @@ class Clients $id = \S::get( 'id' ); $name = trim( \S::get( 'name' ) ); $google_ads_customer_id = trim( \S::get( 'google_ads_customer_id' ) ); + $google_merchant_account_id = trim( \S::get( 'google_merchant_account_id' ) ); if ( !$name ) { @@ -28,6 +29,7 @@ class Clients $data = [ 'name' => $name, 'google_ads_customer_id' => $google_ads_customer_id ?: null, + 'google_merchant_account_id' => $google_merchant_account_id ?: null, 'google_ads_start_date' => $google_ads_start_date ?: null, ]; diff --git a/autoload/controls/class.Cron.php b/autoload/controls/class.Cron.php index b01a3a2..7ba573e 100644 --- a/autoload/controls/class.Cron.php +++ b/autoload/controls/class.Cron.php @@ -4,7 +4,8 @@ class Cron { static public function cron_products() { - global $mdb; + global $mdb, $settings; + self::touch_cron_invocation( __FUNCTION__ ); $api = new \services\GoogleAdsApi(); if ( !$api -> is_configured() ) @@ -90,6 +91,19 @@ class Cron $done_key = 'fetch_done_ids'; } + $clients_per_run_default = (int) ( $settings['cron_products_clients_per_run'] ?? 10 ); + if ( $clients_per_run_default <= 0 ) + { + $clients_per_run_default = 10; + } + + $clients_per_run = (int) \S::get( 'clients_per_run' ); + if ( $clients_per_run <= 0 ) + { + $clients_per_run = $clients_per_run_default; + } + $clients_per_run = min( 100, $clients_per_run ); + $next_client_id = self::pick_next_client_id( $client_ids, $state[ $done_key ] ); if ( !$next_client_id ) @@ -108,60 +122,313 @@ class Cron exit; } - $selected_client = $mdb -> get( 'clients', '*', [ 'id' => $next_client_id ] ); - if ( !$selected_client ) + $clients_processed_in_call = []; + $errors = []; + $processed_products_total = 0; + $skipped_total = 0; + $history_30_products_total = 0; + $products_temp_rows_total = 0; + + $processed_now = 0; + while ( $processed_now < $clients_per_run ) { - echo json_encode( [ 'result' => 'Nie udalo sie wybrac klienta do synchronizacji produktow.', 'client_id' => $next_client_id ] ); - exit; + $next_client_id = self::pick_next_client_id( $client_ids, $state[ $done_key ] ); + if ( !$next_client_id ) + { + break; + } + + $selected_client = $mdb -> get( 'clients', '*', [ 'id' => $next_client_id ] ); + if ( !$selected_client ) + { + $errors[] = 'Nie udalo sie wybrac klienta do synchronizacji produktow. ID: ' . $next_client_id; + $state[ $done_key ][] = (int) $next_client_id; + $state[ $done_key ] = array_values( array_unique( array_map( 'intval', $state[ $done_key ] ) ) ); + $processed_now++; + continue; + } + + if ( $state['phase'] === 'fetch' ) + { + $sync = self::sync_products_fetch_for_client( $selected_client, $api, $state['import_date'] ); + $processed_products_total += (int) ( $sync['processed_products'] ?? 0 ); + $skipped_total += (int) ( $sync['skipped'] ?? 0 ); + if ( !empty( $sync['errors'] ) ) + { + $errors = array_merge( $errors, (array) $sync['errors'] ); + } + } + else if ( $state['phase'] === 'aggregate_30' ) + { + $history_30_products_total += (int) self::aggregate_products_history_30_for_client( (int) $selected_client['id'], $state['import_date'] ); + } + else if ( $state['phase'] === 'aggregate_temp' ) + { + $products_temp_rows_total += (int) self::rebuild_products_temp_for_client( (int) $selected_client['id'] ); + } + + $clients_processed_in_call[] = (int) $next_client_id; + + // Oznaczamy klienta jako przetworzonego rowniez po bledzie, aby nie zapetlac wywolan. + $state[ $done_key ][] = (int) $next_client_id; + $state[ $done_key ] = array_values( array_unique( array_map( 'intval', $state[ $done_key ] ) ) ); + $processed_now++; } - $response = [ - 'result' => '', + self::save_products_pipeline_state( $state_key, $state ); + + $processed_in_phase = count( array_intersect( $client_ids, $state[ $done_key ] ) ); + $remaining_in_phase = max( 0, count( $client_ids ) - $processed_in_phase ); + $estimated_calls_remaining_in_phase = (int) ceil( $remaining_in_phase / max( 1, $clients_per_run ) ); + + $result_text = 'Brak klientow do przetworzenia w tej fazie.'; + if ( $state['phase'] === 'fetch' ) + { + $result_text = empty( $errors ) ? 'Pobieranie produktow zakonczone.' : 'Pobieranie produktow zakonczone z bledami.'; + } + else if ( $state['phase'] === 'aggregate_30' ) + { + $result_text = 'Pierwsza agregacja (history_30) zakonczona.'; + } + else if ( $state['phase'] === 'aggregate_temp' ) + { + $result_text = 'Druga agregacja (products_temp) zakonczona.'; + } + + echo json_encode( [ + 'result' => $result_text, 'date' => $date, 'active_date' => $state['import_date'], 'conversion_window_days' => (int) ( $state['conversion_window_days'] ?? $conversion_window_days ), 'dates_synced' => $state['import_dates'] ?? $import_dates, 'phase' => $state['phase'], - 'client_id' => (int) $selected_client['id'], - 'errors' => [] - ]; - - if ( $state['phase'] === 'fetch' ) - { - $sync = self::sync_products_fetch_for_client( $selected_client, $api, $state['import_date'] ); - $response['result'] = empty( $sync['errors'] ) ? 'Pobieranie produktow zakonczone.' : 'Pobieranie produktow zakonczone z bledami.'; - $response['processed_products'] = (int) $sync['processed_products']; - $response['skipped'] = (int) $sync['skipped']; - $response['errors'] = $sync['errors']; - } - else if ( $state['phase'] === 'aggregate_30' ) - { - $history_30 = self::aggregate_products_history_30_for_client( (int) $selected_client['id'], $state['import_date'] ); - $response['result'] = 'Pierwsza agregacja (history_30) zakonczona.'; - $response['history_30_products'] = (int) $history_30; - } - else if ( $state['phase'] === 'aggregate_temp' ) - { - $temp_rows = self::rebuild_products_temp_for_client( (int) $selected_client['id'] ); - $response['result'] = 'Druga agregacja (products_temp) zakonczona.'; - $response['products_temp_rows'] = (int) $temp_rows; - } - - // Oznaczamy klienta jako przetworzonego rowniez po bledzie, aby nie zapetlac wywolan. - $state[ $done_key ][] = (int) $next_client_id; - $state[ $done_key ] = array_values( array_unique( array_map( 'intval', $state[ $done_key ] ) ) ); - self::save_products_pipeline_state( $state_key, $state ); - - $processed_in_phase = count( array_intersect( $client_ids, $state[ $done_key ] ) ); - - $response['processed_clients_in_phase'] = $processed_in_phase; - $response['remaining_clients_in_phase'] = max( 0, count( $client_ids ) - $processed_in_phase ); - $response['total_clients'] = count( $client_ids ); - - echo json_encode( $response ); + 'clients_per_run' => $clients_per_run, + 'processed_clients_in_call' => count( $clients_processed_in_call ), + 'client_ids_processed_in_call' => $clients_processed_in_call, + 'processed_clients_in_phase' => $processed_in_phase, + 'remaining_clients_in_phase' => $remaining_in_phase, + 'estimated_calls_remaining_in_phase' => $estimated_calls_remaining_in_phase, + 'total_clients' => count( $client_ids ), + 'processed_products' => $processed_products_total, + 'skipped' => $skipped_total, + 'history_30_products' => $history_30_products_total, + 'products_temp_rows' => $products_temp_rows_total, + 'errors' => $errors + ] ); exit; } + static public function cron_products_urls() + { + global $mdb; + self::touch_cron_invocation( __FUNCTION__ ); + + $api = new \services\GoogleAdsApi(); + if ( !$api -> is_merchant_configured() ) + { + echo json_encode( [ + 'result' => 'Merchant API nie jest skonfigurowane. Uzupelnij OAuth2 Client ID/Secret oraz Merchant Refresh Token w Ustawieniach.' + ] ); + exit; + } + + $client_id = (int) \S::get( 'client_id' ); + $batch_limit = (int) \S::get( 'limit' ); + $debug_mode = (int) \S::get( 'debug' ) === 1; + if ( $batch_limit <= 0 ) + { + $batch_limit = 300; + } + $batch_limit = min( 1000, $batch_limit ); + + $where = "deleted = 0 AND google_merchant_account_id IS NOT NULL AND google_merchant_account_id <> ''"; + if ( $client_id > 0 ) + { + $where .= ' AND id = ' . $client_id; + } + + $clients = $mdb -> query( 'SELECT id, name, google_merchant_account_id FROM clients WHERE ' . $where . ' ORDER BY id ASC' ) -> fetchAll( \PDO::FETCH_ASSOC ); + + if ( !is_array( $clients ) || empty( $clients ) ) + { + echo json_encode( [ + 'result' => 'Brak klientow z ustawionym Merchant Account ID.', + 'processed_clients' => 0, + 'checked_products' => 0, + 'updated_urls' => 0, + 'errors' => [] + ] ); + exit; + } + + $checked_products = 0; + $updated_urls = 0; + $unresolved_products = 0; + $processed_clients = 0; + $errors = []; + $details = []; + + foreach ( $clients as $client ) + { + $processed_clients++; + $selected_products = self::get_products_missing_url_for_client( (int) $client['id'], $batch_limit ); + $product_count = count( $selected_products ); + $diag = $debug_mode ? self::get_products_url_sync_diagnostics_for_client( (int) $client['id'] ) : null; + + if ( $product_count === 0 ) + { + $detail_row = [ + 'client_id' => (int) $client['id'], + 'client_name' => (string) $client['name'], + 'merchant_account_id' => (string) $client['google_merchant_account_id'], + 'selected_products' => 0, + 'updated_urls' => 0, + 'unresolved_products' => 0 + ]; + + if ( $debug_mode ) + { + $detail_row['diag'] = $diag; + } + + $details[] = $detail_row; + continue; + } + $checked_products += $product_count; + + $offer_ids = []; + foreach ( $selected_products as $row ) + { + $offer_ids[] = (string) $row['offer_id']; + } + + $links_map = $api -> get_merchant_product_links_for_offer_ids( (string) $client['google_merchant_account_id'], $offer_ids ); + if ( $links_map === false ) + { + $last_err = (string) \services\GoogleAdsApi::get_setting( 'google_merchant_last_error' ); + $errors[] = 'Blad Merchant API dla klienta ' . $client['name'] . ' (ID: ' . $client['id'] . '): ' . $last_err; + $unresolved_products += $product_count; + + $detail_row = [ + 'client_id' => (int) $client['id'], + 'client_name' => (string) $client['name'], + 'merchant_account_id' => (string) $client['google_merchant_account_id'], + 'selected_products' => $product_count, + 'updated_urls' => 0, + 'unresolved_products' => $product_count + ]; + + if ( $debug_mode ) + { + $detail_row['diag'] = $diag; + } + + $details[] = $detail_row; + continue; + } + + $client_updated = 0; + foreach ( $selected_products as $row ) + { + $offer_id = (string) $row['offer_id']; + if ( !isset( $links_map[ $offer_id ] ) ) + { + continue; + } + + \factory\Products::set_product_data( (int) $row['product_id'], 'product_url', (string) $links_map[ $offer_id ] ); + $client_updated++; + } + + $updated_urls += $client_updated; + $client_unresolved = max( 0, $product_count - $client_updated ); + $unresolved_products += $client_unresolved; + + $detail_row = [ + 'client_id' => (int) $client['id'], + 'client_name' => (string) $client['name'], + 'merchant_account_id' => (string) $client['google_merchant_account_id'], + 'selected_products' => $product_count, + 'updated_urls' => $client_updated, + 'unresolved_products' => $client_unresolved + ]; + + if ( $debug_mode ) + { + $detail_row['diag'] = $diag; + } + + $details[] = $detail_row; + } + + echo json_encode( [ + 'result' => empty( $errors ) ? 'Synchronizacja URL produktow zakonczona.' : 'Synchronizacja URL produktow zakonczona z bledami.', + 'processed_clients' => $processed_clients, + 'checked_products' => $checked_products, + 'updated_urls' => $updated_urls, + 'unresolved_products' => $unresolved_products, + 'errors' => $errors, + 'details' => $details + ] ); + exit; + } + + static private function get_products_url_sync_diagnostics_for_client( $client_id ) + { + global $mdb; + + $client_id = (int) $client_id; + if ( $client_id <= 0 ) + { + return []; + } + + $diag = []; + + $diag['products_total'] = (int) $mdb -> query( 'SELECT COUNT(*) FROM products WHERE client_id = ' . $client_id ) -> fetchColumn(); + $diag['products_not_deleted'] = (int) $mdb -> query( 'SELECT COUNT(*) FROM products WHERE client_id = ' . $client_id ) -> fetchColumn(); + $diag['products_with_offer_id'] = (int) $mdb -> query( 'SELECT COUNT(*) FROM products WHERE client_id = ' . $client_id . ' AND TRIM( COALESCE( offer_id, "" ) ) <> ""' ) -> fetchColumn(); + $diag['products_with_pd_rows'] = (int) $mdb -> query( 'SELECT COUNT( DISTINCT pd.product_id ) FROM products_data pd INNER JOIN products p ON p.id = pd.product_id WHERE p.client_id = ' . $client_id ) -> fetchColumn(); + $diag['products_with_real_url'] = (int) $mdb -> query( 'SELECT COUNT(*) FROM products p LEFT JOIN ( SELECT product_id, MAX( CASE WHEN TRIM( COALESCE( product_url, \"\" ) ) = \"\" THEN 0 WHEN LOWER( TRIM( product_url ) ) IN ( \"0\", \"-\", \"null\" ) THEN 0 ELSE 1 END ) AS has_real_url FROM products_data GROUP BY product_id ) pd ON pd.product_id = p.id WHERE p.client_id = ' . $client_id . ' AND TRIM( COALESCE( p.offer_id, \"\" ) ) <> \"\" AND COALESCE( pd.has_real_url, 0 ) = 1' ) -> fetchColumn(); + $diag['products_missing_url'] = (int) $mdb -> query( 'SELECT COUNT(*) FROM products p LEFT JOIN ( SELECT product_id, MAX( CASE WHEN TRIM( COALESCE( product_url, \"\" ) ) = \"\" THEN 0 WHEN LOWER( TRIM( product_url ) ) IN ( \"0\", \"-\", \"null\" ) THEN 0 ELSE 1 END ) AS has_real_url FROM products_data GROUP BY product_id ) pd ON pd.product_id = p.id WHERE p.client_id = ' . $client_id . ' AND TRIM( COALESCE( p.offer_id, \"\" ) ) <> \"\" AND COALESCE( pd.has_real_url, 0 ) = 0' ) -> fetchColumn(); + + return $diag; + } + + static private function get_products_missing_url_for_client( $client_id, $limit ) + { + global $mdb; + + $client_id = (int) $client_id; + $limit = max( 1, min( 1000, (int) $limit ) ); + + if ( $client_id <= 0 ) + { + return []; + } + + $sql = 'SELECT p.id AS product_id, p.offer_id ' + . 'FROM products p ' + . 'LEFT JOIN ( ' + . ' SELECT product_id, ' + . ' MAX( CASE ' + . ' WHEN TRIM( COALESCE( product_url, \'\' ) ) = \'\' THEN 0 ' + . ' WHEN LOWER( TRIM( product_url ) ) IN ( \'0\', \'-\', \'null\' ) THEN 0 ' + . ' ELSE 1 ' + . ' END ) AS has_real_url ' + . ' FROM products_data ' + . ' GROUP BY product_id ' + . ') pd ON pd.product_id = p.id ' + . 'WHERE p.client_id = ' . $client_id . ' ' + . 'AND TRIM( COALESCE( p.offer_id, \'\' ) ) <> \'\' ' + . 'AND COALESCE( pd.has_real_url, 0 ) = 0 ' + . 'ORDER BY p.id ASC ' + . 'LIMIT ' . $limit; + + $rows = $mdb -> query( $sql ) -> fetchAll( \PDO::FETCH_ASSOC ); + return is_array( $rows ) ? $rows : []; + } + static private function init_products_pipeline_state( $date, $client_ids, $import_dates ) { $import_dates = array_values( array_unique( array_map( function( $item ) @@ -205,11 +472,44 @@ class Cron return self::init_products_pipeline_state( $expected_date, $client_ids, $expected_dates ); } - if ( ( $state['anchor_date'] ?? '' ) !== $expected_date || ( $state['clients_hash'] ?? '' ) !== $expected_hash ) + if ( ( $state['anchor_date'] ?? '' ) !== $expected_date ) { return self::init_products_pipeline_state( $expected_date, $client_ids, $expected_dates ); } + $state_dates = array_values( array_unique( array_map( function( $item ) + { + return date( 'Y-m-d', strtotime( $item ) ); + }, (array) ( $state['import_dates'] ?? [] ) ) ) ); + sort( $state_dates ); + + // Gdy zmienia sie lista klientow (np. odblokowany klient), nie resetujemy calego pipeline. + // Zachowujemy postep dla juz przetworzonych i dopinamy nowych klientow do kolejki. + if ( ( $state['clients_hash'] ?? '' ) !== $expected_hash ) + { + if ( $state_dates !== $expected_dates ) + { + return self::init_products_pipeline_state( $expected_date, $client_ids, $expected_dates ); + } + + $allowed_client_ids = array_fill_keys( array_map( 'intval', $client_ids ), true ); + foreach ( [ 'fetch_done_ids', 'aggregate_30_done_ids', 'aggregate_temp_done_ids' ] as $key ) + { + $filtered_ids = []; + foreach ( (array) ( $state[ $key ] ?? [] ) as $id ) + { + $id = (int) $id; + if ( $id > 0 && isset( $allowed_client_ids[ $id ] ) ) + { + $filtered_ids[] = $id; + } + } + $state[ $key ] = array_values( array_unique( $filtered_ids ) ); + } + + $state['clients_hash'] = $expected_hash; + } + if ( !isset( $state['import_dates'] ) || !is_array( $state['import_dates'] ) || empty( $state['import_dates'] ) ) { $state['import_dates'] = $expected_dates; @@ -303,6 +603,286 @@ class Cron $products = []; } + $existing_products_rows = $mdb -> query( + 'SELECT id, offer_id, name + FROM products + WHERE client_id = :client_id + ORDER BY id ASC', + [ ':client_id' => $client_id ] + ) -> fetchAll( \PDO::FETCH_ASSOC ); + + $products_by_offer_id = []; + foreach ( $existing_products_rows as $row ) + { + $offer_id = trim( (string) ( $row['offer_id'] ?? '' ) ); + if ( $offer_id === '' || isset( $products_by_offer_id[ $offer_id ] ) ) + { + continue; + } + + $products_by_offer_id[ $offer_id ] = [ + 'id' => (int) ( $row['id'] ?? 0 ), + 'name' => (string) ( $row['name'] ?? '' ) + ]; + } + + $products_data_rows = $mdb -> query( + 'SELECT pd.id, pd.product_id, pd.product_url + FROM products_data AS pd + INNER JOIN products AS p ON p.id = pd.product_id + WHERE p.client_id = :client_id', + [ ':client_id' => $client_id ] + ) -> fetchAll( \PDO::FETCH_ASSOC ); + + $products_data_map = []; + foreach ( $products_data_rows as $row ) + { + $product_id = (int) ( $row['product_id'] ?? 0 ); + if ( $product_id <= 0 || isset( $products_data_map[ $product_id ] ) ) + { + continue; + } + + $products_data_map[ $product_id ] = [ + 'exists' => true, + 'product_url' => trim( (string) ( $row['product_url'] ?? '' ) ) + ]; + } + + $existing_campaigns_rows = $mdb -> query( + 'SELECT id, campaign_id, campaign_name + FROM campaigns + WHERE client_id = :client_id', + [ ':client_id' => $client_id ] + ) -> fetchAll( \PDO::FETCH_ASSOC ); + + $campaigns_by_external_id = []; + $campaigns_by_db_id = []; + foreach ( $existing_campaigns_rows as $row ) + { + $db_campaign_id = (int) ( $row['id'] ?? 0 ); + $external_campaign_id = (int) ( $row['campaign_id'] ?? 0 ); + + if ( $db_campaign_id <= 0 ) + { + continue; + } + + $campaign_data = [ + 'id' => $db_campaign_id, + 'campaign_name' => (string) ( $row['campaign_name'] ?? '' ) + ]; + + if ( !isset( $campaigns_by_external_id[ $external_campaign_id ] ) ) + { + $campaigns_by_external_id[ $external_campaign_id ] = $campaign_data; + } + + $campaigns_by_db_id[ $db_campaign_id ] = $campaign_data; + } + + $existing_campaign_histories = $mdb -> query( + 'SELECT ch.campaign_id + FROM campaigns_history AS ch + INNER JOIN campaigns AS c ON c.id = ch.campaign_id + WHERE c.client_id = :client_id + AND ch.date_add = :date_add', + [ + ':client_id' => $client_id, + ':date_add' => $date + ] + ) -> fetchAll( \PDO::FETCH_COLUMN ); + + $campaign_history_exists = []; + foreach ( (array) $existing_campaign_histories as $history_campaign_id ) + { + $campaign_history_exists[ (int) $history_campaign_id ] = true; + } + + $existing_ad_groups_rows = $mdb -> query( + 'SELECT ag.id, ag.campaign_id, ag.ad_group_id, ag.ad_group_name + FROM campaign_ad_groups AS ag + INNER JOIN campaigns AS c ON c.id = ag.campaign_id + WHERE c.client_id = :client_id', + [ ':client_id' => $client_id ] + ) -> fetchAll( \PDO::FETCH_ASSOC ); + + $ad_groups_by_scope = []; + foreach ( $existing_ad_groups_rows as $row ) + { + $db_campaign_id = (int) ( $row['campaign_id'] ?? 0 ); + $external_ad_group_id = (int) ( $row['ad_group_id'] ?? 0 ); + $db_ad_group_id = (int) ( $row['id'] ?? 0 ); + + if ( $db_campaign_id <= 0 || $db_ad_group_id <= 0 ) + { + continue; + } + + $scope_key = $db_campaign_id . '|' . $external_ad_group_id; + if ( isset( $ad_groups_by_scope[ $scope_key ] ) ) + { + continue; + } + + $ad_groups_by_scope[ $scope_key ] = [ + 'id' => $db_ad_group_id, + 'ad_group_name' => (string) ( $row['ad_group_name'] ?? '' ) + ]; + } + + $existing_history_rows = $mdb -> query( + 'SELECT ph.product_id, ph.campaign_id, ph.ad_group_id, ph.impressions, ph.clicks, ph.cost, ph.conversions, ph.conversions_value + FROM products_history AS ph + INNER JOIN products AS p ON p.id = ph.product_id + WHERE p.client_id = :client_id + AND ph.date_add = :date_add', + [ + ':client_id' => $client_id, + ':date_add' => $date + ] + ) -> fetchAll( \PDO::FETCH_ASSOC ); + + $history_by_scope = []; + foreach ( $existing_history_rows as $row ) + { + $history_key = (int) ( $row['product_id'] ?? 0 ) . '|' . (int) ( $row['campaign_id'] ?? 0 ) . '|' . (int) ( $row['ad_group_id'] ?? 0 ); + $history_by_scope[ $history_key ] = [ + 'impressions' => (int) ( $row['impressions'] ?? 0 ), + 'clicks' => (int) ( $row['clicks'] ?? 0 ), + 'cost' => (float) ( $row['cost'] ?? 0 ), + 'conversions' => (float) ( $row['conversions'] ?? 0 ), + 'conversions_value' => (float) ( $row['conversions_value'] ?? 0 ) + ]; + } + + $resolve_scope_ids = function( $campaign_external_id, $campaign_name, $ad_group_external_id, $ad_group_name ) use ( &$campaigns_by_external_id, &$campaigns_by_db_id, &$campaign_history_exists, &$ad_groups_by_scope, $client_id, $date, $mdb ) + { + $campaign_external_id = (int) $campaign_external_id; + $campaign_name = trim( (string) $campaign_name ); + $ad_group_external_id = (int) $ad_group_external_id; + $ad_group_name = trim( (string) $ad_group_name ); + + $campaign_data = $campaigns_by_external_id[ $campaign_external_id ] ?? null; + if ( !$campaign_data ) + { + $campaign_name_to_save = $campaign_name; + if ( $campaign_name_to_save === '' ) + { + $campaign_name_to_save = $campaign_external_id > 0 ? 'Kampania #' . $campaign_external_id : '--- konto ---'; + } + + $mdb -> insert( 'campaigns', [ + 'client_id' => $client_id, + 'campaign_id' => $campaign_external_id, + 'campaign_name' => $campaign_name_to_save + ] ); + + $db_campaign_id = (int) $mdb -> id(); + + $campaign_data = [ + 'id' => $db_campaign_id, + 'campaign_name' => $campaign_name_to_save + ]; + + $campaigns_by_external_id[ $campaign_external_id ] = $campaign_data; + $campaigns_by_db_id[ $db_campaign_id ] = $campaign_data; + } + else if ( $campaign_name !== '' && $campaign_name !== (string) ( $campaign_data['campaign_name'] ?? '' ) ) + { + $mdb -> update( 'campaigns', [ 'campaign_name' => $campaign_name ], [ 'id' => (int) $campaign_data['id'] ] ); + $campaign_data['campaign_name'] = $campaign_name; + $campaigns_by_external_id[ $campaign_external_id ] = $campaign_data; + $campaigns_by_db_id[ (int) $campaign_data['id'] ] = $campaign_data; + } + + $db_campaign_id = (int) ( $campaign_data['id'] ?? 0 ); + + if ( $db_campaign_id > 0 && !isset( $campaign_history_exists[ $db_campaign_id ] ) ) + { + $mdb -> insert( 'campaigns_history', [ + 'campaign_id' => $db_campaign_id, + 'roas_30_days' => 0, + 'roas_all_time' => 0, + 'budget' => 0, + 'money_spent' => 0, + 'conversion_value' => 0, + 'bidding_strategy' => '', + 'date_add' => $date + ] ); + + $campaign_history_exists[ $db_campaign_id ] = true; + } + + if ( $db_campaign_id <= 0 ) + { + return [ 'campaign_id' => 0, 'ad_group_id' => 0 ]; + } + + if ( $ad_group_external_id <= 0 ) + { + $scope_key = $db_campaign_id . '|0'; + if ( !isset( $ad_groups_by_scope[ $scope_key ] ) ) + { + $db_ad_group_id = (int) self::ensure_campaign_level_ad_group( $db_campaign_id, $date ); + $ad_groups_by_scope[ $scope_key ] = [ + 'id' => $db_ad_group_id, + 'ad_group_name' => '--- kampania (brak grupy reklam) ---' + ]; + } + + return [ + 'campaign_id' => $db_campaign_id, + 'ad_group_id' => (int) ( $ad_groups_by_scope[ $scope_key ]['id'] ?? 0 ) + ]; + } + + $scope_key = $db_campaign_id . '|' . $ad_group_external_id; + $ad_group_data = $ad_groups_by_scope[ $scope_key ] ?? null; + + if ( !$ad_group_data ) + { + $ad_group_name_to_save = $ad_group_name !== '' ? $ad_group_name : 'Ad group #' . $ad_group_external_id; + + $mdb -> insert( 'campaign_ad_groups', [ + 'campaign_id' => $db_campaign_id, + 'ad_group_id' => $ad_group_external_id, + 'ad_group_name' => $ad_group_name_to_save, + 'impressions_30' => 0, + 'clicks_30' => 0, + 'cost_30' => 0, + 'conversions_30' => 0, + 'conversion_value_30' => 0, + 'roas_30' => 0, + 'impressions_all_time' => 0, + 'clicks_all_time' => 0, + 'cost_all_time' => 0, + 'conversions_all_time' => 0, + 'conversion_value_all_time' => 0, + 'roas_all_time' => 0, + 'date_sync' => $date + ] ); + + $ad_group_data = [ + 'id' => (int) $mdb -> id(), + 'ad_group_name' => $ad_group_name_to_save + ]; + + $ad_groups_by_scope[ $scope_key ] = $ad_group_data; + } + else if ( $ad_group_name !== '' && $ad_group_name !== (string) ( $ad_group_data['ad_group_name'] ?? '' ) ) + { + $mdb -> update( 'campaign_ad_groups', [ 'ad_group_name' => $ad_group_name ], [ 'id' => (int) $ad_group_data['id'] ] ); + $ad_group_data['ad_group_name'] = $ad_group_name; + $ad_groups_by_scope[ $scope_key ] = $ad_group_data; + } + + return [ + 'campaign_id' => $db_campaign_id, + 'ad_group_id' => (int) ( $ad_group_data['id'] ?? 0 ) + ]; + }; + $processed = 0; $skipped = 0; $touched_product_ids = []; @@ -322,23 +902,32 @@ class Cron $product_title = $offer_external_id; } - if ( !$mdb -> count( 'products', [ 'AND' => [ 'client_id' => $client_id, 'offer_id' => $offer_external_id ] ] ) ) + $existing_product = $products_by_offer_id[ $offer_external_id ] ?? null; + + if ( !$existing_product ) { $mdb -> insert( 'products', [ 'client_id' => $client_id, 'offer_id' => $offer_external_id, 'name' => $product_title ] ); + $product_id = $mdb -> id(); + + $products_by_offer_id[ $offer_external_id ] = [ + 'id' => (int) $product_id, + 'name' => $product_title + ]; } else { - $product_id = $mdb -> get( 'products', 'id', [ 'AND' => [ 'client_id' => $client_id, 'offer_id' => $offer_external_id ] ] ); - $offer_current_name = $mdb -> get( 'products', 'name', [ 'AND' => [ 'client_id' => $client_id, 'offer_id' => $offer_external_id ] ] ); + $product_id = (int) ( $existing_product['id'] ?? 0 ); + $offer_current_name = (string) ( $existing_product['name'] ?? '' ); if ( $offer_current_name != $product_title and $date == date( 'Y-m-d', strtotime( '-1 days' ) ) ) { $mdb -> update( 'products', [ 'name' => $product_title ], [ 'AND' => [ 'client_id' => $client_id, 'offer_id' => $offer_external_id ] ] ); + $products_by_offer_id[ $offer_external_id ]['name'] = $product_title; } } @@ -348,19 +937,41 @@ class Cron continue; } + $product_url = trim( (string) ( $offer['ProductUrl'] ?? '' ) ); + $product_url_path = strtolower( (string) parse_url( $product_url, PHP_URL_PATH ) ); + $is_image_url = (bool) preg_match( '/\.(jpg|jpeg|png|gif|webp|bmp|svg|avif)$/i', $product_url_path ); + + if ( $product_url !== '' && filter_var( $product_url, FILTER_VALIDATE_URL ) && !$is_image_url ) + { + $product_data_row = $products_data_map[ $product_id ] ?? [ 'exists' => false, 'product_url' => '' ]; + $existing_product_url = trim( (string) ( $product_data_row['product_url'] ?? '' ) ); + + if ( $existing_product_url !== $product_url ) + { + if ( !empty( $product_data_row['exists'] ) ) + { + $mdb -> update( 'products_data', [ 'product_url' => $product_url ], [ 'product_id' => $product_id ] ); + } + else + { + $mdb -> insert( 'products_data', [ + 'product_id' => $product_id, + 'product_url' => $product_url + ] ); + $product_data_row['exists'] = true; + } + + $product_data_row['product_url'] = $product_url; + $products_data_map[ $product_id ] = $product_data_row; + } + } + $campaign_external_id = (int) ( $offer['CampaignId'] ?? 0 ); $campaign_name = trim( (string) ( $offer['CampaignName'] ?? '' ) ); $ad_group_external_id = (int) ( $offer['AdGroupId'] ?? 0 ); $ad_group_name = trim( (string) ( $offer['AdGroupName'] ?? '' ) ); - $scope = self::resolve_products_scope_ids( - $client_id, - $campaign_external_id, - $campaign_name, - $ad_group_external_id, - $ad_group_name, - $date - ); + $scope = $resolve_scope_ids( $campaign_external_id, $campaign_name, $ad_group_external_id, $ad_group_name ); $db_campaign_id = (int) ( $scope['campaign_id'] ?? 0 ); $db_ad_group_id = (int) ( $scope['ad_group_id'] ?? 0 ); @@ -384,20 +995,11 @@ class Cron 'ad_group_id' => $db_ad_group_id ]; - if ( $mdb -> count( 'products_history', [ 'AND' => [ - 'product_id' => $product_id, - 'campaign_id' => $db_campaign_id, - 'ad_group_id' => $db_ad_group_id, - 'date_add' => $date - ] ] ) ) - { - $offer_data_old = $mdb -> get( 'products_history', '*', [ 'AND' => [ - 'product_id' => $product_id, - 'campaign_id' => $db_campaign_id, - 'ad_group_id' => $db_ad_group_id, - 'date_add' => $date - ] ] ); + $history_scope_key = (int) $product_id . '|' . (int) $db_campaign_id . '|' . (int) $db_ad_group_id; + $offer_data_old = $history_by_scope[ $history_scope_key ] ?? null; + if ( $offer_data_old ) + { if ( $offer_data_old['impressions'] == $offer_data['impressions'] and $offer_data_old['clicks'] == $offer_data['clicks'] @@ -419,12 +1021,28 @@ class Cron 'date_add' => $date ] ] ); + + $history_by_scope[ $history_scope_key ] = [ + 'impressions' => $offer_data['impressions'], + 'clicks' => $offer_data['clicks'], + 'cost' => $offer_data['cost'], + 'conversions' => $offer_data['conversions'], + 'conversions_value' => $offer_data['conversions_value'] + ]; } else { $offer_data['product_id'] = $product_id; $offer_data['date_add'] = $date; $mdb -> insert( 'products_history', $offer_data ); + + $history_by_scope[ $history_scope_key ] = [ + 'impressions' => $offer_data['impressions'], + 'clicks' => $offer_data['clicks'], + 'cost' => $offer_data['cost'], + 'conversions' => $offer_data['conversions'], + 'conversions_value' => $offer_data['conversions_value'] + ]; } $touched_product_ids[ $product_id ] = true; @@ -725,6 +1343,8 @@ class Cron $offers_data_tmp = $mdb -> get( 'products_data', '*', [ 'product_id' => $product_id ] ); if ( isset( $offers_data_tmp['id'] ) ) { + $old_custom_label_4 = (string) ( $offers_data_tmp['custom_label_4'] ?? '' ); + if ( $new_custom_label_4 != $offers_data_tmp['custom_label_4'] ) { $mdb -> insert( 'products_comments', [ @@ -736,6 +1356,16 @@ class Cron } $mdb -> update( 'products_data', [ 'custom_label_4' => $new_custom_label_4 ], [ 'id' => $offers_data_tmp['id'] ] ); + + if ( $old_custom_label_4 !== (string) $new_custom_label_4 ) + { + \controls\Products::sync_product_fields_to_merchant( $product_id, [ + 'custom_label_4' => [ + 'old' => $old_custom_label_4, + 'new' => (string) $new_custom_label_4 + ] + ], 'cron_products' ); + } } else { @@ -753,6 +1383,13 @@ class Cron 'date_add' => date( 'Y-m-d' ) ] ); } + + \controls\Products::sync_product_fields_to_merchant( $product_id, [ + 'custom_label_4' => [ + 'old' => '', + 'new' => (string) $new_custom_label_4 + ] + ], 'cron_products' ); } } } @@ -812,6 +1449,7 @@ class Cron static public function cron_products_history_30() { global $mdb; + self::touch_cron_invocation( __FUNCTION__ ); $start_time = microtime(true); @@ -1013,39 +1651,62 @@ class Cron } static public function cron_xml() + { + $result = self::generate_custom_feed_for_client( \S::get( 'client_id' ), true ); + + if ( ( $result['status'] ?? '' ) !== 'ok' ) + { + $response = [ 'result' => $result['message'] ?? 'Nie udalo sie wygenerowac pliku XML.' ]; + if ( !empty( $result['client'] ) ) + { + $response['client'] = $result['client']; + } + echo json_encode( $response ); + exit; + } + + $url = (string) ( $result['url'] ?? '' ); + echo json_encode( [ 'result' => 'Plik XML zostal wygenerowany ' . $url . '.' ] ); + exit; + } + + static public function generate_custom_feed_for_client( $client_id, $touch_invocation = true ) { global $mdb; - if ( !$client_id = \S::get( 'client_id' ) ) + $client_id = (int) $client_id; + + if ( $touch_invocation ) { - echo json_encode( [ 'result' => "Nie podano ID klienta." ] ); - exit; + self::touch_cron_invocation( 'cron_xml' ); + } + + if ( $client_id <= 0 ) + { + return [ 'status' => 'error', 'message' => 'Nie podano ID klienta.' ]; } if ( !$mdb -> count( 'clients', [ 'id' => $client_id ] ) ) { - echo json_encode( [ 'result' => "Nie znaleziono klienta o podanym ID.", "client" => "Nie istnieje" ] ); - exit; + return [ 'status' => 'error', 'message' => 'Nie znaleziono klienta o podanym ID.', 'client' => 'Nie istnieje' ]; } $results = $mdb -> query( 'SELECT * FROM products AS p INNER JOIN products_data AS pd ON p.id = pd.product_id WHERE p.client_id = ' . $client_id ) -> fetchAll( \PDO::FETCH_ASSOC ); - // if empty results if ( empty( $results ) ) { - echo json_encode( [ 'result' => "Brak produktow do wygenerowania pliku XML." ] ); - exit; + return [ 'status' => 'error', 'message' => 'Brak produktow do wygenerowania pliku XML.' ]; } - $doc = new \DOMDocument('1.0', 'UTF-8'); - $xmlRoot = $doc->createElement('rss'); - $xmlRoot = $doc->appendChild($xmlRoot); - $xmlRoot->setAttribute('version', '2.0'); - $xmlRoot->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:g', 'http://base.google.com/ns/1.0'); + $doc = new \DOMDocument( '1.0', 'UTF-8' ); + $xmlRoot = $doc -> createElement( 'rss' ); + $xmlRoot = $doc -> appendChild( $xmlRoot ); + $xmlRoot -> setAttribute( 'version', '2.0' ); + $xmlRoot -> setAttributeNS( 'http://www.w3.org/2000/xmlns/', 'xmlns:g', 'http://base.google.com/ns/1.0' ); - $channelNode = $xmlRoot->appendChild($doc->createElement('channel')); - $channelNode->appendChild($doc->createElement('title', 'Custom Feed')); - $channelNode->appendChild($doc->createElement('link', 'https://ads.pagedev.pl')); + $channelNode = $xmlRoot -> appendChild( $doc -> createElement( 'channel' ) ); + $channelNode -> appendChild( $doc -> createElement( 'title', 'Custom Feed' ) ); + $channelNode -> appendChild( $doc -> createElement( 'link', 'https://ads.pagedev.pl' ) ); $fieldMappings = [ 'title' => 'g:title', @@ -1055,41 +1716,67 @@ class Cron 'google_product_category' => 'g:google_product_category' ]; - foreach ($results as $row) + foreach ( $results as $row ) { $hasValidField = false; - foreach ($fieldMappings as $dbField => $xmlTag) { - if (!empty($row[$dbField])) { - $hasValidField = true; - break; - } + foreach ( $fieldMappings as $dbField => $xmlTag ) + { + if ( !empty( $row[ $dbField ] ) ) + { + $hasValidField = true; + break; + } } - if ( $hasValidField ) + if ( !$hasValidField ) { - $itemNode = $channelNode->appendChild($doc->createElement('item')); + continue; + } - $offer_id = $mdb -> get( 'products', 'offer_id', [ 'id' => $row['product_id'] ] ); - $offer_id = str_replace( 'shopify_pl', 'shopify_PL', $offer_id ); - $p_gid = $itemNode->appendChild($doc->createElement('id', $offer_id)); + $itemNode = $channelNode -> appendChild( $doc -> createElement( 'item' ) ); - foreach ($fieldMappings as $dbField => $xmlTag) { - if (!empty($row[$dbField])) { - $itemNode->appendChild($doc->createElement($xmlTag, $row[$dbField])); - } + $offer_id = $mdb -> get( 'products', 'offer_id', [ 'id' => $row['product_id'] ] ); + $offer_id = str_replace( 'shopify_pl', 'shopify_PL', $offer_id ); + $itemNode -> appendChild( $doc -> createElement( 'id', $offer_id ) ); + + foreach ( $fieldMappings as $dbField => $xmlTag ) + { + if ( !empty( $row[ $dbField ] ) ) + { + $itemNode -> appendChild( $doc -> createElement( $xmlTag, $row[ $dbField ] ) ); } } } - file_put_contents('xml/custom-feed-' . $_GET['client_id'] . '.xml', $doc->saveXML()); + $xml_dir = dirname( __DIR__, 2 ) . DIRECTORY_SEPARATOR . 'xml'; + if ( !is_dir( $xml_dir ) ) + { + @mkdir( $xml_dir, 0777, true ); + } - echo json_encode( [ 'result' => "Plik XML zostal wygenerowany https://adspro.projectpro.pl/xml/custom-feed-" . $_GET['client_id'] . ".xml." ] ); - exit; + $file_path = $xml_dir . DIRECTORY_SEPARATOR . 'custom-feed-' . $client_id . '.xml'; + $save_result = @file_put_contents( $file_path, $doc -> saveXML() ); + if ( $save_result === false ) + { + return [ 'status' => 'error', 'message' => 'Nie udalo sie zapisac pliku XML na serwerze.' ]; + } + + $scheme = ( !empty( $_SERVER['HTTPS'] ) && $_SERVER['HTTPS'] !== 'off' ) ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST'] ?? ( $_SERVER['SERVER_NAME'] ?? 'localhost' ); + $url = $scheme . '://' . $host . '/xml/custom-feed-' . $client_id . '.xml'; + + return [ + 'status' => 'ok', + 'message' => 'Plik XML zostal wygenerowany.', + 'url' => $url, + 'client_id' => $client_id + ]; } static public function cron_phrases() { global $mdb; + self::touch_cron_invocation( __FUNCTION__ ); if ( !$client_id = \S::get( 'client_id' ) ) { @@ -1172,6 +1859,7 @@ class Cron static public function cron_phrases_history_30() { global $mdb; + self::touch_cron_invocation( __FUNCTION__ ); $start_time = microtime( true ); // Rozpoczcie mierzenia czasu @@ -1228,7 +1916,8 @@ class Cron static public function cron_campaigns() { - global $mdb; + global $mdb, $settings; + self::touch_cron_invocation( __FUNCTION__ ); $api = new \services\GoogleAdsApi(); @@ -1267,6 +1956,7 @@ class Cron 'processed_records' => (int) $sync['processed_records'], 'ad_groups_synced' => (int) ( $sync['ad_groups_synced'] ?? 0 ), 'search_terms_synced' => (int) ( $sync['search_terms_synced'] ?? 0 ), + 'keywords_synced' => (int) ( $sync['keywords_synced'] ?? 0 ), 'negative_keywords_synced' => (int) ( $sync['negative_keywords_synced'] ?? 0 ), 'errors' => $sync['errors'] ] ); @@ -1305,13 +1995,49 @@ class Cron $client_ids = array_values( array_unique( $client_ids ) ); $window_state_key = 'cron_campaigns_window_state'; - $window_state = self::get_campaigns_window_state( $window_state_key, $sync_date, $sync_dates ); + $window_state = self::get_campaigns_window_state( $window_state_key, $sync_date, $sync_dates, $client_ids ); + $window_state['client_ids'] = $client_ids; self::save_campaigns_window_state( $window_state_key, $window_state ); $active_sync_date = $window_state['sync_date']; $sync_details = ( $active_sync_date === $sync_date ); $state_key = 'cron_campaigns_state'; $state = self::get_daily_cron_state( $state_key, $active_sync_date ); + + $processed_ids_normalized = array_values( array_unique( array_map( 'intval', (array) ( $state['processed_ids'] ?? [] ) ) ) ); + $allowed_clients_lookup = array_fill_keys( array_map( 'intval', $client_ids ), true ); + $processed_ids_filtered = []; + foreach ( $processed_ids_normalized as $pid ) + { + if ( $pid > 0 && isset( $allowed_clients_lookup[ $pid ] ) ) + { + $processed_ids_filtered[] = $pid; + } + } + + if ( count( $processed_ids_filtered ) !== count( $processed_ids_normalized ) ) + { + $state['processed_ids'] = $processed_ids_filtered; + self::save_daily_cron_state( $state_key, $state, $active_sync_date ); + } + else + { + $state['processed_ids'] = $processed_ids_normalized; + } + + $clients_per_run_default = (int) ( $settings['cron_campaigns_clients_per_run'] ?? 2 ); + if ( $clients_per_run_default <= 0 ) + { + $clients_per_run_default = 2; + } + + $clients_per_run = (int) \S::get( 'clients_per_run' ); + if ( $clients_per_run <= 0 ) + { + $clients_per_run = $clients_per_run_default; + } + $clients_per_run = min( 20, $clients_per_run ); + $next_client_id = self::pick_next_client_id( $client_ids, $state['processed_ids'] ); if ( !$next_client_id ) @@ -1348,36 +2074,77 @@ class Cron exit; } - $selected_client = $clients_map[$next_client_id] ?? null; - if ( !$selected_client ) + $clients_processed_in_call = []; + $errors = []; + $processed_records_total = 0; + $ad_groups_synced_total = 0; + $search_terms_synced_total = 0; + $keywords_synced_total = 0; + $negative_keywords_synced_total = 0; + + $processed_now = 0; + while ( $processed_now < $clients_per_run ) { - echo json_encode( [ 'result' => 'Nie udalo sie wybrac klienta do synchronizacji kampanii.', 'client_id' => $next_client_id ] ); - exit; + $next_client_id = self::pick_next_client_id( $client_ids, $state['processed_ids'] ); + if ( !$next_client_id ) + { + break; + } + + $selected_client = $clients_map[$next_client_id] ?? null; + if ( !$selected_client ) + { + $errors[] = 'Nie udalo sie wybrac klienta do synchronizacji kampanii. ID: ' . $next_client_id; + $state['processed_ids'][] = (int) $next_client_id; + $state['processed_ids'] = array_values( array_unique( array_map( 'intval', $state['processed_ids'] ) ) ); + $processed_now++; + continue; + } + + $sync = self::sync_campaigns_for_client( $selected_client, $api, $active_sync_date, $sync_details ); + $processed_records_total += (int) ( $sync['processed_records'] ?? 0 ); + $ad_groups_synced_total += (int) ( $sync['ad_groups_synced'] ?? 0 ); + $search_terms_synced_total += (int) ( $sync['search_terms_synced'] ?? 0 ); + $keywords_synced_total += (int) ( $sync['keywords_synced'] ?? 0 ); + $negative_keywords_synced_total += (int) ( $sync['negative_keywords_synced'] ?? 0 ); + + if ( !empty( $sync['errors'] ) ) + { + $errors = array_merge( $errors, (array) $sync['errors'] ); + } + + // Oznaczamy klienta jako przetworzonego rowniez po bledzie, aby nie zapetlac wywolan. + $state['processed_ids'][] = (int) $next_client_id; + $state['processed_ids'] = array_values( array_unique( array_map( 'intval', $state['processed_ids'] ) ) ); + $clients_processed_in_call[] = (int) $next_client_id; + $processed_now++; } - $sync = self::sync_campaigns_for_client( $selected_client, $api, $active_sync_date, $sync_details ); - - // Oznaczamy klienta jako przetworzonego rowniez po bledzie, aby nie zapetlac wywolan. - $state['processed_ids'][] = (int) $next_client_id; - $state['processed_ids'] = array_values( array_unique( array_map( 'intval', $state['processed_ids'] ) ) ); self::save_daily_cron_state( $state_key, $state, $active_sync_date ); $processed_today = count( array_intersect( $client_ids, $state['processed_ids'] ) ); + $remaining_today = max( 0, count( $client_ids ) - $processed_today ); + $estimated_calls_remaining_today = (int) ceil( $remaining_today / max( 1, $clients_per_run ) ); echo json_encode( [ - 'result' => empty( $sync['errors'] ) ? 'Synchronizacja kampanii zakonczona.' : 'Synchronizacja kampanii zakonczona z bledami.', - 'client_id' => $next_client_id, + 'result' => empty( $errors ) ? 'Synchronizacja kampanii zakonczona.' : 'Synchronizacja kampanii zakonczona z bledami.', + 'client_id' => !empty( $clients_processed_in_call ) ? (int) $clients_processed_in_call[0] : 0, + 'client_ids_processed_in_call' => $clients_processed_in_call, + 'processed_clients_in_call' => count( $clients_processed_in_call ), + 'clients_per_run' => $clients_per_run, 'date' => $sync_date, 'active_date' => $active_sync_date, 'conversion_window_days' => $conversion_window_days, 'dates_synced' => $window_state['sync_dates'], - 'processed_records' => (int) $sync['processed_records'], - 'ad_groups_synced' => (int) ( $sync['ad_groups_synced'] ?? 0 ), - 'search_terms_synced' => (int) ( $sync['search_terms_synced'] ?? 0 ), - 'negative_keywords_synced' => (int) ( $sync['negative_keywords_synced'] ?? 0 ), + 'processed_records' => $processed_records_total, + 'ad_groups_synced' => $ad_groups_synced_total, + 'search_terms_synced' => $search_terms_synced_total, + 'keywords_synced' => $keywords_synced_total, + 'negative_keywords_synced' => $negative_keywords_synced_total, 'processed_clients_today' => $processed_today, - 'remaining_clients_today' => max( 0, count( $client_ids ) - $processed_today ), - 'errors' => $sync['errors'] + 'remaining_clients_today' => $remaining_today, + 'estimated_calls_remaining_today' => $estimated_calls_remaining_today, + 'errors' => $errors ] ); exit; } @@ -1403,6 +2170,7 @@ class Cron 'processed_records' => 0, 'ad_groups_synced' => 0, 'search_terms_synced' => 0, + 'keywords_synced' => 0, 'negative_keywords_synced' => 0, 'errors' => $errors ]; @@ -1423,6 +2191,7 @@ class Cron 'processed_records' => 0, 'ad_groups_synced' => 0, 'search_terms_synced' => 0, + 'keywords_synced' => 0, 'negative_keywords_synced' => 0, 'errors' => $errors ]; @@ -1593,6 +2362,7 @@ class Cron 'processed_records' => $processed, 'ad_groups_synced' => 0, 'search_terms_synced' => 0, + 'keywords_synced' => 0, 'negative_keywords_synced' => 0, 'errors' => $errors ]; @@ -1600,14 +2370,16 @@ class Cron $ad_groups_sync = self::sync_campaign_ad_groups_for_client( $campaigns_db_map, $customer_id, $api, $as_of_date ); $search_terms_sync = self::sync_campaign_search_terms_for_client( $campaigns_db_map, $ad_groups_sync['ad_group_map'], $customer_id, $api, $as_of_date ); + $keywords_sync = self::sync_campaign_keywords_for_client( $campaigns_db_map, $ad_groups_sync['ad_group_map'], $customer_id, $api, $as_of_date ); $negative_keywords_sync = self::sync_campaign_negative_keywords_for_client( $campaigns_db_map, $ad_groups_sync['ad_group_map'], $customer_id, $api, $as_of_date ); - $errors = array_merge( $errors, $ad_groups_sync['errors'], $search_terms_sync['errors'], $negative_keywords_sync['errors'] ); + $errors = array_merge( $errors, $ad_groups_sync['errors'], $search_terms_sync['errors'], $keywords_sync['errors'], $negative_keywords_sync['errors'] ); return [ 'processed_records' => $processed, 'ad_groups_synced' => (int) $ad_groups_sync['count'], 'search_terms_synced' => (int) $search_terms_sync['count'], + 'keywords_synced' => (int) $keywords_sync['count'], 'negative_keywords_synced' => (int) $negative_keywords_sync['count'], 'errors' => $errors ]; @@ -1900,6 +2672,135 @@ class Cron return (int) $mdb -> id(); } + static private function sync_campaign_keywords_for_client( $campaigns_db_map, $ad_group_db_map, $customer_id, $api, $date_sync ) + { + global $mdb; + + $campaign_db_ids = array_values( array_unique( array_map( 'intval', array_values( $campaigns_db_map ) ) ) ); + if ( empty( $campaign_db_ids ) ) + { + return [ 'count' => 0, 'errors' => [] ]; + } + + $keywords_30 = $api -> get_ad_keywords_30_days( $customer_id ); + if ( $keywords_30 === false ) + { + $last_err = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ); + return [ 'count' => 0, 'errors' => [ 'Blad pobierania slow kluczowych (30 dni): ' . $last_err ] ]; + } + + $keywords_all_time = $api -> get_ad_keywords_all_time( $customer_id ); + if ( $keywords_all_time === false ) + { + $last_err = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ); + return [ 'count' => 0, 'errors' => [ 'Blad pobierania slow kluczowych (all time): ' . $last_err ] ]; + } + + if ( !is_array( $keywords_30 ) ) + { + $keywords_30 = []; + } + + if ( !is_array( $keywords_all_time ) ) + { + $keywords_all_time = []; + } + + $map_30 = []; + foreach ( $keywords_30 as $row ) + { + $campaign_external_id = isset( $row['campaign_id'] ) ? (string) $row['campaign_id'] : ''; + $ad_group_external_id = isset( $row['ad_group_id'] ) ? (string) $row['ad_group_id'] : ''; + $keyword_text = trim( (string) ( $row['keyword_text'] ?? '' ) ); + $match_type = trim( (string) ( $row['match_type'] ?? '' ) ); + + if ( $campaign_external_id === '' || $ad_group_external_id === '' || $keyword_text === '' ) + { + continue; + } + + $map_30[ $campaign_external_id . '|' . $ad_group_external_id . '|' . strtolower( $keyword_text ) . '|' . strtolower( $match_type ) ] = $row; + } + + $map_all_time = []; + foreach ( $keywords_all_time as $row ) + { + $campaign_external_id = isset( $row['campaign_id'] ) ? (string) $row['campaign_id'] : ''; + $ad_group_external_id = isset( $row['ad_group_id'] ) ? (string) $row['ad_group_id'] : ''; + $keyword_text = trim( (string) ( $row['keyword_text'] ?? '' ) ); + $match_type = trim( (string) ( $row['match_type'] ?? '' ) ); + + if ( $campaign_external_id === '' || $ad_group_external_id === '' || $keyword_text === '' ) + { + continue; + } + + $map_all_time[ $campaign_external_id . '|' . $ad_group_external_id . '|' . strtolower( $keyword_text ) . '|' . strtolower( $match_type ) ] = $row; + } + + $mdb -> delete( 'campaign_keywords', [ 'campaign_id' => $campaign_db_ids ] ); + + $keys = array_values( array_unique( array_merge( array_keys( $map_30 ), array_keys( $map_all_time ) ) ) ); + $count = 0; + + foreach ( $keys as $key ) + { + $parts = explode( '|', $key, 4 ); + $campaign_external_id = $parts[0] ?? ''; + $ad_group_external_id = $parts[1] ?? ''; + + $db_campaign_id = (int) ( $campaigns_db_map[ $campaign_external_id ] ?? 0 ); + $db_ad_group_id = (int) ( $ad_group_db_map[ $campaign_external_id . '|' . $ad_group_external_id ] ?? 0 ); + + if ( $db_campaign_id <= 0 || $db_ad_group_id <= 0 ) + { + continue; + } + + $row_30 = $map_30[ $key ] ?? []; + $row_all_time = $map_all_time[ $key ] ?? []; + + $keyword_text = trim( (string) ( $row_30['keyword_text'] ?? ( $row_all_time['keyword_text'] ?? '' ) ) ); + if ( $keyword_text === '' ) + { + continue; + } + + $match_type = trim( (string) ( $row_30['match_type'] ?? ( $row_all_time['match_type'] ?? '' ) ) ); + $clicks_30 = (int) ( $row_30['clicks'] ?? 0 ); + $clicks_all_time = (int) ( $row_all_time['clicks'] ?? 0 ); + + if ( $clicks_30 <= 0 && $clicks_all_time <= 0 ) + { + continue; + } + + $mdb -> insert( 'campaign_keywords', [ + 'campaign_id' => $db_campaign_id, + 'ad_group_id' => $db_ad_group_id, + 'keyword_text' => $keyword_text, + 'match_type' => $match_type, + 'impressions_30' => (int) ( $row_30['impressions'] ?? 0 ), + 'clicks_30' => $clicks_30, + 'cost_30' => (float) ( $row_30['cost'] ?? 0 ), + 'conversions_30' => (float) ( $row_30['conversions'] ?? 0 ), + 'conversion_value_30' => (float) ( $row_30['conversion_value'] ?? 0 ), + 'roas_30' => (float) ( $row_30['roas'] ?? 0 ), + 'impressions_all_time' => (int) ( $row_all_time['impressions'] ?? 0 ), + 'clicks_all_time' => $clicks_all_time, + 'cost_all_time' => (float) ( $row_all_time['cost'] ?? 0 ), + 'conversions_all_time' => (float) ( $row_all_time['conversions'] ?? 0 ), + 'conversion_value_all_time' => (float) ( $row_all_time['conversion_value'] ?? 0 ), + 'roas_all_time' => (float) ( $row_all_time['roas'] ?? 0 ), + 'date_sync' => $date_sync + ] ); + + $count++; + } + + return [ 'count' => $count, 'errors' => [] ]; + } + static private function sync_campaign_negative_keywords_for_client( $campaigns_db_map, $ad_group_db_map, $customer_id, $api, $date_sync ) { global $mdb; @@ -1980,7 +2881,7 @@ class Cron return [ 'count' => $count, 'errors' => [] ]; } - static private function get_campaigns_window_state( $state_key, $anchor_date, $sync_dates ) + static private function get_campaigns_window_state( $state_key, $anchor_date, $sync_dates, $client_ids = [] ) { $anchor_date = date( 'Y-m-d', strtotime( $anchor_date ) ); $sync_dates = array_values( array_unique( array_map( function( $item ) @@ -1994,7 +2895,10 @@ class Cron $sync_dates = [ $anchor_date ]; } - $expected_hash = md5( $anchor_date . '|' . implode( ',', $sync_dates ) ); + $client_ids_sorted = array_values( array_unique( array_map( 'intval', (array) $client_ids ) ) ); + sort( $client_ids_sorted ); + + $expected_hash = md5( $anchor_date . '|' . implode( ',', $client_ids_sorted ) . '|' . implode( ',', $sync_dates ) ); $state_raw = self::get_setting_value( $state_key, '' ); $state = json_decode( (string) $state_raw, true ); @@ -2034,14 +2938,18 @@ class Cron return; } + $client_ids_sorted = array_values( array_unique( array_map( 'intval', (array) ( $state['client_ids'] ?? [] ) ) ) ); + sort( $client_ids_sorted ); + $anchor_date = date( 'Y-m-d', strtotime( $state['anchor_date'] ?? end( $sync_dates ) ) ); $current_date_index = max( 0, min( count( $sync_dates ) - 1, (int) ( $state['current_date_index'] ?? 0 ) ) ); $payload = [ 'anchor_date' => $anchor_date, 'sync_dates' => $sync_dates, + 'client_ids' => $client_ids_sorted, 'current_date_index' => $current_date_index, 'sync_date' => $sync_dates[ $current_date_index ], - 'window_hash' => md5( $anchor_date . '|' . implode( ',', $sync_dates ) ) + 'window_hash' => md5( $anchor_date . '|' . implode( ',', $client_ids_sorted ) . '|' . implode( ',', $sync_dates ) ) ]; self::set_setting_value( $state_key, json_encode( $payload, JSON_UNESCAPED_UNICODE ) ); @@ -2202,6 +3110,48 @@ class Cron ] ); } + static private function touch_cron_invocation( $action_name ) + { + $now_timestamp = time(); + $now = date( 'Y-m-d H:i:s', $now_timestamp ); + $last_action_invoked_at = self::get_setting_value( 'cron_last_invoked_' . $action_name . '_at', '' ); + $last_action_timestamp = strtotime( (string) $last_action_invoked_at ); + + if ( $last_action_timestamp ) + { + $interval_seconds = $now_timestamp - $last_action_timestamp; + + // Pomijamy skrajne wartosci (np. pierwsze uruchomienie po dluzszej przerwie). + if ( $interval_seconds >= 1 && $interval_seconds <= 21600 ) + { + $avg_key = 'cron_avg_interval_' . $action_name . '_sec'; + $samples_key = 'cron_avg_interval_' . $action_name . '_samples'; + + $avg_interval = (float) self::get_setting_value( $avg_key, 0 ); + $samples = (int) self::get_setting_value( $samples_key, 0 ); + + $weight = min( 99, max( 0, $samples ) ); + if ( $weight <= 0 || $avg_interval <= 0 ) + { + $new_avg = (float) $interval_seconds; + $new_samples = 1; + } + else + { + $new_avg = ( ( $avg_interval * $weight ) + $interval_seconds ) / ( $weight + 1 ); + $new_samples = min( 100, $weight + 1 ); + } + + self::set_setting_value( $avg_key, (string) round( $new_avg, 2 ) ); + self::set_setting_value( $samples_key, (string) $new_samples ); + self::set_setting_value( 'cron_last_interval_' . $action_name . '_sec', (string) (int) $interval_seconds ); + } + } + + self::set_setting_value( 'cron_last_invoked_at', $now ); + self::set_setting_value( 'cron_last_invoked_' . $action_name . '_at', $now ); + } + static private function format_bidding_strategy( $strategy_type, $target_roas = 0 ) { $map = [ diff --git a/autoload/controls/class.Products.php b/autoload/controls/class.Products.php index f63fb4a..1de176f 100644 --- a/autoload/controls/class.Products.php +++ b/autoload/controls/class.Products.php @@ -2,6 +2,168 @@ namespace controls; class Products { + static private function normalize_keyword_source_url( $url ) + { + $url = trim( (string) $url ); + if ( $url === '' ) + { + return ''; + } + + $parts = parse_url( $url ); + if ( !is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) + { + return $url; + } + + $normalized = strtolower( (string) $parts['scheme'] ) . '://' . strtolower( (string) $parts['host'] ); + + if ( isset( $parts['port'] ) ) + { + $normalized .= ':' . (int) $parts['port']; + } + + $normalized .= isset( $parts['path'] ) ? (string) $parts['path'] : '/'; + + if ( isset( $parts['query'] ) && $parts['query'] !== '' ) + { + $normalized .= '?' . (string) $parts['query']; + } + + return $normalized; + } + + static public function sync_product_fields_to_merchant( $product_id, $changed_fields, $sync_source = 'products_ui' ) + { + $product_id = (int) $product_id; + $changed_fields = is_array( $changed_fields ) ? $changed_fields : []; + $sync_source = trim( (string) $sync_source ) ?: 'products_ui'; + + if ( $product_id <= 0 || empty( $changed_fields ) ) + { + return [ 'status' => 'skipped', 'message' => 'Brak zmian do synchronizacji.' ]; + } + + $supported_fields = [ 'title', 'description', 'google_product_category', 'custom_label_4' ]; + $normalized_changes = []; + + foreach ( $changed_fields as $field => $change ) + { + if ( !in_array( $field, $supported_fields, true ) ) + { + continue; + } + + $old_value = trim( (string) ( $change['old'] ?? '' ) ); + $new_value = trim( (string) ( $change['new'] ?? '' ) ); + + if ( $old_value === $new_value ) + { + continue; + } + + $normalized_changes[ $field ] = [ + 'old' => $old_value, + 'new' => $new_value + ]; + } + + if ( empty( $normalized_changes ) ) + { + return [ 'status' => 'skipped', 'message' => 'Brak rzeczywistych zmian pól.' ]; + } + + $merchant_context = \factory\Products::get_product_merchant_context( $product_id ); + $merchant_account_id = trim( (string) ( $merchant_context['google_merchant_account_id'] ?? '' ) ); + $offer_id = trim( (string) ( $merchant_context['offer_id'] ?? '' ) ); + + if ( !$merchant_context || $merchant_account_id === '' || $offer_id === '' ) + { + $reason = 'Brak merchant_account_id lub offer_id dla produktu.'; + foreach ( $normalized_changes as $field => $change ) + { + \factory\Products::add_product_merchant_sync_log( [ + 'product_id' => $product_id, + 'field_name' => $field, + 'old_value' => $change['old'], + 'new_value' => $change['new'], + 'sync_status' => 'skipped', + 'sync_source' => $sync_source, + 'merchant_account_id' => $merchant_account_id !== '' ? $merchant_account_id : null, + 'offer_id' => $offer_id !== '' ? $offer_id : null, + 'error_message' => $reason + ] ); + } + + return [ 'status' => 'skipped', 'message' => $reason ]; + } + + $merchant_api = new \services\GoogleAdsApi(); + if ( !$merchant_api -> is_merchant_configured() ) + { + $reason = 'Merchant API nie jest skonfigurowane.'; + foreach ( $normalized_changes as $field => $change ) + { + \factory\Products::add_product_merchant_sync_log( [ + 'product_id' => $product_id, + 'field_name' => $field, + 'old_value' => $change['old'], + 'new_value' => $change['new'], + 'sync_status' => 'skipped', + 'sync_source' => $sync_source, + 'merchant_account_id' => $merchant_account_id, + 'offer_id' => $offer_id, + 'error_message' => $reason + ] ); + } + + return [ 'status' => 'skipped', 'message' => $reason ]; + } + + $payload_fields = []; + foreach ( $normalized_changes as $field => $change ) + { + $payload_fields[ $field ] = $change['new']; + } + + $sync_result = $merchant_api -> update_merchant_product_fields_by_offer_id( $merchant_account_id, $offer_id, $payload_fields ); + $sync_success = !empty( $sync_result['success'] ); + $sync_status = $sync_success ? 'success' : 'error'; + $sync_error = trim( (string) ( $sync_result['error'] ?? '' ) ); + $merchant_product_id = trim( (string) ( $sync_result['merchant_product_id'] ?? '' ) ); + $api_response = null; + + if ( isset( $sync_result['response'] ) ) + { + $api_response = is_string( $sync_result['response'] ) + ? $sync_result['response'] + : json_encode( $sync_result['response'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); + } + + foreach ( $normalized_changes as $field => $change ) + { + \factory\Products::add_product_merchant_sync_log( [ + 'product_id' => $product_id, + 'field_name' => $field, + 'old_value' => $change['old'], + 'new_value' => $change['new'], + 'sync_status' => $sync_status, + 'sync_source' => $sync_source, + 'merchant_account_id' => $merchant_account_id, + 'merchant_product_id' => $merchant_product_id !== '' ? $merchant_product_id : null, + 'offer_id' => $offer_id, + 'api_response' => $api_response, + 'error_message' => $sync_error !== '' ? $sync_error : null + ] ); + } + + return [ + 'status' => $sync_status, + 'message' => $sync_success ? 'Synchronizacja Merchant API zakończona sukcesem.' : ( $sync_error !== '' ? $sync_error : 'Błąd synchronizacji Merchant API.' ), + 'result' => $sync_result + ]; + } + static public function get_client_bestseller_min_roas() { $client_id = \S::get( 'client_id' ); @@ -57,6 +219,233 @@ class Products exit; } + static public function assign_product_scope() + { + $product_id = (int) \S::get( 'product_id' ); + $campaign_mode = trim( (string) \S::get( 'campaign_mode' ) ); + $campaign_id = (int) \S::get( 'campaign_id' ); + $campaign_name = trim( (string) \S::get( 'campaign_name' ) ); + + $ad_group_mode = trim( (string) \S::get( 'ad_group_mode' ) ); + $ad_group_id = (int) \S::get( 'ad_group_id' ); + $ad_group_name = trim( (string) \S::get( 'ad_group_name' ) ); + + $campaign_daily_budget = (float) \S::get( 'campaign_daily_budget' ); + $default_cpc = (float) \S::get( 'default_cpc' ); + + if ( $campaign_daily_budget <= 0 ) + { + $campaign_daily_budget = 50.0; + } + + if ( $default_cpc <= 0 ) + { + $default_cpc = 1.0; + } + + if ( $product_id <= 0 ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Nieprawidłowy produkt.' ] ); + exit; + } + + $product_context = \factory\Products::get_product_scope_context( $product_id ); + if ( !$product_context ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Nie znaleziono produktu.' ] ); + exit; + } + + $client_id = (int) ( $product_context['client_id'] ?? 0 ); + $offer_id = trim( (string) ( $product_context['offer_id'] ?? '' ) ); + $customer_id = preg_replace( '/\D+/', '', (string) ( $product_context['google_ads_customer_id'] ?? '' ) ); + $merchant_account_id = preg_replace( '/\D+/', '', (string) ( $product_context['google_merchant_account_id'] ?? '' ) ); + + if ( $offer_id === '' ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Produkt nie ma offer_id (ID oferty).' ] ); + exit; + } + + if ( $customer_id === '' ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Brak Google Ads Customer ID u klienta.' ] ); + exit; + } + + $google_ads_api = new \services\GoogleAdsApi(); + if ( !$google_ads_api -> is_configured() ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Google Ads API nie jest skonfigurowane.' ] ); + exit; + } + + $external_campaign_id = 0; + $external_ad_group_id = 0; + $resolved_campaign_name = ''; + $resolved_ad_group_name = ''; + + if ( $campaign_mode === 'new' ) + { + if ( $campaign_name === '' ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Podaj nazwę nowej kampanii.' ] ); + exit; + } + + if ( $merchant_account_id === '' ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Brak Google Merchant Account ID u klienta (wymagane dla kampanii Shopping).' ] ); + exit; + } + + $campaign_result = $google_ads_api -> create_standard_shopping_campaign( $customer_id, $merchant_account_id, $campaign_name, $campaign_daily_budget ); + + if ( empty( $campaign_result['success'] ) ) + { + $error_message = trim( (string) ( $campaign_result['error'] ?? \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ) ) ); + echo json_encode( [ 'status' => 'error', 'message' => $error_message !== '' ? $error_message : 'Nie udało się utworzyć kampanii Standard Shopping w Google Ads.' ] ); + exit; + } + + $external_campaign_id = (int) ( $campaign_result['campaign_id'] ?? 0 ); + $resolved_campaign_name = trim( (string) ( $campaign_result['campaign_name'] ?? $campaign_name ) ); + } + else + { + if ( $campaign_id <= 0 ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Wybierz kampanię.' ] ); + exit; + } + + $campaign_scope = \factory\Products::get_campaign_scope_context( $campaign_id ); + if ( !$campaign_scope ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Nie znaleziono wybranej kampanii.' ] ); + exit; + } + + if ( (int) ( $campaign_scope['client_id'] ?? 0 ) !== $client_id ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Wybrana kampania nie należy do klienta produktu.' ] ); + exit; + } + + $external_campaign_id = (int) ( $campaign_scope['campaign_id'] ?? 0 ); + $resolved_campaign_name = trim( (string) ( $campaign_scope['campaign_name'] ?? '' ) ); + + if ( $external_campaign_id <= 0 ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Wybrana kampania nie ma ID Google Ads. Wybierz kampanię zsynchronizowaną z Google Ads.' ] ); + exit; + } + } + + if ( $ad_group_mode === 'new' ) + { + if ( $ad_group_name === '' ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Podaj nazwę nowej grupy reklam.' ] ); + exit; + } + + $ad_group_result = $google_ads_api -> create_standard_shopping_ad_group_with_offer( $customer_id, $external_campaign_id, $ad_group_name, $offer_id, $default_cpc ); + + if ( empty( $ad_group_result['success'] ) ) + { + $error_message = trim( (string) ( $ad_group_result['error'] ?? \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ) ) ); + echo json_encode( [ 'status' => 'error', 'message' => $error_message !== '' ? $error_message : 'Nie udało się utworzyć grupy reklam i przypisać produktu w Google Ads.' ] ); + exit; + } + + $external_ad_group_id = (int) ( $ad_group_result['ad_group_id'] ?? 0 ); + $resolved_ad_group_name = trim( (string) ( $ad_group_result['ad_group_name'] ?? $ad_group_name ) ); + } + else + { + if ( $ad_group_id <= 0 ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Wybierz grupę reklam.' ] ); + exit; + } + + $ad_group_scope = \factory\Products::get_ad_group_scope_context( $ad_group_id ); + if ( !$ad_group_scope ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Nie znaleziono wybranej grupy reklam.' ] ); + exit; + } + + if ( (int) ( $ad_group_scope['campaign_id'] ?? 0 ) !== (int) $campaign_id && $campaign_mode !== 'new' ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Wybrana grupa reklam nie należy do wskazanej kampanii.' ] ); + exit; + } + + $external_ad_group_id = (int) ( $ad_group_scope['ad_group_id'] ?? 0 ); + $resolved_ad_group_name = trim( (string) ( $ad_group_scope['ad_group_name'] ?? '' ) ); + + if ( $external_ad_group_id <= 0 ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Wybrana grupa reklam nie ma ID Google Ads. Wybierz grupę zsynchronizowaną z Google Ads.' ] ); + exit; + } + + $offer_result = $google_ads_api -> ensure_standard_shopping_offer_in_ad_group( $customer_id, $external_ad_group_id, $offer_id, $default_cpc ); + if ( empty( $offer_result['success'] ) ) + { + $error_message = trim( (string) ( $offer_result['error'] ?? \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ) ) ); + echo json_encode( [ 'status' => 'error', 'message' => $error_message !== '' ? $error_message : 'Nie udało się przypisać produktu do wybranej grupy reklam w Google Ads.' ] ); + exit; + } + } + + if ( $external_campaign_id <= 0 || $external_ad_group_id <= 0 ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Nie udało się przygotować docelowego scope Google Ads.' ] ); + exit; + } + + $resolved_scope = \controls\Cron::resolve_products_scope_ids( + $client_id, + $external_campaign_id, + $resolved_campaign_name, + $external_ad_group_id, + $resolved_ad_group_name, + date( 'Y-m-d' ) + ); + + $local_campaign_id = (int) ( $resolved_scope['campaign_id'] ?? 0 ); + $local_ad_group_id = (int) ( $resolved_scope['ad_group_id'] ?? 0 ); + + if ( $local_campaign_id <= 0 || $local_ad_group_id <= 0 ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Utworzono scope w Google Ads, ale nie udało się zsynchronizować mapowania lokalnego.' ] ); + exit; + } + + if ( !\factory\Products::assign_product_scope( $product_id, $local_campaign_id, $local_ad_group_id ) ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Produkt dodano do Google Ads, ale nie udało się zapisać przypisania lokalnie.' ] ); + exit; + } + + \factory\Products::add_product_comment( + $product_id, + 'Przypisano produkt do Google Ads: kampania #' . $external_campaign_id . ' (' . $resolved_campaign_name . '), grupa reklam #' . $external_ad_group_id . ' (' . $resolved_ad_group_name . ').' + ); + + echo json_encode( [ + 'status' => 'ok', + 'campaign_id' => $local_campaign_id, + 'ad_group_id' => $local_ad_group_id, + 'external_campaign_id' => $external_campaign_id, + 'external_ad_group_id' => $external_ad_group_id + ] ); + exit; + } + static public function comment_add() { $product_id = \S::get( 'product_id' ); @@ -102,6 +491,31 @@ class Products exit; } + static public function get_product_merchant_sync_logs() + { + $product_id = (int) \S::get( 'product_id' ); + $limit = (int) \S::get( 'limit' ); + + if ( $product_id <= 0 ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Nieprawidłowe ID produktu.' ] ); + exit; + } + + if ( $limit <= 0 ) + { + $limit = 50; + } + + $logs = \factory\Products::get_product_merchant_sync_logs( $product_id, $limit ); + + echo json_encode( [ + 'status' => 'ok', + 'logs' => $logs + ] ); + exit; + } + static public function ai_suggest() { $product_id = \S::get( 'product_id' ); @@ -144,12 +558,51 @@ class Products // Pobierz treść strony produktu jeśli podano URL $product_url = \S::get( 'product_url' ); + $keyword_source_url = self::normalize_keyword_source_url( $product_url ); $page_content = ''; if ( $product_url && filter_var( $product_url, FILTER_VALIDATE_URL ) ) { $page_content = \services\OpenAiApi::fetch_page_content( $product_url ); } + $keyword_terms = []; + $warnings = []; + + $should_enrich_with_keyword_planner = in_array( $field, [ 'title', 'description' ], true ) + && in_array( $provider, [ 'openai', 'claude' ], true ); + + if ( $should_enrich_with_keyword_planner && $keyword_source_url !== '' && filter_var( $keyword_source_url, FILTER_VALIDATE_URL ) ) + { + $keyword_terms = \factory\Products::get_cached_keyword_planner_terms( $product_id, $keyword_source_url, 15 ); + + if ( empty( $keyword_terms ) ) + { + $ads_context = \factory\Products::get_product_ads_keyword_context( $product_id ); + $customer_id = trim( (string) ( $ads_context['google_ads_customer_id'] ?? '' ) ); + + if ( $customer_id !== '' ) + { + $google_ads_api = new \services\GoogleAdsApi(); + $fetched_terms = $google_ads_api -> generate_keyword_ideas_from_url( $customer_id, $keyword_source_url, 40 ); + + if ( $fetched_terms !== false ) + { + \factory\Products::replace_keyword_planner_terms( $product_id, $keyword_source_url, $customer_id, $fetched_terms ); + $keyword_terms = \factory\Products::get_cached_keyword_planner_terms( $product_id, $keyword_source_url, 15 ); + } + else + { + $last_error = trim( (string) \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ) ); + $warnings[] = 'Nie udało się pobrać fraz z Google Ads Keyword Planner. ' . ( $last_error !== '' ? 'Szczegóły: ' . $last_error : '' ); + } + } + else + { + $warnings[] = 'Brak Google Ads Customer ID u klienta — pominięto frazy z Keyword Planner.'; + } + } + } + $context = [ 'original_name' => $product['name'], 'current_title' => \factory\Products::get_product_data( $product_id, 'title' ), @@ -165,6 +618,7 @@ class Products 'roas' => $product['roas'] ?? 0, 'custom_label_4' => \factory\Products::get_product_data( $product_id, 'custom_label_4' ), 'page_content' => $page_content, + 'keyword_planner_terms' => $keyword_terms, ]; $api = $provider === 'claude' ? \services\ClaudeApi::class : \services\OpenAiApi::class; @@ -187,10 +641,19 @@ class Products $result['provider'] = $provider; if ( $product_url && !$page_content ) - $result['warning'] = 'Nie udało się pobrać treści ze strony produktu (strona może blokować dostęp). Sugestia oparta tylko na nazwie produktu.'; + $warnings[] = 'Nie udało się pobrać treści ze strony produktu (strona może blokować dostęp). Sugestia oparta tylko na nazwie produktu.'; elseif ( $page_content ) $result['page_fetched'] = true; + if ( !empty( $warnings ) ) + $result['warning'] = implode( ' ', array_filter( $warnings ) ); + + if ( !empty( $keyword_terms ) ) + { + $result['keyword_planner_terms_used'] = true; + $result['keyword_planner_terms_count'] = count( $keyword_terms ); + } + echo json_encode( $result ); exit; } @@ -257,6 +720,7 @@ class Products $custom_class = ''; $custom_label_4 = \factory\Products::get_product_data( $row['product_id'], 'custom_label_4' ); $custom_name = \factory\Products::get_product_data( $row['product_id'], 'title' ); + $product_url = trim( (string) \factory\Products::get_product_data( $row['product_id'], 'product_url' ) ); if ( $custom_name ) { @@ -283,12 +747,16 @@ class Products // ➌ ROAS – liczba + pasek performance $roasValue = (float)$row['roas']; + $roasDisplay = (int) round( $roasValue, 0 ); $roasNumeric = ($roasValue <= (float)$row['min_roas']) - ? ''.($roasValue).'' - : $roasValue; + ? '' . $roasDisplay . '' + : $roasDisplay; $roasPerfBar = $renderPerfBar($roasValue, $roas_min, $roas_max); $roasCellHtml = '
'.$roasNumeric.$roasPerfBar.'
'; + $product_url_html = $product_url !== '' + ? ' Otworz' + : ''; $data['data'][] = [ '', // checkbox column @@ -296,6 +764,7 @@ class Products $row['offer_id'], htmlspecialchars( (string) ( $row['campaign_name'] ?? '' ) ), htmlspecialchars( (string) ( $row['ad_group_name'] ?? '' ) ), + $product_url_html, '
' . $row['name'] . ' @@ -317,7 +786,11 @@ class Products '', '', '', - '' + '
' + . '' + . '' + . '' + . '
' ]; } @@ -368,9 +841,17 @@ class Products { $product_id = \S::get( 'product_id' ); $custom_label_4 = \S::get( 'custom_label_4' ); + $old_custom_label_4 = (string) \factory\Products::get_product_data( $product_id, 'custom_label_4' ); if ( \factory\Products::set_product_data( $product_id, 'custom_label_4', $custom_label_4 ) ) { + self::sync_product_fields_to_merchant( $product_id, [ + 'custom_label_4' => [ + 'old' => $old_custom_label_4, + 'new' => (string) $custom_label_4 + ] + ], 'products_ui' ); + \factory\Products::add_product_comment( $product_id, 'Zmiana etykiety 4 na: ' . $custom_label_4 ); echo json_encode( [ 'status' => 'ok' ] ); } @@ -423,7 +904,7 @@ class Products { $comment_html = '
'; } @@ -524,18 +1005,38 @@ class Products $google_product_category = \S::get( 'google_product_category' ); $product_url = \S::get( 'product_url' ); + $old_title = (string) \factory\Products::get_product_data( $product_id, 'title' ); + $old_description = (string) \factory\Products::get_product_data( $product_id, 'description' ); + $old_category = (string) \factory\Products::get_product_data( $product_id, 'google_product_category' ); + + $changed_for_merchant = []; + if ( $product_id ) { if ( $custom_title ) + { \factory\Products::set_product_data( $product_id, 'title', $custom_title ); + $changed_for_merchant['title'] = [ 'old' => $old_title, 'new' => (string) $custom_title ]; + } if ( $custom_description ) + { \factory\Products::set_product_data( $product_id, 'description', $custom_description ); + $changed_for_merchant['description'] = [ 'old' => $old_description, 'new' => (string) $custom_description ]; + } if ( $google_product_category ) + { \factory\Products::set_product_data( $product_id, 'google_product_category', $google_product_category ); + $changed_for_merchant['google_product_category'] = [ 'old' => $old_category, 'new' => (string) $google_product_category ]; + } \factory\Products::set_product_data( $product_id, 'product_url', $product_url ?: '' ); + + if ( !empty( $changed_for_merchant ) ) + { + self::sync_product_fields_to_merchant( $product_id, $changed_for_merchant, 'products_ui' ); + } } \factory\Products::add_product_comment( $product_id, 'Zmiana tytułu i opisu produktu.' ); diff --git a/autoload/controls/class.Users.php b/autoload/controls/class.Users.php index 58eb883..150a75c 100644 --- a/autoload/controls/class.Users.php +++ b/autoload/controls/class.Users.php @@ -71,10 +71,33 @@ class Users } return \view\Users::settings( - $user + $user, + self::get_cron_dashboard_data() ); } + public static function settings_cron_status() + { + global $user; + + header( 'Content-Type: application/json; charset=utf-8' ); + header( 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0' ); + header( 'Pragma: no-cache' ); + + if ( !$user ) + { + http_response_code( 403 ); + echo json_encode( [ 'status' => 'error', 'message' => 'Brak autoryzacji.' ] ); + exit; + } + + echo json_encode( [ + 'status' => 'ok', + 'data' => self::get_cron_dashboard_data() + ], JSON_UNESCAPED_UNICODE ); + exit; + } + public static function settings_save_google_ads() { $fields = [ @@ -82,6 +105,7 @@ class Users 'google_ads_client_id', 'google_ads_client_secret', 'google_ads_refresh_token', + 'google_merchant_refresh_token', 'google_ads_manager_account_id', ]; @@ -90,9 +114,13 @@ class Users \services\GoogleAdsApi::set_setting( $field, \S::get( $field ) ); } + \services\GoogleAdsApi::set_setting( 'google_ads_debug_enabled', \S::get( 'google_ads_debug_enabled' ) ? '1' : '0' ); + // wyczyść cached token przy zmianie credentials \services\GoogleAdsApi::set_setting( 'google_ads_access_token', null ); \services\GoogleAdsApi::set_setting( 'google_ads_access_token_expires', null ); + \services\GoogleAdsApi::set_setting( 'google_merchant_access_token', null ); + \services\GoogleAdsApi::set_setting( 'google_merchant_access_token_expires', null ); \S::alert( 'Ustawienia Google Ads zostały zapisane.' ); header( 'Location: /settings' ); @@ -121,6 +149,293 @@ class Users exit; } + private static function get_cron_dashboard_data() + { + global $mdb; + + $base_url = self::get_base_url(); + $clients_total = (int) $mdb -> query( "SELECT COUNT(*) FROM clients WHERE deleted = 0 AND google_ads_customer_id IS NOT NULL AND google_ads_customer_id <> ''" ) -> fetchColumn(); + + $campaign_window_state = self::get_setting_json( 'cron_campaigns_window_state' ); + $campaign_daily_state = self::get_setting_json( 'cron_campaigns_state' ); + $campaign_dates = self::normalize_dates( $campaign_window_state['sync_dates'] ?? [] ); + $campaign_dates_count = count( $campaign_dates ); + if ( $campaign_dates_count < 1 ) + { + $campaign_dates = [ date( 'Y-m-d' ) ]; + $campaign_dates_count = 1; + } + + $campaign_current_date_index = (int) ( $campaign_window_state['current_date_index'] ?? 0 ); + $campaign_current_date_index = max( 0, min( $campaign_dates_count - 1, $campaign_current_date_index ) ); + $campaign_processed_today = count( self::normalize_ids( $campaign_daily_state['processed_ids'] ?? [] ) ); + $campaign_processed_today = min( $clients_total, $campaign_processed_today ); + $campaign_total = $clients_total * $campaign_dates_count; + $campaign_processed = min( $campaign_total, ( $campaign_current_date_index * $clients_total ) + $campaign_processed_today ); + $campaign_remaining = max( 0, $campaign_total - $campaign_processed ); + $campaign_active_date = $campaign_window_state['sync_date'] ?? ( $campaign_dates[ $campaign_current_date_index ] ?? '' ); + $campaign_meta = 'Aktywny dzień: ' . ( $campaign_active_date ?: '-' ) . ', okno dni: ' . $campaign_dates_count; + $campaign_eta_meta = self::build_eta_meta( 'cron_campaigns', $campaign_remaining ); + if ( $campaign_eta_meta !== '' ) + { + $campaign_meta .= ', ' . $campaign_eta_meta; + } + + $products_state = self::get_setting_json( 'cron_products_pipeline_state' ); + $products_dates = self::normalize_dates( $products_state['import_dates'] ?? [] ); + $products_dates_count = count( $products_dates ); + if ( $products_dates_count < 1 ) + { + $products_dates = [ date( 'Y-m-d' ) ]; + $products_dates_count = 1; + } + + $products_current_date_index = (int) ( $products_state['current_date_index'] ?? 0 ); + $products_current_date_index = max( 0, min( $products_dates_count - 1, $products_current_date_index ) ); + $products_phase = (string) ( $products_state['phase'] ?? 'fetch' ); + + $products_fetch_done = count( self::normalize_ids( $products_state['fetch_done_ids'] ?? [] ) ); + $products_aggregate_30_done = count( self::normalize_ids( $products_state['aggregate_30_done_ids'] ?? [] ) ); + $products_aggregate_temp_done = count( self::normalize_ids( $products_state['aggregate_temp_done_ids'] ?? [] ) ); + + $products_fetch_done = min( $clients_total, $products_fetch_done ); + $products_aggregate_30_done = min( $clients_total, $products_aggregate_30_done ); + $products_aggregate_temp_done = min( $clients_total, $products_aggregate_temp_done ); + + $products_per_day_total = $clients_total * 3; + $products_total = $products_per_day_total * $products_dates_count; + + $products_done_in_day = 0; + if ( $products_phase === 'aggregate_30' ) + { + $products_done_in_day = $clients_total + $products_aggregate_30_done; + } + else if ( $products_phase === 'aggregate_temp' ) + { + $products_done_in_day = ( $clients_total * 2 ) + $products_aggregate_temp_done; + } + else if ( $products_phase === 'done' ) + { + $products_done_in_day = $products_per_day_total; + } + else + { + $products_done_in_day = $products_fetch_done; + } + + $products_done_in_day = min( $products_per_day_total, $products_done_in_day ); + $products_processed = min( $products_total, ( $products_current_date_index * $products_per_day_total ) + $products_done_in_day ); + if ( $products_phase === 'done' ) + { + $products_processed = $products_total; + } + $products_remaining = max( 0, $products_total - $products_processed ); + + $products_phase_labels = [ + 'fetch' => 'Pobieranie', + 'aggregate_30' => 'Agregacja 30 dni', + 'aggregate_temp' => 'Agregacja temp', + 'done' => 'Zakończono' + ]; + $products_phase_label = $products_phase_labels[ $products_phase ] ?? $products_phase; + $products_active_date = $products_state['import_date'] ?? ( $products_dates[ $products_current_date_index ] ?? '' ); + $products_meta = 'Faza: ' . $products_phase_label . ', aktywny dzień: ' . ( $products_active_date ?: '-' ) . ', okno dni: ' . $products_dates_count; + $products_eta_meta = self::build_eta_meta( 'cron_products', $products_remaining ); + if ( $products_eta_meta !== '' ) + { + $products_meta .= ', ' . $products_eta_meta; + } + + $cron_endpoints = [ + [ 'name' => 'Legacy CRON', 'path' => '/cron.php', 'action' => 'cron_legacy' ], + [ 'name' => 'Cron kampanii', 'path' => '/cron/cron_campaigns', 'action' => 'cron_campaigns' ], + [ 'name' => 'Cron produktów', 'path' => '/cron/cron_products', 'action' => 'cron_products' ], + [ 'name' => 'Cron URL produktów (Merchant)', 'path' => '/cron/cron_products_urls', 'action' => 'cron_products_urls' ], + [ 'name' => 'Cron fraz', 'path' => '/cron/cron_phrases', 'action' => 'cron_phrases' ], + [ 'name' => 'Historia 30 dni produktów', 'path' => '/cron/cron_products_history_30', 'action' => 'cron_products_history_30' ], + [ 'name' => 'Historia 30 dni fraz', 'path' => '/cron/cron_phrases_history_30', 'action' => 'cron_phrases_history_30' ], + [ 'name' => 'Eksport XML', 'path' => '/cron/cron_xml', 'action' => 'cron_xml' ], + ]; + + $urls = []; + foreach ( $cron_endpoints as $endpoint ) + { + $last_key = 'cron_last_invoked_' . $endpoint['action'] . '_at'; + $urls[] = [ + 'name' => $endpoint['name'], + 'url' => $base_url . $endpoint['path'], + 'last_invoked_at' => self::format_datetime( \services\GoogleAdsApi::get_setting( $last_key ) ), + ]; + } + + return [ + 'overall_last_invoked_at' => self::format_datetime( \services\GoogleAdsApi::get_setting( 'cron_last_invoked_at' ) ), + 'clients_total' => $clients_total, + 'progress' => [ + [ + 'name' => 'Kampanie', + 'processed' => $campaign_processed, + 'total' => $campaign_total, + 'percent' => self::progress_percent( $campaign_processed, $campaign_total ), + 'meta' => $campaign_meta + ], + [ + 'name' => 'Produkty', + 'processed' => $products_processed, + 'total' => $products_total, + 'percent' => self::progress_percent( $products_processed, $products_total ), + 'meta' => $products_meta + ], + ], + 'urls' => $urls + ]; + } + + private static function get_setting_json( $setting_key ) + { + $raw = \services\GoogleAdsApi::get_setting( $setting_key ); + if ( !$raw ) + { + return []; + } + + $decoded = json_decode( (string) $raw, true ); + return is_array( $decoded ) ? $decoded : []; + } + + private static function normalize_ids( $items ) + { + $result = []; + foreach ( (array) $items as $item ) + { + $id = (int) $item; + if ( $id > 0 ) + { + $result[] = $id; + } + } + return array_values( array_unique( $result ) ); + } + + private static function normalize_dates( $items ) + { + $result = []; + foreach ( (array) $items as $item ) + { + $timestamp = strtotime( (string) $item ); + if ( !$timestamp ) + { + continue; + } + $result[] = date( 'Y-m-d', $timestamp ); + } + $result = array_values( array_unique( $result ) ); + sort( $result ); + return $result; + } + + private static function progress_percent( $processed, $total ) + { + $processed = (int) $processed; + $total = (int) $total; + + if ( $total <= 0 ) + { + return 0; + } + + return (int) round( min( 100, max( 0, ( $processed / $total ) * 100 ) ) ); + } + + private static function build_eta_meta( $action_name, $remaining_tasks ) + { + $remaining_tasks = max( 0, (int) $remaining_tasks ); + + if ( $remaining_tasks <= 0 ) + { + return 'Szacowany koniec: zakończono'; + } + + $avg_interval_seconds = (float) \services\GoogleAdsApi::get_setting( 'cron_avg_interval_' . $action_name . '_sec' ); + $last_interval_seconds = (int) \services\GoogleAdsApi::get_setting( 'cron_last_interval_' . $action_name . '_sec' ); + + if ( $avg_interval_seconds <= 0 && $last_interval_seconds > 0 ) + { + $avg_interval_seconds = (float) $last_interval_seconds; + } + + if ( $avg_interval_seconds <= 0 ) + { + return 'Szacowany koniec: brak danych o częstotliwości'; + } + + $estimated_seconds = (int) max( 1, round( $remaining_tasks * $avg_interval_seconds ) ); + $eta_timestamp = time() + $estimated_seconds; + + return 'Śr. interwał: ' + . self::format_duration_short( (int) round( $avg_interval_seconds ) ) + . ', szacowany koniec: ' + . date( 'Y-m-d H:i:s', $eta_timestamp ) + . ' (za ' + . self::format_duration_short( $estimated_seconds ) + . ')'; + } + + private static function format_duration_short( $seconds ) + { + $seconds = max( 0, (int) $seconds ); + + if ( $seconds < 60 ) + { + return $seconds . ' sek'; + } + + $days = (int) floor( $seconds / 86400 ); + $seconds -= $days * 86400; + $hours = (int) floor( $seconds / 3600 ); + $seconds -= $hours * 3600; + $minutes = (int) floor( $seconds / 60 ); + + $parts = []; + if ( $days > 0 ) + { + $parts[] = $days . ' d'; + } + if ( $hours > 0 ) + { + $parts[] = $hours . ' h'; + } + if ( $minutes > 0 ) + { + $parts[] = $minutes . ' min'; + } + + if ( empty( $parts ) ) + { + return '1 min'; + } + + return implode( ' ', array_slice( $parts, 0, 2 ) ); + } + + private static function format_datetime( $value ) + { + $timestamp = strtotime( (string) $value ); + if ( !$timestamp ) + { + return 'Brak danych'; + } + + return date( 'Y-m-d H:i:s', $timestamp ); + } + + private static function get_base_url() + { + $scheme = ( !empty( $_SERVER['HTTPS'] ) && $_SERVER['HTTPS'] !== 'off' ) ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST'] ?? ( $_SERVER['SERVER_NAME'] ?? 'localhost' ); + return $scheme . '://' . $host; + } + public static function login() { if ( $user = \factory\Users::login( @@ -155,4 +470,4 @@ class Users return \Tpl::view( 'users/login-form' ); } -} \ No newline at end of file +} diff --git a/autoload/controls/class.XmlFiles.php b/autoload/controls/class.XmlFiles.php new file mode 100644 index 0000000..49b5f2d --- /dev/null +++ b/autoload/controls/class.XmlFiles.php @@ -0,0 +1,38 @@ + \factory\XmlFiles::get_clients_with_xml_feed() + ] ); + } + + static public function regenerate() + { + $client_id = (int) \S::get( 'client_id' ); + + if ( $client_id <= 0 ) + { + \S::alert( 'Nie podano ID klienta.' ); + header( 'Location: /xml_files' ); + exit; + } + + $result = \controls\Cron::generate_custom_feed_for_client( $client_id, true ); + + if ( ( $result['status'] ?? '' ) === 'ok' ) + { + \S::alert( 'Plik XML zostal wygenerowany: ' . ( $result['url'] ?? '' ) ); + } + else + { + \S::alert( $result['message'] ?? 'Nie udalo sie wygenerowac pliku XML.' ); + } + + header( 'Location: /xml_files' ); + exit; + } +} diff --git a/autoload/factory/class.Campaigns.php b/autoload/factory/class.Campaigns.php index 37d716a..b21aa51 100644 --- a/autoload/factory/class.Campaigns.php +++ b/autoload/factory/class.Campaigns.php @@ -107,6 +107,8 @@ class Campaigns st.id, st.campaign_id, st.ad_group_id, + c.campaign_name, + c.advertising_channel_type, ag.ad_group_name, st.search_term, st.impressions_30, @@ -122,6 +124,7 @@ class Campaigns st.conversion_value_all_time, st.roas_all_time FROM campaign_search_terms AS st + LEFT JOIN campaigns AS c ON c.id = st.campaign_id LEFT JOIN campaign_ad_groups AS ag ON ag.id = st.ad_group_id WHERE st.campaign_id = :campaign_id'; @@ -167,6 +170,46 @@ class Campaigns return $mdb -> query( $sql, $params ) -> fetchAll( \PDO::FETCH_ASSOC ); } + static public function get_campaign_keywords( $campaign_id, $ad_group_id = 0 ) + { + global $mdb; + + $sql = 'SELECT + kw.id, + kw.campaign_id, + kw.ad_group_id, + ag.ad_group_name, + kw.keyword_text, + kw.match_type, + kw.impressions_30, + kw.clicks_30, + kw.cost_30, + kw.conversions_30, + kw.conversion_value_30, + kw.roas_30, + kw.impressions_all_time, + kw.clicks_all_time, + kw.cost_all_time, + kw.conversions_all_time, + kw.conversion_value_all_time, + kw.roas_all_time + FROM campaign_keywords AS kw + LEFT JOIN campaign_ad_groups AS ag ON ag.id = kw.ad_group_id + WHERE kw.campaign_id = :campaign_id'; + + $params = [ ':campaign_id' => (int) $campaign_id ]; + + if ( (int) $ad_group_id > 0 ) + { + $sql .= ' AND kw.ad_group_id = :ad_group_id'; + $params[':ad_group_id'] = (int) $ad_group_id; + } + + $sql .= ' ORDER BY kw.clicks_30 DESC, kw.clicks_all_time DESC, kw.keyword_text ASC'; + + return $mdb -> query( $sql, $params ) -> fetchAll( \PDO::FETCH_ASSOC ); + } + static public function get_search_term_context( $search_term_row_id ) { global $mdb; @@ -242,6 +285,38 @@ class Campaigns return (int) $mdb -> id(); } + static public function get_negative_keyword_context( $negative_keyword_row_id ) + { + global $mdb; + + return $mdb -> query( + 'SELECT + nk.id AS negative_keyword_row_id, + nk.scope, + nk.keyword_text, + nk.match_type, + nk.campaign_id AS db_campaign_id, + nk.ad_group_id AS db_ad_group_id, + c.client_id, + c.campaign_id AS external_campaign_id, + ag.ad_group_id AS external_ad_group_id, + cl.google_ads_customer_id + FROM campaign_negative_keywords AS nk + INNER JOIN campaigns AS c ON c.id = nk.campaign_id + INNER JOIN clients AS cl ON cl.id = c.client_id + LEFT JOIN campaign_ad_groups AS ag ON ag.id = nk.ad_group_id + WHERE nk.id = :negative_keyword_row_id + LIMIT 1', + [ ':negative_keyword_row_id' => (int) $negative_keyword_row_id ] + ) -> fetch( \PDO::FETCH_ASSOC ); + } + + static public function delete_campaign_negative_keyword( $negative_keyword_row_id ) + { + global $mdb; + return $mdb -> delete( 'campaign_negative_keywords', [ 'id' => (int) $negative_keyword_row_id ] ); + } + static public function delete_campaign( $campaign_id ) { global $mdb; diff --git a/autoload/factory/class.Products.php b/autoload/factory/class.Products.php index 2a525a3..4bb63f4 100644 --- a/autoload/factory/class.Products.php +++ b/autoload/factory/class.Products.php @@ -88,32 +88,64 @@ class Products $order_dir = strtoupper( (string) $order_dir ) === 'ASC' ? 'ASC' : 'DESC'; $order_map = [ - 'offer_id' => 'p.offer_id', - 'campaign_name' => 'c.campaign_name', - 'ad_group_name' => 'ag.ad_group_name', - 'name' => 'pt.name', - 'impressions' => 'pt.impressions', - 'impressions_30' => 'pt.impressions_30', - 'clicks' => 'pt.clicks', - 'clicks_30' => 'pt.clicks_30', - 'ctr' => 'pt.ctr', - 'cost' => 'pt.cost', - 'cpc' => 'pt.cpc', - 'conversions' => 'pt.conversions', - 'conversions_value' => 'pt.conversions_value', - 'roas' => 'pt.roas', - 'min_roas' => 'p.min_roas' + 'offer_id' => 'offer_id', + 'campaign_name' => 'campaign_name', + 'ad_group_name' => 'ad_group_name', + 'name' => 'name', + 'impressions' => 'impressions', + 'impressions_30' => 'impressions_30', + 'clicks' => 'clicks', + 'clicks_30' => 'clicks_30', + 'ctr' => 'ctr', + 'cost' => 'cost', + 'cpc' => 'cpc', + 'conversions' => 'conversions', + 'conversions_value' => 'conversions_value', + 'roas' => 'roas', + 'min_roas' => 'min_roas' ]; - $order_sql = $order_map[ $order_name ] ?? 'pt.clicks'; + $order_sql = $order_map[ $order_name ] ?? 'clicks'; $params = [ ':client_id' => (int) $client_id ]; - $sql = 'SELECT pt.*, p.offer_id, p.min_roas, - COALESCE( c.campaign_name, \'--- brak kampanii ---\' ) AS campaign_name, + $sql = 'SELECT + p.id AS product_id, + p.offer_id, + p.min_roas, + pt.campaign_id, CASE - WHEN pt.ad_group_id = 0 THEN \'PMax (bez grup reklam)\' - ELSE COALESCE( ag.ad_group_name, \'--- brak grupy reklam ---\' ) - END AS ad_group_name + WHEN COUNT( DISTINCT pt.campaign_id ) > 1 THEN \'--- wiele kampanii ---\' + ELSE COALESCE( MAX( c.campaign_name ), \'--- brak kampanii ---\' ) + END AS campaign_name, + CASE + WHEN COUNT( DISTINCT pt.ad_group_id ) > 1 THEN \'--- wiele grup reklam ---\' + WHEN MAX( pt.ad_group_id ) = 0 THEN \'PMax (bez grup reklam)\' + ELSE COALESCE( MAX( ag.ad_group_name ), \'--- brak grupy reklam ---\' ) + END AS ad_group_name, + CASE + WHEN COUNT( DISTINCT pt.ad_group_id ) = 1 THEN MAX( pt.ad_group_id ) + ELSE 0 + END AS ad_group_id, + MAX( pt.name ) AS name, + SUM( pt.impressions ) AS impressions, + SUM( pt.impressions_30 ) AS impressions_30, + SUM( pt.clicks ) AS clicks, + SUM( pt.clicks_30 ) AS clicks_30, + CASE + WHEN SUM( pt.impressions ) > 0 THEN ROUND( SUM( pt.clicks ) / SUM( pt.impressions ) * 100, 2 ) + ELSE 0 + END AS ctr, + SUM( pt.cost ) AS cost, + CASE + WHEN SUM( pt.clicks ) > 0 THEN ROUND( SUM( pt.cost ) / SUM( pt.clicks ), 6 ) + ELSE 0 + END AS cpc, + SUM( pt.conversions ) AS conversions, + SUM( pt.conversions_value ) AS conversions_value, + CASE + WHEN SUM( pt.cost ) > 0 THEN ROUND( SUM( pt.conversions_value ) / SUM( pt.cost ) * 100, 2 ) + ELSE 0 + END AS roas FROM products_temp AS pt INNER JOIN products AS p ON p.id = pt.product_id LEFT JOIN campaigns AS c ON c.id = pt.campaign_id @@ -133,7 +165,8 @@ class Products $params[':search'] = '%' . $search . '%'; } - $sql .= ' ORDER BY ' . $order_sql . ' ' . $order_dir . ', pt.id DESC LIMIT ' . $start . ', ' . $limit; + $sql .= ' GROUP BY p.id, p.offer_id, p.min_roas, pt.campaign_id'; + $sql .= ' ORDER BY ' . $order_sql . ' ' . $order_dir . ', product_id DESC LIMIT ' . $start . ', ' . $limit; return $mdb -> query( $sql, $params ) -> fetchAll( \PDO::FETCH_ASSOC ); } @@ -178,12 +211,14 @@ class Products global $mdb; $params = [ ':client_id' => (int) $client_id ]; - $sql = 'SELECT COUNT(0) - FROM products_temp AS pt - INNER JOIN products AS p ON p.id = pt.product_id - LEFT JOIN campaigns AS c ON c.id = pt.campaign_id - LEFT JOIN campaign_ad_groups AS ag ON ag.id = pt.ad_group_id - WHERE p.client_id = :client_id'; + $sql = 'SELECT COUNT(0) + FROM ( + SELECT p.id, pt.campaign_id + FROM products_temp AS pt + INNER JOIN products AS p ON p.id = pt.product_id + LEFT JOIN campaigns AS c ON c.id = pt.campaign_id + LEFT JOIN campaign_ad_groups AS ag ON ag.id = pt.ad_group_id + WHERE p.client_id = :client_id'; self::build_scope_filters( $sql, $params, $campaign_id, $ad_group_id ); @@ -198,6 +233,9 @@ class Products $params[':search'] = '%' . $search . '%'; } + $sql .= ' GROUP BY p.id, pt.campaign_id + ) AS grouped_rows'; + return $mdb -> query( $sql, $params ) -> fetchColumn(); } @@ -246,6 +284,177 @@ class Products return $mdb -> get( 'products_data', $field, [ 'product_id' => $product_id ] ); } + static public function get_product_merchant_context( $product_id ) + { + global $mdb; + + return $mdb -> query( + 'SELECT + p.id AS product_id, + p.client_id, + p.offer_id, + cl.google_merchant_account_id + FROM products AS p + INNER JOIN clients AS cl ON cl.id = p.client_id + WHERE p.id = :product_id + LIMIT 1', + [ ':product_id' => (int) $product_id ] + ) -> fetch( \PDO::FETCH_ASSOC ); + } + + static public function add_product_merchant_sync_log( $row ) + { + global $mdb; + + $data = [ + 'product_id' => (int) ( $row['product_id'] ?? 0 ), + 'field_name' => (string) ( $row['field_name'] ?? '' ), + 'old_value' => isset( $row['old_value'] ) ? (string) $row['old_value'] : null, + 'new_value' => isset( $row['new_value'] ) ? (string) $row['new_value'] : null, + 'sync_status' => (string) ( $row['sync_status'] ?? 'pending' ), + 'sync_source' => (string) ( $row['sync_source'] ?? 'products_ui' ), + 'merchant_account_id' => isset( $row['merchant_account_id'] ) ? (string) $row['merchant_account_id'] : null, + 'merchant_product_id' => isset( $row['merchant_product_id'] ) ? (string) $row['merchant_product_id'] : null, + 'offer_id' => isset( $row['offer_id'] ) ? (string) $row['offer_id'] : null, + 'api_response' => isset( $row['api_response'] ) ? (string) $row['api_response'] : null, + 'error_message' => isset( $row['error_message'] ) ? (string) $row['error_message'] : null, + 'date_add' => date( 'Y-m-d H:i:s' ) + ]; + + if ( $data['product_id'] <= 0 || trim( $data['field_name'] ) === '' ) + { + return false; + } + + return $mdb -> insert( 'products_merchant_sync_log', $data ); + } + + static public function get_product_merchant_sync_logs( $product_id, $limit = 50 ) + { + global $mdb; + + $product_id = (int) $product_id; + $limit = max( 1, (int) $limit ); + + if ( $product_id <= 0 ) + { + return []; + } + + return $mdb -> query( + 'SELECT + id, + field_name, + old_value, + new_value, + sync_status, + sync_source, + merchant_account_id, + merchant_product_id, + offer_id, + error_message, + date_add + FROM products_merchant_sync_log + WHERE product_id = :product_id + ORDER BY id DESC + LIMIT ' . $limit, + [ ':product_id' => $product_id ] + ) -> fetchAll( \PDO::FETCH_ASSOC ); + } + + static public function get_product_ads_keyword_context( $product_id ) + { + global $mdb; + + return $mdb -> query( + 'SELECT + p.id AS product_id, + p.client_id, + cl.google_ads_customer_id + FROM products AS p + INNER JOIN clients AS cl ON cl.id = p.client_id + WHERE p.id = :product_id + LIMIT 1', + [ ':product_id' => (int) $product_id ] + ) -> fetch( \PDO::FETCH_ASSOC ); + } + + static public function get_cached_keyword_planner_terms( $product_id, $source_url, $limit = 15 ) + { + global $mdb; + + $product_id = (int) $product_id; + $source_url = trim( (string) $source_url ); + $limit = max( 1, (int) $limit ); + + if ( $product_id <= 0 || $source_url === '' ) + { + return []; + } + + return $mdb -> query( + 'SELECT keyword_text, avg_monthly_searches, competition, competition_index + FROM products_keyword_planner_terms + WHERE product_id = :product_id + AND source_url = :source_url + ORDER BY avg_monthly_searches DESC, keyword_text ASC + LIMIT ' . $limit, + [ + ':product_id' => $product_id, + ':source_url' => $source_url + ] + ) -> fetchAll( \PDO::FETCH_ASSOC ); + } + + static public function replace_keyword_planner_terms( $product_id, $source_url, $customer_id, $terms ) + { + global $mdb; + + $product_id = (int) $product_id; + $source_url = trim( (string) $source_url ); + $customer_id = trim( (string) $customer_id ); + $terms = is_array( $terms ) ? $terms : []; + + if ( $product_id <= 0 || $source_url === '' ) + { + return false; + } + + $mdb -> delete( 'products_keyword_planner_terms', [ + 'AND' => [ + 'product_id' => $product_id, + 'source_url' => $source_url + ] + ] ); + + if ( empty( $terms ) ) + { + return true; + } + + foreach ( $terms as $term ) + { + $keyword_text = trim( (string) ( $term['keyword'] ?? '' ) ); + if ( $keyword_text === '' ) + { + continue; + } + + $mdb -> insert( 'products_keyword_planner_terms', [ + 'product_id' => $product_id, + 'source_url' => $source_url, + 'keyword_text' => mb_substr( $keyword_text, 0, 255 ), + 'avg_monthly_searches' => (int) ( $term['avg_monthly_searches'] ?? 0 ), + 'competition' => $term['competition'] ?? null, + 'competition_index' => isset( $term['competition_index'] ) ? (int) $term['competition_index'] : null, + 'source_customer_id' => $customer_id !== '' ? $customer_id : null, + 'date_add' => date( 'Y-m-d H:i:s' ) + ] ); + } + + return true; + } + static public function set_product_data( $product_id, $field, $value ) { global $mdb; @@ -406,4 +615,239 @@ class Products else return $mdb -> insert( 'products_comments', [ 'product_id' => $product_id, 'comment' => $comment, 'date_add' => $date ] ); } + + static public function get_product_scope_context( $product_id ) + { + global $mdb; + + return $mdb -> query( + 'SELECT + p.id, + p.client_id, + p.offer_id, + p.name, + cl.google_ads_customer_id, + cl.google_merchant_account_id + FROM products AS p + INNER JOIN clients AS cl ON cl.id = p.client_id + WHERE p.id = :product_id + LIMIT 1', + [ ':product_id' => (int) $product_id ] + ) -> fetch( \PDO::FETCH_ASSOC ); + } + + static public function get_campaign_scope_context( $campaign_id ) + { + global $mdb; + + return $mdb -> query( + 'SELECT id, client_id, campaign_id, campaign_name + FROM campaigns + WHERE id = :campaign_id + LIMIT 1', + [ ':campaign_id' => (int) $campaign_id ] + ) -> fetch( \PDO::FETCH_ASSOC ); + } + + static public function get_ad_group_scope_context( $ad_group_id ) + { + global $mdb; + + return $mdb -> query( + 'SELECT id, campaign_id, ad_group_id, ad_group_name + FROM campaign_ad_groups + WHERE id = :ad_group_id + LIMIT 1', + [ ':ad_group_id' => (int) $ad_group_id ] + ) -> fetch( \PDO::FETCH_ASSOC ); + } + + static private function get_next_local_campaign_external_id( $client_id ) + { + global $mdb; + + $min_external_id = (int) $mdb -> query( + 'SELECT MIN( campaign_id ) + FROM campaigns + WHERE client_id = :client_id', + [ ':client_id' => (int) $client_id ] + ) -> fetchColumn(); + + if ( $min_external_id < 0 ) + { + return $min_external_id - 1; + } + + return -1; + } + + static private function get_next_local_ad_group_external_id( $campaign_id ) + { + global $mdb; + + $min_external_id = (int) $mdb -> query( + 'SELECT MIN( ad_group_id ) + FROM campaign_ad_groups + WHERE campaign_id = :campaign_id', + [ ':campaign_id' => (int) $campaign_id ] + ) -> fetchColumn(); + + if ( $min_external_id < 0 ) + { + return $min_external_id - 1; + } + + return -1; + } + + static public function create_local_campaign( $client_id, $campaign_name ) + { + global $mdb; + + $client_id = (int) $client_id; + $campaign_name = trim( (string) $campaign_name ); + + if ( $client_id <= 0 || $campaign_name === '' ) + { + return 0; + } + + $existing_campaign_id = (int) $mdb -> get( 'campaigns', 'id', [ + 'AND' => [ + 'client_id' => $client_id, + 'campaign_name' => $campaign_name + ] + ] ); + + if ( $existing_campaign_id > 0 ) + { + return $existing_campaign_id; + } + + $mdb -> insert( 'campaigns', [ + 'client_id' => $client_id, + 'campaign_id' => self::get_next_local_campaign_external_id( $client_id ), + 'campaign_name' => $campaign_name + ] ); + + return (int) $mdb -> id(); + } + + static public function create_local_ad_group( $campaign_id, $ad_group_name ) + { + global $mdb; + + $campaign_id = (int) $campaign_id; + $ad_group_name = trim( (string) $ad_group_name ); + + if ( $campaign_id <= 0 || $ad_group_name === '' ) + { + return 0; + } + + $existing_ad_group_id = (int) $mdb -> get( 'campaign_ad_groups', 'id', [ + 'AND' => [ + 'campaign_id' => $campaign_id, + 'ad_group_name' => $ad_group_name + ] + ] ); + + if ( $existing_ad_group_id > 0 ) + { + return $existing_ad_group_id; + } + + $mdb -> insert( 'campaign_ad_groups', [ + 'campaign_id' => $campaign_id, + 'ad_group_id' => self::get_next_local_ad_group_external_id( $campaign_id ), + 'ad_group_name' => $ad_group_name, + 'impressions_30' => 0, + 'clicks_30' => 0, + 'cost_30' => 0, + 'conversions_30' => 0, + 'conversion_value_30' => 0, + 'roas_30' => 0, + 'impressions_all_time' => 0, + 'clicks_all_time' => 0, + 'cost_all_time' => 0, + 'conversions_all_time' => 0, + 'conversion_value_all_time' => 0, + 'roas_all_time' => 0, + 'date_sync' => date( 'Y-m-d' ) + ] ); + + return (int) $mdb -> id(); + } + + static public function assign_product_scope( $product_id, $campaign_id, $ad_group_id ) + { + global $mdb; + + $product_id = (int) $product_id; + $campaign_id = (int) $campaign_id; + $ad_group_id = (int) $ad_group_id; + + if ( $product_id <= 0 || $campaign_id <= 0 || $ad_group_id <= 0 ) + { + return false; + } + + $product = self::get_product_scope_context( $product_id ); + if ( !$product ) + { + return false; + } + + $campaign_client_id = (int) $mdb -> get( 'campaigns', 'client_id', [ 'id' => $campaign_id ] ); + if ( $campaign_client_id <= 0 || $campaign_client_id !== (int) $product['client_id'] ) + { + return false; + } + + $ad_group_campaign_id = (int) $mdb -> get( 'campaign_ad_groups', 'campaign_id', [ 'id' => $ad_group_id ] ); + if ( $ad_group_campaign_id <= 0 || $ad_group_campaign_id !== $campaign_id ) + { + return false; + } + + $scope_exists = (int) $mdb -> count( 'products_temp', [ + 'AND' => [ + 'product_id' => $product_id, + 'campaign_id' => $campaign_id, + 'ad_group_id' => $ad_group_id + ] + ] ) > 0; + + if ( $scope_exists ) + { + $mdb -> update( 'products_temp', [ + 'name' => $product['name'] + ], [ + 'AND' => [ + 'product_id' => $product_id, + 'campaign_id' => $campaign_id, + 'ad_group_id' => $ad_group_id + ] + ] ); + + return true; + } + + return $mdb -> insert( 'products_temp', [ + 'product_id' => $product_id, + 'campaign_id' => $campaign_id, + 'ad_group_id' => $ad_group_id, + 'name' => $product['name'], + 'impressions' => 0, + 'impressions_30' => 0, + 'clicks' => 0, + 'clicks_30' => 0, + 'ctr' => 0, + 'cost' => 0, + 'conversions' => 0, + 'conversions_value' => 0, + 'cpc' => 0, + 'roas' => 0 + ] ); + } } diff --git a/autoload/factory/class.XmlFiles.php b/autoload/factory/class.XmlFiles.php new file mode 100644 index 0000000..0c4c8e7 --- /dev/null +++ b/autoload/factory/class.XmlFiles.php @@ -0,0 +1,43 @@ + query( + "SELECT id, name, google_ads_customer_id + FROM clients + WHERE deleted = 0 + ORDER BY name ASC" + ) -> fetchAll( \PDO::FETCH_ASSOC ); + + $rows = []; + + foreach ( $clients as $client ) + { + $client_id = (int) ( $client['id'] ?? 0 ); + $scheme = ( !empty( $_SERVER['HTTPS'] ) && $_SERVER['HTTPS'] !== 'off' ) ? 'https' : 'http'; + $host = $_SERVER['HTTP_HOST'] ?? ( $_SERVER['SERVER_NAME'] ?? 'localhost' ); + $relative_path = '/xml/custom-feed-' . $client_id . '.xml'; + $absolute_url = $scheme . '://' . $host . $relative_path; + $absolute_path = dirname( __DIR__, 2 ) . DIRECTORY_SEPARATOR . 'xml' . DIRECTORY_SEPARATOR . 'custom-feed-' . $client_id . '.xml'; + $exists = is_file( $absolute_path ); + $last_modified = $exists ? date( 'Y-m-d H:i:s', (int) filemtime( $absolute_path ) ) : ''; + + $rows[] = [ + 'client_id' => $client_id, + 'client_name' => (string) ( $client['name'] ?? '' ), + 'google_ads_customer_id' => (string) ( $client['google_ads_customer_id'] ?? '' ), + 'xml_relative_path' => $relative_path, + 'xml_url' => $absolute_url, + 'xml_exists' => $exists, + 'xml_last_modified' => $last_modified + ]; + } + + return $rows; + } +} diff --git a/autoload/services/class.ClaudeApi.php b/autoload/services/class.ClaudeApi.php index ff2efd8..662c0df 100644 --- a/autoload/services/class.ClaudeApi.php +++ b/autoload/services/class.ClaudeApi.php @@ -153,6 +153,31 @@ Twoje odpowiedzi muszą być: static public function suggest_title( $context ) { $context_text = self::build_context_text( $context ); + $keyword_planner_text = ''; + + if ( !empty( $context['keyword_planner_terms'] ) && is_array( $context['keyword_planner_terms'] ) ) + { + $keyword_lines = []; + $keyword_lines[] = 'Najpopularniejsze frazy z Google Ads Keyword Planner (na bazie URL produktu, posortowane malejąco po średniej liczbie wyszukiwań):'; + + foreach ( array_slice( $context['keyword_planner_terms'], 0, 15 ) as $term ) + { + $text = trim( (string) ( $term['keyword_text'] ?? '' ) ); + if ( $text === '' ) + { + continue; + } + + $avg_monthly = (int) ( $term['avg_monthly_searches'] ?? 0 ); + $keyword_lines[] = '- ' . $text . ' (avg miesięcznie: ' . $avg_monthly . ')'; + } + + if ( count( $keyword_lines ) > 1 ) + { + $keyword_lines[] = 'Użyj tych fraz WYBIÓRCZO i naturalnie (bez upychania słów kluczowych), tylko jeśli pasują do produktu.'; + $keyword_planner_text = "\n\n" . implode( "\n", $keyword_lines ); + } + } $prompt = 'Zaproponuj zoptymalizowany tytuł produktu dla Google Merchant Center. @@ -170,7 +195,7 @@ BEZWZGLĘDNY ZAKAZ (odrzucenie przez Google): - Cechy wymyślone — opisuj TYLKO to co wynika z oryginalnej nazwy lub treści strony produktu - Jeśli podano treść ze strony produktu, wykorzystaj ją do wzbogacenia tytułu o rzeczywiste cechy (marka, materiał, kolor, rozmiar itp.) -' . $context_text . ' +' . $context_text . $keyword_planner_text . ' Zwróć TYLKO tytuł, bez cudzysłowów, bez wyjaśnień.'; @@ -181,6 +206,31 @@ Zwróć TYLKO tytuł, bez cudzysłowów, bez wyjaśnień.'; { $context_text = self::build_context_text( $context ); $has_page = !empty( $context['page_content'] ); + $keyword_planner_text = ''; + + if ( !empty( $context['keyword_planner_terms'] ) && is_array( $context['keyword_planner_terms'] ) ) + { + $keyword_lines = []; + $keyword_lines[] = 'Najpopularniejsze frazy z Google Ads Keyword Planner (na bazie URL produktu, posortowane malejąco po średniej liczbie wyszukiwań):'; + + foreach ( array_slice( $context['keyword_planner_terms'], 0, 15 ) as $term ) + { + $text = trim( (string) ( $term['keyword_text'] ?? '' ) ); + if ( $text === '' ) + { + continue; + } + + $avg_monthly = (int) ( $term['avg_monthly_searches'] ?? 0 ); + $keyword_lines[] = '- ' . $text . ' (avg miesięcznie: ' . $avg_monthly . ')'; + } + + if ( count( $keyword_lines ) > 1 ) + { + $keyword_lines[] = 'W opisie wykorzystuj te frazy naturalnie i wyłącznie gdy realnie pasują do produktu (bez keyword stuffing).'; + $keyword_planner_text = "\n\n" . implode( "\n", $keyword_lines ); + } + } $length_guide = $has_page ? '- Napisz rozbudowany, szczegółowy opis: ok. 1000 znaków (800-1200) @@ -213,7 +263,7 @@ BEZWZGLĘDNY ZAKAZ (odrzucenie przez Google): - Opisy akcesoriów/produktów nie wchodzących w skład oferty - Cechy wymyślone — opisuj TYLKO to co wynika z nazwy lub treści strony produktu -' . $context_text . ' +' . $context_text . $keyword_planner_text . ' Zwróć TYLKO opis w formacie HTML (używając dozwolonych tagów), bez cudzysłowów, bez wyjaśnień.'; diff --git a/autoload/services/class.GoogleAdsApi.php b/autoload/services/class.GoogleAdsApi.php index fc4f373..c25f350 100644 --- a/autoload/services/class.GoogleAdsApi.php +++ b/autoload/services/class.GoogleAdsApi.php @@ -7,12 +7,14 @@ class GoogleAdsApi private $client_id; private $client_secret; private $refresh_token; + private $merchant_refresh_token; private $manager_account_id; private $access_token; private static $API_VERSION = 'v23'; private static $TOKEN_URL = 'https://oauth2.googleapis.com/token'; private static $ADS_BASE_URL = 'https://googleads.googleapis.com'; + private static $MERCHANT_BASE_URL = 'https://shoppingcontent.googleapis.com/content/v2.1'; public function __construct() { @@ -20,6 +22,7 @@ class GoogleAdsApi $this -> client_id = self::get_setting( 'google_ads_client_id' ); $this -> client_secret = self::get_setting( 'google_ads_client_secret' ); $this -> refresh_token = self::get_setting( 'google_ads_refresh_token' ); + $this -> merchant_refresh_token = self::get_setting( 'google_merchant_refresh_token' ); $this -> manager_account_id = self::get_setting( 'google_ads_manager_account_id' ); } @@ -55,6 +58,344 @@ class GoogleAdsApi && !empty( $this -> refresh_token ); } + public function is_merchant_configured() + { + $merchant_refresh_token = trim( (string) $this -> merchant_refresh_token ); + if ( $merchant_refresh_token === '' ) + { + $merchant_refresh_token = trim( (string) $this -> refresh_token ); + } + + return !empty( $this -> client_id ) + && !empty( $this -> client_secret ) + && $merchant_refresh_token !== ''; + } + + public function get_merchant_product_links_for_offer_ids( $merchant_account_id, $offer_ids ) + { + $merchant_account_id = preg_replace( '/\D+/', '', (string) $merchant_account_id ); + $offer_ids = array_values( array_unique( array_filter( array_map( function( $item ) + { + return trim( (string) $item ); + }, (array) $offer_ids ) ) ) ); + + if ( $merchant_account_id === '' ) + { + self::set_setting( 'google_merchant_last_error', 'Brak Merchant Account ID.' ); + return false; + } + + if ( empty( $offer_ids ) ) + { + return []; + } + + $access_token = $this -> get_merchant_access_token(); + if ( !$access_token ) + { + return false; + } + + $remaining = array_fill_keys( $offer_ids, true ); + $found = []; + $page_token = ''; + $safety_limit = 500; + + while ( $safety_limit-- > 0 ) + { + $query = [ 'maxResults' => 250 ]; + if ( $page_token !== '' ) + { + $query['pageToken'] = $page_token; + } + + $url = self::$MERCHANT_BASE_URL . '/' . rawurlencode( $merchant_account_id ) . '/products?' . http_build_query( $query ); + + $ch = curl_init( $url ); + curl_setopt_array( $ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $access_token, + 'Accept: application/json' + ], + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_TIMEOUT => 120, + ] ); + + $response = curl_exec( $ch ); + $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + $error = curl_error( $ch ); + curl_close( $ch ); + + if ( $http_code !== 200 || !$response ) + { + self::set_setting( 'google_merchant_last_error', 'Merchant products.list failed: HTTP ' . $http_code . ' | ' . $error . ' | ' . substr( (string) $response, 0, 1000 ) ); + return false; + } + + $payload = json_decode( (string) $response, true ); + if ( !is_array( $payload ) ) + { + self::set_setting( 'google_merchant_last_error', 'Merchant products.list: niepoprawna odpowiedz JSON.' ); + return false; + } + + $items = isset( $payload['resources'] ) && is_array( $payload['resources'] ) + ? $payload['resources'] + : ( isset( $payload['items'] ) && is_array( $payload['items'] ) ? $payload['items'] : [] ); + + foreach ( $items as $item ) + { + $offer_id = trim( (string) ( $item['offerId'] ?? '' ) ); + if ( $offer_id === '' || !isset( $remaining[ $offer_id ] ) ) + { + continue; + } + + $link = trim( (string) ( $item['link'] ?? '' ) ); + if ( !$this -> is_valid_merchant_product_url( $link ) ) + { + continue; + } + + $found[ $offer_id ] = $link; + unset( $remaining[ $offer_id ] ); + } + + if ( empty( $remaining ) ) + { + break; + } + + $page_token = trim( (string) ( $payload['nextPageToken'] ?? '' ) ); + if ( $page_token === '' ) + { + break; + } + } + + self::set_setting( 'google_merchant_last_error', null ); + return $found; + } + + public function get_merchant_products_for_offer_ids( $merchant_account_id, $offer_ids ) + { + $merchant_account_id = preg_replace( '/\D+/', '', (string) $merchant_account_id ); + $offer_ids = array_values( array_unique( array_filter( array_map( function( $item ) + { + return trim( (string) $item ); + }, (array) $offer_ids ) ) ) ); + + if ( $merchant_account_id === '' ) + { + self::set_setting( 'google_merchant_last_error', 'Brak Merchant Account ID.' ); + return false; + } + + if ( empty( $offer_ids ) ) + { + return []; + } + + $access_token = $this -> get_merchant_access_token(); + if ( !$access_token ) + { + return false; + } + + $remaining = array_fill_keys( $offer_ids, true ); + $found = []; + $page_token = ''; + $safety_limit = 500; + + while ( $safety_limit-- > 0 ) + { + $query = [ 'maxResults' => 250 ]; + if ( $page_token !== '' ) + { + $query['pageToken'] = $page_token; + } + + $url = self::$MERCHANT_BASE_URL . '/' . rawurlencode( $merchant_account_id ) . '/products?' . http_build_query( $query ); + + $ch = curl_init( $url ); + curl_setopt_array( $ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $access_token, + 'Accept: application/json' + ], + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_TIMEOUT => 120, + ] ); + + $response = curl_exec( $ch ); + $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + $error = curl_error( $ch ); + curl_close( $ch ); + + if ( $http_code !== 200 || !$response ) + { + self::set_setting( 'google_merchant_last_error', 'Merchant products.list failed: HTTP ' . $http_code . ' | ' . $error . ' | ' . substr( (string) $response, 0, 1000 ) ); + return false; + } + + $payload = json_decode( (string) $response, true ); + if ( !is_array( $payload ) ) + { + self::set_setting( 'google_merchant_last_error', 'Merchant products.list: niepoprawna odpowiedz JSON.' ); + return false; + } + + $items = isset( $payload['resources'] ) && is_array( $payload['resources'] ) + ? $payload['resources'] + : ( isset( $payload['items'] ) && is_array( $payload['items'] ) ? $payload['items'] : [] ); + + foreach ( $items as $item ) + { + $offer_id = trim( (string) ( $item['offerId'] ?? '' ) ); + if ( $offer_id === '' || !isset( $remaining[ $offer_id ] ) ) + { + continue; + } + + $found[ $offer_id ] = $item; + unset( $remaining[ $offer_id ] ); + } + + if ( empty( $remaining ) ) + { + break; + } + + $page_token = trim( (string) ( $payload['nextPageToken'] ?? '' ) ); + if ( $page_token === '' ) + { + break; + } + } + + self::set_setting( 'google_merchant_last_error', null ); + return $found; + } + + public function update_merchant_product_fields_by_offer_id( $merchant_account_id, $offer_id, $fields ) + { + $merchant_account_id = preg_replace( '/\D+/', '', (string) $merchant_account_id ); + $offer_id = trim( (string) $offer_id ); + $fields = is_array( $fields ) ? $fields : []; + + if ( $merchant_account_id === '' || $offer_id === '' ) + { + self::set_setting( 'google_merchant_last_error', 'Brak Merchant Account ID lub offer_id.' ); + return [ 'success' => false, 'error' => 'Brak Merchant Account ID lub offer_id.' ]; + } + + $field_map = [ + 'title' => 'title', + 'description' => 'description', + 'google_product_category' => 'googleProductCategory', + 'custom_label_0' => 'customLabel0', + 'custom_label_1' => 'customLabel1', + 'custom_label_2' => 'customLabel2', + 'custom_label_3' => 'customLabel3', + 'custom_label_4' => 'customLabel4' + ]; + + $patch_payload = []; + foreach ( $fields as $key => $value ) + { + if ( !isset( $field_map[ $key ] ) ) + { + continue; + } + + $patch_payload[ $field_map[ $key ] ] = $value; + } + + if ( empty( $patch_payload ) ) + { + return [ 'success' => true, 'skipped' => true, 'message' => 'Brak wspieranych pol do synchronizacji.' ]; + } + + $items_map = $this -> get_merchant_products_for_offer_ids( $merchant_account_id, [ $offer_id ] ); + if ( $items_map === false || !isset( $items_map[ $offer_id ] ) ) + { + $error_message = trim( (string) self::get_setting( 'google_merchant_last_error' ) ); + if ( $error_message === '' ) + { + $error_message = 'Nie znaleziono produktu w Merchant Center dla offer_id: ' . $offer_id; + } + + return [ 'success' => false, 'error' => $error_message ]; + } + + $merchant_item = $items_map[ $offer_id ]; + $merchant_product_id = trim( (string) ( $merchant_item['id'] ?? '' ) ); + + if ( $merchant_product_id === '' ) + { + return [ 'success' => false, 'error' => 'Brak identyfikatora produktu Merchant (id) dla offer_id: ' . $offer_id ]; + } + + $access_token = $this -> get_merchant_access_token(); + if ( !$access_token ) + { + return [ 'success' => false, 'error' => (string) self::get_setting( 'google_merchant_last_error' ) ]; + } + + $url = self::$MERCHANT_BASE_URL + . '/' . rawurlencode( $merchant_account_id ) + . '/products/' . rawurlencode( $merchant_product_id ); + + $ch = curl_init( $url ); + curl_setopt_array( $ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => 'PATCH', + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $access_token, + 'Content-Type: application/json', + 'Accept: application/json' + ], + CURLOPT_POSTFIELDS => json_encode( $patch_payload ), + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_TIMEOUT => 120, + ] ); + + $response = curl_exec( $ch ); + $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + $error = curl_error( $ch ); + curl_close( $ch ); + + if ( $http_code !== 200 || !$response ) + { + $error_data = json_decode( (string) $response, true ); + $error_message = (string) ( $error_data['error']['message'] ?? '' ); + if ( $error_message === '' ) + { + $error_message = 'Merchant products.patch failed: HTTP ' . $http_code . ' | ' . $error . ' | ' . substr( (string) $response, 0, 1000 ); + } + + self::set_setting( 'google_merchant_last_error', $error_message ); + return [ + 'success' => false, + 'error' => $error_message, + 'merchant_product_id' => $merchant_product_id, + 'response' => $response + ]; + } + + $response_data = json_decode( (string) $response, true ); + self::set_setting( 'google_merchant_last_error', null ); + + return [ + 'success' => true, + 'merchant_product_id' => $merchant_product_id, + 'response' => $response_data, + 'patched_fields' => array_keys( $patch_payload ) + ]; + } + // --- OAuth2 --- private function get_access_token() @@ -116,6 +457,76 @@ class GoogleAdsApi return $this -> access_token; } + private function get_merchant_access_token() + { + $cached_token = self::get_setting( 'google_merchant_access_token' ); + $cached_expires = (int) self::get_setting( 'google_merchant_access_token_expires' ); + + if ( $cached_token && $cached_expires > time() ) + { + return $cached_token; + } + + return $this -> refresh_merchant_access_token(); + } + + private function refresh_merchant_access_token() + { + $merchant_refresh_token = trim( (string) $this -> merchant_refresh_token ); + if ( $merchant_refresh_token === '' ) + { + $merchant_refresh_token = trim( (string) $this -> refresh_token ); + } + + if ( $merchant_refresh_token === '' ) + { + self::set_setting( 'google_merchant_last_error', 'Brak refresh tokena dla Merchant API.' ); + return false; + } + + $ch = curl_init( self::$TOKEN_URL ); + curl_setopt_array( $ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query( [ + 'client_id' => $this -> client_id, + 'client_secret' => $this -> client_secret, + 'refresh_token' => $merchant_refresh_token, + 'grant_type' => 'refresh_token' + ] ), + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_TIMEOUT => 30, + ] ); + + $response = curl_exec( $ch ); + $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + $error = curl_error( $ch ); + curl_close( $ch ); + + if ( $http_code !== 200 || !$response ) + { + self::set_setting( 'google_merchant_last_error', 'Merchant token refresh failed: HTTP ' . $http_code . ' | ' . $error . ' | ' . $response ); + return false; + } + + $data = json_decode( (string) $response, true ); + + if ( !isset( $data['access_token'] ) ) + { + self::set_setting( 'google_merchant_last_error', 'Merchant token refresh: brak access_token w odpowiedzi' ); + return false; + } + + $access_token = (string) $data['access_token']; + $expires_at = time() + ( $data['expires_in'] ?? 3600 ) - 60; + + self::set_setting( 'google_merchant_access_token', $access_token ); + self::set_setting( 'google_merchant_access_token_expires', $expires_at ); + self::set_setting( 'google_merchant_last_error', null ); + + return $access_token; + } + // --- Google Ads API --- public function search_stream( $customer_id, $gaql_query ) @@ -238,6 +649,870 @@ class GoogleAdsApi return $data; } + private function normalize_ads_customer_id( $customer_id ) + { + return preg_replace( '/\D+/', '', (string) $customer_id ); + } + + private function parse_resource_id( $resource_name ) + { + $resource_name = trim( (string) $resource_name ); + + if ( preg_match( '#/(\d+)$#', $resource_name, $matches ) ) + { + return (int) $matches[1]; + } + + return 0; + } + + public function create_standard_shopping_campaign( $customer_id, $merchant_account_id, $campaign_name, $daily_budget = 50.0, $sales_country = 'PL' ) + { + $customer_id = $this -> normalize_ads_customer_id( $customer_id ); + $merchant_account_id = preg_replace( '/\D+/', '', (string) $merchant_account_id ); + $campaign_name = trim( (string) $campaign_name ); + $sales_country = strtoupper( trim( (string) $sales_country ) ); + $daily_budget = (float) $daily_budget; + + if ( $customer_id === '' || $merchant_account_id === '' || $campaign_name === '' ) + { + self::set_setting( 'google_ads_last_error', 'Brak danych do utworzenia kampanii Standard Shopping.' ); + return [ 'success' => false ]; + } + + if ( $sales_country === '' ) + { + $sales_country = 'PL'; + } + + if ( $daily_budget <= 0 ) + { + $daily_budget = 50.0; + } + + $budget_micros = max( 1000000, (int) round( $daily_budget * 1000000 ) ); + $budget_tmp_resource = 'customers/' . $customer_id . '/campaignBudgets/-1'; + + $operations = [ + [ + 'campaignBudgetOperation' => [ + 'create' => [ + 'resourceName' => $budget_tmp_resource, + 'name' => 'adsPRO | Budget | ' . $campaign_name . ' | ' . date( 'Y-m-d H:i:s' ), + 'amountMicros' => $budget_micros, + 'deliveryMethod' => 'STANDARD', + 'explicitlyShared' => false + ] + ] + ], + [ + 'campaignOperation' => [ + 'create' => [ + 'name' => $campaign_name, + 'status' => 'PAUSED', + 'advertisingChannelType' => 'SHOPPING', + 'campaignBudget' => $budget_tmp_resource, + 'manualCpc' => (object) [], + 'shoppingSetting' => [ + 'merchantId' => (int) $merchant_account_id, + 'salesCountry' => $sales_country, + 'campaignPriority' => 0, + 'enableLocal' => false + ] + ] + ] + ] + ]; + + $result = $this -> mutate( $customer_id, $operations ); + + if ( $result === false ) + { + return [ + 'success' => false, + 'error' => (string) self::get_setting( 'google_ads_last_error' ), + 'sent_operations' => $operations + ]; + } + + $campaign_resource_name = ''; + foreach ( (array) ( $result['mutateOperationResponses'] ?? [] ) as $row ) + { + $campaign_resource_name = (string) ( $row['campaignResult']['resourceName'] ?? '' ); + if ( $campaign_resource_name === '' ) + { + $campaign_resource_name = (string) ( $row['campaign_result']['resource_name'] ?? '' ); + } + + if ( $campaign_resource_name !== '' ) + { + break; + } + } + + $campaign_id = $this -> parse_resource_id( $campaign_resource_name ); + + if ( $campaign_id <= 0 ) + { + self::set_setting( 'google_ads_last_error', 'Nie udało się odczytać ID kampanii z odpowiedzi mutate.' ); + return [ 'success' => false, 'response' => $result ]; + } + + return [ + 'success' => true, + 'campaign_id' => $campaign_id, + 'campaign_name' => $campaign_name, + 'response' => $result + ]; + } + + private function get_listing_groups_count_for_ad_group( $customer_id, $ad_group_id ) + { + $customer_id = $this -> normalize_ads_customer_id( $customer_id ); + $ad_group_id = (int) $ad_group_id; + + if ( $customer_id === '' || $ad_group_id <= 0 ) + { + return false; + } + + $gaql = "SELECT " + . "ad_group_criterion.resource_name " + . "FROM ad_group_criterion " + . "WHERE ad_group.id = " . $ad_group_id . " " + . "AND ad_group_criterion.type = 'LISTING_GROUP' " + . "LIMIT 100"; + + $rows = $this -> search_stream( $customer_id, $gaql ); + if ( !is_array( $rows ) ) + { + return false; + } + + return count( $rows ); + } + + private function get_listing_group_nodes_for_ad_group( $customer_id, $ad_group_id ) + { + $customer_id = $this -> normalize_ads_customer_id( $customer_id ); + $ad_group_id = (int) $ad_group_id; + + if ( $customer_id === '' || $ad_group_id <= 0 ) + { + return false; + } + + $gaql = "SELECT " + . "ad_group_criterion.resource_name, " + . "ad_group_criterion.listing_group.parent_ad_group_criterion " + . "FROM ad_group_criterion " + . "WHERE ad_group.id = " . $ad_group_id . " " + . "AND ad_group_criterion.type = 'LISTING_GROUP' " + . "LIMIT 1000"; + + $rows = $this -> search_stream( $customer_id, $gaql ); + if ( !is_array( $rows ) ) + { + return false; + } + + $nodes = []; + foreach ( $rows as $row ) + { + $resource_name = trim( (string) ( $row['adGroupCriterion']['resourceName'] ?? $row['ad_group_criterion']['resource_name'] ?? '' ) ); + if ( $resource_name === '' ) + { + continue; + } + + $parent_resource = trim( (string) ( $row['adGroupCriterion']['listingGroup']['parentAdGroupCriterion'] ?? $row['ad_group_criterion']['listing_group']['parent_ad_group_criterion'] ?? '' ) ); + + $nodes[] = [ + 'resource_name' => $resource_name, + 'parent_resource_name' => $parent_resource + ]; + } + + return $nodes; + } + + private function build_listing_group_removal_order( $nodes ) + { + $remaining = []; + foreach ( (array) $nodes as $node ) + { + $resource_name = trim( (string) ( $node['resource_name'] ?? '' ) ); + if ( $resource_name === '' ) + { + continue; + } + + $remaining[ $resource_name ] = trim( (string) ( $node['parent_resource_name'] ?? '' ) ); + } + + $order = []; + + while ( !empty( $remaining ) ) + { + $parent_set = []; + foreach ( $remaining as $resource_name => $parent_resource ) + { + if ( $parent_resource !== '' && isset( $remaining[ $parent_resource ] ) ) + { + $parent_set[ $parent_resource ] = true; + } + } + + $leaf_resources = []; + foreach ( $remaining as $resource_name => $parent_resource ) + { + if ( !isset( $parent_set[ $resource_name ] ) ) + { + $leaf_resources[] = $resource_name; + } + } + + if ( empty( $leaf_resources ) ) + { + foreach ( array_keys( $remaining ) as $resource_name ) + { + $order[] = $resource_name; + } + break; + } + + foreach ( $leaf_resources as $resource_name ) + { + $order[] = $resource_name; + unset( $remaining[ $resource_name ] ); + } + } + + return array_values( array_unique( $order ) ); + } + + private function clear_listing_groups_in_ad_group( $customer_id, $ad_group_id ) + { + $customer_id = $this -> normalize_ads_customer_id( $customer_id ); + $ad_group_id = (int) $ad_group_id; + + if ( $customer_id === '' || $ad_group_id <= 0 ) + { + return [ 'success' => false, 'removed' => 0 ]; + } + + $nodes = $this -> get_listing_group_nodes_for_ad_group( $customer_id, $ad_group_id ); + if ( $nodes === false ) + { + return [ + 'success' => false, + 'removed' => 0, + 'error' => (string) self::get_setting( 'google_ads_last_error' ) + ]; + } + + if ( empty( $nodes ) ) + { + return [ 'success' => true, 'removed' => 0 ]; + } + + $removal_order = $this -> build_listing_group_removal_order( $nodes ); + + if ( empty( $removal_order ) ) + { + return [ 'success' => true, 'removed' => 0 ]; + } + + $operations = []; + foreach ( $removal_order as $resource_name ) + { + $operations[] = [ + 'adGroupCriterionOperation' => [ + 'remove' => $resource_name + ] + ]; + } + + $result = $this -> mutate( $customer_id, $operations ); + if ( $result === false ) + { + return [ + 'success' => false, + 'removed' => 0, + 'error' => (string) self::get_setting( 'google_ads_last_error' ), + 'sent_operations_count' => count( $operations ) + ]; + } + + return [ 'success' => true, 'removed' => count( $operations ) ]; + } + + private function find_ad_group_id_by_campaign_and_name( $customer_id, $campaign_id, $ad_group_name ) + { + $customer_id = $this -> normalize_ads_customer_id( $customer_id ); + $campaign_id = (int) $campaign_id; + $ad_group_name = trim( (string) $ad_group_name ); + + if ( $customer_id === '' || $campaign_id <= 0 || $ad_group_name === '' ) + { + return 0; + } + + $gaql = "SELECT " + . "ad_group.id " + . "FROM ad_group " + . "WHERE campaign.id = " . $campaign_id . " " + . "AND ad_group.name = '" . $this -> gaql_escape( $ad_group_name ) . "' " + . "AND ad_group.status != 'REMOVED' " + . "LIMIT 1"; + + $rows = $this -> search_stream( $customer_id, $gaql ); + if ( !is_array( $rows ) || empty( $rows ) ) + { + return 0; + } + + return (int) ( $rows[0]['adGroup']['id'] ?? $rows[0]['ad_group']['id'] ?? 0 ); + } + + private function get_ad_group_status( $customer_id, $ad_group_id ) + { + $customer_id = $this -> normalize_ads_customer_id( $customer_id ); + $ad_group_id = (int) $ad_group_id; + + if ( $customer_id === '' || $ad_group_id <= 0 ) + { + return ''; + } + + $gaql = "SELECT " + . "ad_group.status " + . "FROM ad_group " + . "WHERE ad_group.id = " . $ad_group_id . " " + . "LIMIT 1"; + + $rows = $this -> search_stream( $customer_id, $gaql ); + if ( !is_array( $rows ) || empty( $rows ) ) + { + return ''; + } + + return strtoupper( trim( (string) ( $rows[0]['adGroup']['status'] ?? $rows[0]['ad_group']['status'] ?? '' ) ) ); + } + + private function enable_ad_group( $customer_id, $ad_group_id ) + { + $customer_id = $this -> normalize_ads_customer_id( $customer_id ); + $ad_group_id = (int) $ad_group_id; + + if ( $customer_id === '' || $ad_group_id <= 0 ) + { + return false; + } + + $operation = [ + 'adGroupOperation' => [ + 'update' => [ + 'resourceName' => 'customers/' . $customer_id . '/adGroups/' . $ad_group_id, + 'status' => 'ENABLED' + ], + 'updateMask' => 'status' + ] + ]; + + return $this -> mutate( $customer_id, [ $operation ] ) !== false; + } + + private function get_root_listing_group_resource_name( $customer_id, $ad_group_id ) + { + $customer_id = $this -> normalize_ads_customer_id( $customer_id ); + $ad_group_id = (int) $ad_group_id; + + if ( $customer_id === '' || $ad_group_id <= 0 ) + { + return ''; + } + + $gaql = "SELECT " + . "ad_group_criterion.resource_name, " + . "ad_group_criterion.listing_group.type " + . "FROM ad_group_criterion " + . "WHERE ad_group.id = " . $ad_group_id . " " + . "AND ad_group_criterion.type = 'LISTING_GROUP' " + . "LIMIT 50"; + + $rows = $this -> search_stream( $customer_id, $gaql ); + if ( !is_array( $rows ) || empty( $rows ) ) + { + return ''; + } + + $fallback_resource = ''; + + foreach ( $rows as $row ) + { + $type = strtoupper( (string) ( $row['listingGroup']['type'] ?? $row['listing_group']['type'] ?? '' ) ); + $resource_name = trim( (string) ( $row['adGroupCriterion']['resourceName'] ?? $row['ad_group_criterion']['resource_name'] ?? '' ) ); + + if ( $resource_name === '' ) + { + continue; + } + + if ( $fallback_resource === '' ) + { + $fallback_resource = $resource_name; + } + + if ( $type === 'SUBDIVISION' ) + { + return $resource_name; + } + } + + return $fallback_resource; + } + + private function extract_first_ad_group_criterion_resource_name_from_mutate( $mutate_response ) + { + foreach ( (array) ( $mutate_response['mutateOperationResponses'] ?? [] ) as $row ) + { + $resource_name = trim( (string) ( $row['adGroupCriterionResult']['resourceName'] ?? '' ) ); + if ( $resource_name === '' ) + { + $resource_name = trim( (string) ( $row['ad_group_criterion_result']['resource_name'] ?? '' ) ); + } + + if ( $resource_name !== '' ) + { + return $resource_name; + } + } + + return ''; + } + + public function ensure_standard_shopping_offer_in_ad_group( $customer_id, $ad_group_id, $offer_id, $cpc_bid = 1.00 ) + { + $customer_id = $this -> normalize_ads_customer_id( $customer_id ); + $ad_group_id = (int) $ad_group_id; + $offer_id = trim( (string) $offer_id ); + $cpc_bid = (float) $cpc_bid; + + if ( $customer_id === '' || $ad_group_id <= 0 || $offer_id === '' ) + { + self::set_setting( 'google_ads_last_error', 'Brak danych do przypisania produktu do grupy reklam Standard Shopping.' ); + return [ 'success' => false ]; + } + + if ( $cpc_bid <= 0 ) + { + $cpc_bid = 1.00; + } + + $listing_groups_count = $this -> get_listing_groups_count_for_ad_group( $customer_id, $ad_group_id ); + if ( $listing_groups_count === false ) + { + return [ 'success' => false, 'error' => (string) self::get_setting( 'google_ads_last_error' ) ]; + } + + if ( $listing_groups_count > 0 ) + { + $clear_result = $this -> clear_listing_groups_in_ad_group( $customer_id, $ad_group_id ); + + if ( empty( $clear_result['success'] ) ) + { + return [ + 'success' => false, + 'error' => 'Nie udało się usunąć istniejącej struktury listing groups: ' . (string) ( $clear_result['error'] ?? '' ), + 'listing_groups_count' => $listing_groups_count, + 'clear_result' => $clear_result + ]; + } + + $listing_groups_count_after_clear = $this -> get_listing_groups_count_for_ad_group( $customer_id, $ad_group_id ); + if ( $listing_groups_count_after_clear === false ) + { + return [ 'success' => false, 'error' => (string) self::get_setting( 'google_ads_last_error' ) ]; + } + + if ( $listing_groups_count_after_clear > 0 ) + { + return [ + 'success' => false, + 'error' => 'Po czyszczeniu nadal istnieją listing groups w grupie reklam.', + 'listing_groups_count' => $listing_groups_count_after_clear, + 'clear_result' => $clear_result + ]; + } + } + + $ad_group_resource = 'customers/' . $customer_id . '/adGroups/' . $ad_group_id; + $root_temp_resource = 'customers/' . $customer_id . '/adGroupCriteria/' . $ad_group_id . '~-1'; + $cpc_bid_micros = max( 10000, (int) round( $cpc_bid * 1000000 ) ); + + $operations = [ + [ + 'adGroupCriterionOperation' => [ + 'create' => [ + 'resourceName' => $root_temp_resource, + 'adGroup' => $ad_group_resource, + 'status' => 'ENABLED', + 'listingGroup' => [ + 'type' => 'SUBDIVISION' + ] + ] + ] + ], + [ + 'adGroupCriterionOperation' => [ + 'create' => [ + 'adGroup' => $ad_group_resource, + 'status' => 'ENABLED', + 'cpcBidMicros' => $cpc_bid_micros, + 'listingGroup' => [ + 'type' => 'UNIT', + 'parentAdGroupCriterion' => $root_temp_resource, + 'caseValue' => [ + 'productItemId' => [ + 'value' => $offer_id + ] + ] + ] + ] + ] + ], + [ + 'adGroupCriterionOperation' => [ + 'create' => [ + 'adGroup' => $ad_group_resource, + 'status' => 'ENABLED', + 'negative' => true, + 'listingGroup' => [ + 'type' => 'UNIT', + 'parentAdGroupCriterion' => $root_temp_resource, + 'caseValue' => [ + 'productItemId' => (object) [] + ] + ] + ] + ] + ] + ]; + + $result = $this -> mutate( $customer_id, $operations ); + + if ( $result === false ) + { + return [ + 'success' => false, + 'error' => (string) self::get_setting( 'google_ads_last_error' ), + 'sent_operations' => $operations, + 'root_temp_resource' => $root_temp_resource + ]; + } + + return [ + 'success' => true, + 'response' => $result, + 'root_temp_resource' => $root_temp_resource + ]; + } + + public function create_standard_shopping_ad_group_with_offer( $customer_id, $campaign_id, $ad_group_name, $offer_id, $cpc_bid = 1.00 ) + { + $customer_id = $this -> normalize_ads_customer_id( $customer_id ); + $campaign_id = (int) $campaign_id; + $ad_group_name = trim( (string) $ad_group_name ); + $offer_id = trim( (string) $offer_id ); + $cpc_bid = (float) $cpc_bid; + + if ( $customer_id === '' || $campaign_id <= 0 || $ad_group_name === '' || $offer_id === '' ) + { + self::set_setting( 'google_ads_last_error', 'Brak danych do utworzenia grupy reklam Standard Shopping.' ); + return [ 'success' => false ]; + } + + if ( $cpc_bid <= 0 ) + { + $cpc_bid = 1.00; + } + + $ad_group_tmp_resource = 'customers/' . $customer_id . '/adGroups/-1'; + $cpc_bid_micros = max( 10000, (int) round( $cpc_bid * 1000000 ) ); + + $operations = [ + [ + 'adGroupOperation' => [ + 'create' => [ + 'resourceName' => $ad_group_tmp_resource, + 'campaign' => 'customers/' . $customer_id . '/campaigns/' . $campaign_id, + 'name' => $ad_group_name, + 'status' => 'PAUSED', + 'type' => 'SHOPPING_PRODUCT_ADS', + 'cpcBidMicros' => $cpc_bid_micros + ] + ] + ], + [ + 'adGroupAdOperation' => [ + 'create' => [ + 'adGroup' => $ad_group_tmp_resource, + 'status' => 'PAUSED', + 'ad' => [ + 'shoppingProductAd' => (object) [] + ] + ] + ] + ] + ]; + + $result = $this -> mutate( $customer_id, $operations ); + + if ( $result === false ) + { + $last_error = (string) self::get_setting( 'google_ads_last_error' ); + $is_duplicate_name = stripos( $last_error, 'DUPLICATE_ADGROUP_NAME' ) !== false + || stripos( $last_error, 'AdGroup with the same name already exists' ) !== false; + + if ( $is_duplicate_name ) + { + $existing_ad_group_id = $this -> find_ad_group_id_by_campaign_and_name( $customer_id, $campaign_id, $ad_group_name ); + + if ( $existing_ad_group_id > 0 ) + { + $existing_status = $this -> get_ad_group_status( $customer_id, $existing_ad_group_id ); + + if ( $existing_status === 'REMOVED' ) + { + return [ + 'success' => false, + 'duplicate_name' => true, + 'ad_group_id' => $existing_ad_group_id, + 'ad_group_name' => $ad_group_name, + 'error' => 'Istniejąca grupa reklam o tej nazwie jest usunięta (REMOVED). Wybierz inną nazwę grupy.' + ]; + } + + if ( $existing_status === 'PAUSED' ) + { + if ( !$this -> enable_ad_group( $customer_id, $existing_ad_group_id ) ) + { + return [ + 'success' => false, + 'duplicate_name' => true, + 'ad_group_id' => $existing_ad_group_id, + 'ad_group_name' => $ad_group_name, + 'error' => 'Nie udało się włączyć istniejącej grupy reklam (PAUSED -> ENABLED).' + ]; + } + } + + $offer_result = $this -> ensure_standard_shopping_offer_in_ad_group( $customer_id, $existing_ad_group_id, $offer_id, $cpc_bid ); + + if ( !empty( $offer_result['success'] ) ) + { + return [ + 'success' => true, + 'duplicate_name' => true, + 'ad_group_id' => $existing_ad_group_id, + 'ad_group_name' => $ad_group_name, + 'offer_result' => $offer_result, + 'response' => null + ]; + } + + return [ + 'success' => false, + 'duplicate_name' => true, + 'ad_group_id' => $existing_ad_group_id, + 'ad_group_name' => $ad_group_name, + 'error' => (string) ( $offer_result['error'] ?? 'Nie udało się przypisać produktu do istniejącej grupy reklam.' ), + 'offer_result' => $offer_result, + 'response' => null + ]; + } + } + + return [ + 'success' => false, + 'error' => $last_error, + 'sent_operations' => $operations + ]; + } + + $ad_group_resource_name = ''; + foreach ( (array) ( $result['mutateOperationResponses'] ?? [] ) as $row ) + { + $ad_group_resource_name = (string) ( $row['adGroupResult']['resourceName'] ?? '' ); + if ( $ad_group_resource_name === '' ) + { + $ad_group_resource_name = (string) ( $row['ad_group_result']['resource_name'] ?? '' ); + } + + if ( $ad_group_resource_name !== '' ) + { + break; + } + } + + $ad_group_id = $this -> parse_resource_id( $ad_group_resource_name ); + if ( $ad_group_id <= 0 ) + { + self::set_setting( 'google_ads_last_error', 'Nie udało się odczytać ID grupy reklam z odpowiedzi mutate.' ); + return [ 'success' => false, 'response' => $result ]; + } + + $offer_result = $this -> ensure_standard_shopping_offer_in_ad_group( $customer_id, $ad_group_id, $offer_id, $cpc_bid ); + if ( empty( $offer_result['success'] ) ) + { + return [ + 'success' => false, + 'ad_group_id' => $ad_group_id, + 'error' => (string) ( $offer_result['error'] ?? 'Nie udało się ustawić filtra produktu w grupie reklam.' ), + 'offer_result' => $offer_result, + 'response' => $result + ]; + } + + return [ + 'success' => true, + 'ad_group_id' => $ad_group_id, + 'ad_group_name' => $ad_group_name, + 'response' => $result, + 'offer_result' => $offer_result + ]; + } + + public function generate_keyword_ideas_from_url( $customer_id, $url, $limit = 40 ) + { + $access_token = $this -> get_access_token(); + if ( !$access_token ) + { + return false; + } + + $customer_id = preg_replace( '/\D+/', '', (string) $customer_id ); + $url = trim( (string) $url ); + $limit = max( 1, (int) $limit ); + + if ( $customer_id === '' || $url === '' ) + { + self::set_setting( 'google_ads_last_error', 'Brak customer_id lub URL dla generate_keyword_ideas_from_url.' ); + return false; + } + + if ( !filter_var( $url, FILTER_VALIDATE_URL ) ) + { + self::set_setting( 'google_ads_last_error', 'Nieprawidlowy URL dla Keyword Planner.' ); + return false; + } + + $endpoint = self::$ADS_BASE_URL . '/' . self::$API_VERSION . '/customers/' . $customer_id . ':generateKeywordIdeas'; + + $headers = [ + 'Authorization: Bearer ' . $access_token, + 'developer-token: ' . $this -> developer_token, + 'Content-Type: application/json', + ]; + + if ( !empty( $this -> manager_account_id ) ) + { + $headers[] = 'login-customer-id: ' . str_replace( '-', '', $this -> manager_account_id ); + } + + $payload = [ + 'urlSeed' => [ + 'url' => $url + ], + 'keywordPlanNetwork' => 'GOOGLE_SEARCH_AND_PARTNERS', + 'includeAdultKeywords' => false + ]; + + $ch = curl_init( $endpoint ); + curl_setopt_array( $ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_POSTFIELDS => json_encode( $payload ), + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_TIMEOUT => 120, + ] ); + + $response = curl_exec( $ch ); + $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); + $error = curl_error( $ch ); + curl_close( $ch ); + + if ( $http_code !== 200 || !$response ) + { + self::set_setting( 'google_ads_last_error', 'generateKeywordIdeas failed: HTTP ' . $http_code . ' | ' . $error . ' | ' . substr( (string) $response, 0, 1000 ) ); + return false; + } + + $data = json_decode( (string) $response, true ); + if ( !is_array( $data ) ) + { + self::set_setting( 'google_ads_last_error', 'generateKeywordIdeas failed: niepoprawna odpowiedz JSON' ); + return false; + } + + $results = isset( $data['results'] ) && is_array( $data['results'] ) ? $data['results'] : []; + $parsed = []; + + foreach ( $results as $row ) + { + $keyword_text = trim( (string) ( $row['text'] ?? '' ) ); + if ( $keyword_text === '' ) + { + continue; + } + + $metrics = isset( $row['keywordIdeaMetrics'] ) && is_array( $row['keywordIdeaMetrics'] ) + ? $row['keywordIdeaMetrics'] + : []; + + $parsed[] = [ + 'keyword' => $keyword_text, + 'avg_monthly_searches' => (int) ( $metrics['avgMonthlySearches'] ?? 0 ), + 'competition' => isset( $metrics['competition'] ) ? (string) $metrics['competition'] : null, + 'competition_index' => isset( $metrics['competitionIndex'] ) ? (int) $metrics['competitionIndex'] : null, + ]; + } + + usort( $parsed, static function( $left, $right ) + { + $left_volume = (int) ( $left['avg_monthly_searches'] ?? 0 ); + $right_volume = (int) ( $right['avg_monthly_searches'] ?? 0 ); + + if ( $left_volume === $right_volume ) + { + return strcasecmp( (string) ( $left['keyword'] ?? '' ), (string) ( $right['keyword'] ?? '' ) ); + } + + return $right_volume <=> $left_volume; + } ); + + $unique = []; + foreach ( $parsed as $row ) + { + $key = mb_strtolower( (string) $row['keyword'], 'UTF-8' ); + if ( isset( $unique[ $key ] ) ) + { + continue; + } + + $unique[ $key ] = $row; + if ( count( $unique ) >= $limit ) + { + break; + } + } + + self::set_setting( 'google_ads_last_error', null ); + return array_values( $unique ); + } + public function add_negative_keyword_to_ad_group( $customer_id, $ad_group_id, $keyword_text, $match_type = 'PHRASE' ) { $customer_id = trim( str_replace( '-', '', (string) $customer_id ) ); @@ -358,6 +1633,164 @@ class GoogleAdsApi ]; } + public function remove_negative_keyword_from_campaign( $customer_id, $campaign_id, $keyword_text, $match_type = 'PHRASE' ) + { + $customer_id = trim( str_replace( '-', '', (string) $customer_id ) ); + $campaign_id = trim( (string) $campaign_id ); + $keyword_text = trim( (string) $keyword_text ); + $match_type = strtoupper( trim( (string) $match_type ) ); + + if ( $customer_id === '' || $campaign_id === '' || $keyword_text === '' ) + { + self::set_setting( 'google_ads_last_error', 'Brak wymaganych danych do usuniecia frazy wykluczajacej.' ); + return [ 'success' => false, 'removed' => 0 ]; + } + + if ( !in_array( $match_type, [ 'PHRASE', 'EXACT', 'BROAD' ], true ) ) + { + $match_type = 'PHRASE'; + } + + $keyword_text_escaped = $this -> gaql_escape( $keyword_text ); + $gaql = "SELECT " + . "campaign_criterion.resource_name " + . "FROM campaign_criterion " + . "WHERE campaign.id = " . $campaign_id . " " + . "AND campaign_criterion.type = 'KEYWORD' " + . "AND campaign_criterion.negative = TRUE " + . "AND campaign_criterion.keyword.text = '" . $keyword_text_escaped . "' " + . "AND campaign_criterion.keyword.match_type = " . $match_type . " " + . "LIMIT 50"; + + $results = $this -> search_stream( $customer_id, $gaql ); + if ( $results === false ) + { + return [ 'success' => false, 'removed' => 0 ]; + } + + $resource_names = []; + foreach ( (array) $results as $row ) + { + $resource_name = (string) ( $row['campaignCriterion']['resourceName'] ?? '' ); + if ( $resource_name === '' ) + { + $resource_name = (string) ( $row['campaign_criterion']['resource_name'] ?? '' ); + } + if ( $resource_name !== '' ) + { + $resource_names[] = $resource_name; + } + } + $resource_names = array_values( array_unique( $resource_names ) ); + + if ( empty( $resource_names ) ) + { + return [ 'success' => true, 'removed' => 0, 'not_found' => true ]; + } + + $operations = []; + foreach ( $resource_names as $resource_name ) + { + $operations[] = [ + 'campaignCriterionOperation' => [ + 'remove' => $resource_name + ] + ]; + } + + $mutate_result = $this -> mutate( $customer_id, $operations ); + if ( $mutate_result === false ) + { + return [ 'success' => false, 'removed' => 0, 'sent_operations' => $operations ]; + } + + return [ + 'success' => true, + 'removed' => count( $resource_names ), + 'response' => $mutate_result, + 'sent_operations' => $operations + ]; + } + + public function remove_negative_keyword_from_ad_group( $customer_id, $ad_group_id, $keyword_text, $match_type = 'PHRASE' ) + { + $customer_id = trim( str_replace( '-', '', (string) $customer_id ) ); + $ad_group_id = trim( (string) $ad_group_id ); + $keyword_text = trim( (string) $keyword_text ); + $match_type = strtoupper( trim( (string) $match_type ) ); + + if ( $customer_id === '' || $ad_group_id === '' || $keyword_text === '' ) + { + self::set_setting( 'google_ads_last_error', 'Brak wymaganych danych do usuniecia frazy wykluczajacej.' ); + return [ 'success' => false, 'removed' => 0 ]; + } + + if ( !in_array( $match_type, [ 'PHRASE', 'EXACT', 'BROAD' ], true ) ) + { + $match_type = 'PHRASE'; + } + + $keyword_text_escaped = $this -> gaql_escape( $keyword_text ); + $gaql = "SELECT " + . "ad_group_criterion.resource_name " + . "FROM ad_group_criterion " + . "WHERE ad_group.id = " . $ad_group_id . " " + . "AND ad_group_criterion.type = 'KEYWORD' " + . "AND ad_group_criterion.negative = TRUE " + . "AND ad_group_criterion.keyword.text = '" . $keyword_text_escaped . "' " + . "AND ad_group_criterion.keyword.match_type = " . $match_type . " " + . "LIMIT 50"; + + $results = $this -> search_stream( $customer_id, $gaql ); + if ( $results === false ) + { + return [ 'success' => false, 'removed' => 0 ]; + } + + $resource_names = []; + foreach ( (array) $results as $row ) + { + $resource_name = (string) ( $row['adGroupCriterion']['resourceName'] ?? '' ); + if ( $resource_name === '' ) + { + $resource_name = (string) ( $row['ad_group_criterion']['resource_name'] ?? '' ); + } + if ( $resource_name !== '' ) + { + $resource_names[] = $resource_name; + } + } + $resource_names = array_values( array_unique( $resource_names ) ); + + if ( empty( $resource_names ) ) + { + return [ 'success' => true, 'removed' => 0, 'not_found' => true ]; + } + + $operations = []; + foreach ( $resource_names as $resource_name ) + { + $operations[] = [ + 'adGroupCriterionOperation' => [ + 'remove' => $resource_name + ] + ]; + } + + $mutate_result = $this -> mutate( $customer_id, $operations ); + if ( $mutate_result === false ) + { + return [ 'success' => false, 'removed' => 0, 'sent_operations' => $operations ]; + } + + return [ + 'success' => true, + 'removed' => count( $resource_names ), + 'response' => $mutate_result, + 'sent_operations' => $operations + ]; + } + private function gaql_escape( $value ) { return str_replace( [ '\\', '\'' ], [ '\\\\', '\\\'' ], (string) $value ); @@ -449,12 +1882,32 @@ class GoogleAdsApi { $date = date( 'Y-m-d', strtotime( $date ) ); + $gaql_with_ad_group_with_url = "SELECT " + . "segments.date, " + . "segments.product_item_id, " + . "segments.product_title, " + . "segments.product_link, " + . "campaign.id, " + . "campaign.name, " + . "campaign.advertising_channel_type, " + . "ad_group.id, " + . "ad_group.name, " + . "metrics.impressions, " + . "metrics.clicks, " + . "metrics.cost_micros, " + . "metrics.conversions, " + . "metrics.conversions_value " + . "FROM shopping_performance_view " + . "WHERE segments.date = '" . $date . "' " + . "AND campaign.advertising_channel_type = 'SHOPPING'"; + $gaql_with_ad_group = "SELECT " . "segments.date, " . "segments.product_item_id, " . "segments.product_title, " . "campaign.id, " . "campaign.name, " + . "campaign.advertising_channel_type, " . "ad_group.id, " . "ad_group.name, " . "metrics.impressions, " @@ -463,7 +1916,27 @@ class GoogleAdsApi . "metrics.conversions, " . "metrics.conversions_value " . "FROM shopping_performance_view " - . "WHERE segments.date = '" . $date . "'"; + . "WHERE segments.date = '" . $date . "' " + . "AND campaign.advertising_channel_type = 'SHOPPING'"; + + $gaql_pmax_asset_group_with_url = "SELECT " + . "segments.date, " + . "segments.product_item_id, " + . "segments.product_title, " + . "segments.product_link, " + . "campaign.id, " + . "campaign.name, " + . "campaign.advertising_channel_type, " + . "asset_group.id, " + . "asset_group.name, " + . "metrics.impressions, " + . "metrics.clicks, " + . "metrics.cost_micros, " + . "metrics.conversions, " + . "metrics.conversions_value " + . "FROM asset_group_product_group_view " + . "WHERE segments.date = '" . $date . "' " + . "AND campaign.advertising_channel_type = 'PERFORMANCE_MAX'"; $gaql_pmax_asset_group = "SELECT " . "segments.date, " @@ -471,6 +1944,7 @@ class GoogleAdsApi . "segments.product_title, " . "campaign.id, " . "campaign.name, " + . "campaign.advertising_channel_type, " . "asset_group.id, " . "asset_group.name, " . "metrics.impressions, " @@ -482,6 +1956,23 @@ class GoogleAdsApi . "WHERE segments.date = '" . $date . "' " . "AND campaign.advertising_channel_type = 'PERFORMANCE_MAX'"; + $gaql_pmax_campaign_level_fallback_with_url = "SELECT " + . "segments.date, " + . "segments.product_item_id, " + . "segments.product_title, " + . "segments.product_link, " + . "campaign.id, " + . "campaign.name, " + . "campaign.advertising_channel_type, " + . "metrics.impressions, " + . "metrics.clicks, " + . "metrics.cost_micros, " + . "metrics.conversions, " + . "metrics.conversions_value " + . "FROM shopping_performance_view " + . "WHERE segments.date = '" . $date . "' " + . "AND campaign.advertising_channel_type = 'PERFORMANCE_MAX'"; + $gaql_pmax_campaign_level_fallback = "SELECT " . "segments.date, " . "segments.product_item_id, " @@ -495,10 +1986,22 @@ class GoogleAdsApi . "metrics.conversions, " . "metrics.conversions_value " . "FROM shopping_performance_view " - . "WHERE segments.date = '" . $date . "'"; + . "WHERE segments.date = '" . $date . "' " + . "AND campaign.advertising_channel_type = 'PERFORMANCE_MAX'"; - $results_with_ad_group = $this -> search_stream( $customer_id, $gaql_with_ad_group ); - $results_pmax_asset_group = $this -> search_stream( $customer_id, $gaql_pmax_asset_group ); + $search_with_optional_url = function( $query_with_url, $query_without_url ) use ( $customer_id ) + { + $result = $this -> search_stream( $customer_id, $query_with_url ); + if ( is_array( $result ) ) + { + return $result; + } + + return $this -> search_stream( $customer_id, $query_without_url ); + }; + + $results_with_ad_group = $search_with_optional_url( $gaql_with_ad_group_with_url, $gaql_with_ad_group ); + $results_pmax_asset_group = $search_with_optional_url( $gaql_pmax_asset_group_with_url, $gaql_pmax_asset_group ); $results_pmax_campaign_fallback = []; $had_success = false; @@ -521,7 +2024,7 @@ class GoogleAdsApi $results_pmax_asset_group = []; // Fallback dla kont/API, gdzie asset_group_product_group_view moze nie byc dostepny. - $tmp = $this -> search_stream( $customer_id, $gaql_pmax_campaign_level_fallback ); + $tmp = $search_with_optional_url( $gaql_pmax_campaign_level_fallback_with_url, $gaql_pmax_campaign_level_fallback ); if ( is_array( $tmp ) ) { $had_success = true; @@ -552,6 +2055,12 @@ class GoogleAdsApi foreach ( $rows as $row ) { + $channel_type = strtoupper( trim( (string) ( $row['campaign']['advertisingChannelType'] ?? '' ) ) ); + if ( $channel_type === 'SEARCH' ) + { + continue; + } + $offer_id = trim( (string) ( $row['segments']['productItemId'] ?? '' ) ); if ( $offer_id === '' ) { @@ -601,9 +2110,16 @@ class GoogleAdsApi if ( !isset( $products[ $scope_key ] ) ) { + $initial_product_url = trim( (string) ( $row['segments']['productLink'] ?? '' ) ); + if ( $initial_product_url !== '' && $this -> is_likely_image_url( $initial_product_url ) ) + { + $initial_product_url = ''; + } + $products[ $scope_key ] = [ 'OfferId' => $offer_id, 'ProductTitle' => (string) ( $row['segments']['productTitle'] ?? $offer_id ), + 'ProductUrl' => $initial_product_url, 'CampaignId' => $campaign_id, 'CampaignName' => $campaign_name, 'AdGroupId' => $ad_group_id, @@ -616,6 +2132,15 @@ class GoogleAdsApi ]; } + if ( ( $products[ $scope_key ]['ProductUrl'] ?? '' ) === '' ) + { + $candidate_url = trim( (string) ( $row['segments']['productLink'] ?? '' ) ); + if ( $candidate_url !== '' && !$this -> is_likely_image_url( $candidate_url ) ) + { + $products[ $scope_key ]['ProductUrl'] = $candidate_url; + } + } + $products[ $scope_key ]['Impressions'] += (int) ( $row['metrics']['impressions'] ?? 0 ); $products[ $scope_key ]['Clicks'] += (int) ( $row['metrics']['clicks'] ?? 0 ); $products[ $scope_key ]['Cost'] += (float) ( $row['metrics']['costMicros'] ?? 0 ) / 1000000; @@ -628,9 +2153,141 @@ class GoogleAdsApi $collect_rows( $results_pmax_asset_group, 'asset_group' ); $collect_rows( $results_pmax_campaign_fallback, 'campaign' ); + $product_urls_by_offer_id = $this -> get_product_urls_by_offer_id( $customer_id, $date ); + if ( !empty( $product_urls_by_offer_id ) ) + { + foreach ( $products as &$product ) + { + $offer_id = trim( (string) ( $product['OfferId'] ?? '' ) ); + if ( $offer_id === '' ) + { + continue; + } + + if ( trim( (string) ( $product['ProductUrl'] ?? '' ) ) !== '' ) + { + continue; + } + + if ( isset( $product_urls_by_offer_id[ $offer_id ] ) ) + { + $product['ProductUrl'] = $product_urls_by_offer_id[ $offer_id ]; + } + } + unset( $product ); + } + return array_values( $products ); } + private function gaql_field_leaf_to_json_key( $leaf ) + { + $leaf = trim( (string) $leaf ); + if ( $leaf === '' ) + { + return ''; + } + + $parts = explode( '_', strtolower( $leaf ) ); + $key = array_shift( $parts ); + foreach ( $parts as $part ) + { + $key .= ucfirst( $part ); + } + + return $key; + } + + private function is_likely_image_url( $url ) + { + $url = trim( (string) $url ); + if ( $url === '' ) + { + return false; + } + + $path = strtolower( (string) parse_url( $url, PHP_URL_PATH ) ); + return (bool) preg_match( '/\.(jpg|jpeg|png|gif|webp|bmp|svg|avif)$/i', $path ); + } + + private function is_valid_merchant_product_url( $url ) + { + $url = trim( (string) $url ); + if ( $url === '' ) + { + return false; + } + + if ( !filter_var( $url, FILTER_VALIDATE_URL ) ) + { + return false; + } + + return !$this -> is_likely_image_url( $url ); + } + + private function get_product_urls_by_offer_id( $customer_id, $date ) + { + $date = date( 'Y-m-d', strtotime( $date ) ); + $last_error_before = self::get_setting( 'google_ads_last_error' ); + + $url_fields = [ + 'shopping_product.link', + 'shopping_product.product_link', + 'shopping_product.product_url', + 'shopping_product.landing_page' + ]; + + foreach ( $url_fields as $field ) + { + $gaql = "SELECT " + . "shopping_product.item_id, " + . $field . " " + . "FROM shopping_product " + . "WHERE segments.date = '" . $date . "'"; + + $rows = $this -> search_stream( $customer_id, $gaql ); + if ( !is_array( $rows ) ) + { + continue; + } + + $field_parts = explode( '.', $field ); + $leaf = end( $field_parts ); + $json_key = $this -> gaql_field_leaf_to_json_key( $leaf ); + $map = []; + + foreach ( $rows as $row ) + { + $item_id = trim( (string) ( $row['shoppingProduct']['itemId'] ?? '' ) ); + if ( $item_id === '' ) + { + continue; + } + + $url = trim( (string) ( $row['shoppingProduct'][ $json_key ] ?? '' ) ); + if ( $url === '' || !filter_var( $url, FILTER_VALIDATE_URL ) || $this -> is_likely_image_url( $url ) ) + { + continue; + } + + if ( !isset( $map[ $item_id ] ) ) + { + $map[ $item_id ] = $url; + } + } + + if ( !empty( $map ) ) + { + return $map; + } + } + + self::set_setting( 'google_ads_last_error', $last_error_before ); + + return []; + } + public function get_campaigns_30_days( $customer_id, $as_of_date = null ) { $as_of_date = $as_of_date ? date( 'Y-m-d', strtotime( $as_of_date ) ) : date( 'Y-m-d' ); @@ -692,6 +2349,55 @@ class GoogleAdsApi unset( $c['cost_total'] ); } + // Fallback: gdy raport metryk zwraca pusty wynik dla danego dnia/okna, + // pobieramy aktywne kampanie, aby zachowac budzet/strategie zamiast pustych rekordow. + if ( empty( $campaigns ) ) + { + $meta_gaql = "SELECT " + . "campaign.id, " + . "campaign.name, " + . "campaign.advertising_channel_type, " + . "campaign.bidding_strategy_type, " + . "campaign.target_roas.target_roas, " + . "campaign_budget.amount_micros " + . "FROM campaign " + . "WHERE campaign.status = 'ENABLED'"; + + $meta_rows = $this -> search_stream( $customer_id, $meta_gaql ); + if ( is_array( $meta_rows ) ) + { + foreach ( $meta_rows as $row ) + { + $cid = $row['campaign']['id'] ?? null; + if ( !$cid ) + { + continue; + } + + if ( isset( $campaigns[ $cid ] ) ) + { + continue; + } + + $campaigns[ $cid ] = [ + 'campaign_id' => $cid, + 'campaign_name' => $row['campaign']['name'] ?? '', + 'advertising_channel_type' => (string) ( $row['campaign']['advertisingChannelType'] ?? '' ), + 'bidding_strategy' => $row['campaign']['biddingStrategyType'] ?? 'UNKNOWN', + 'target_roas' => isset( $row['campaign']['targetRoas']['targetRoas'] ) + ? (float) $row['campaign']['targetRoas']['targetRoas'] + : 0, + 'budget' => isset( $row['campaignBudget']['amountMicros'] ) + ? (float) $row['campaignBudget']['amountMicros'] / 1000000 + : 0, + 'money_spent' => 0, + 'conversion_value' => 0, + 'roas_30_days' => 0, + ]; + } + } + } + return array_values( $campaigns ); } @@ -868,6 +2574,59 @@ class GoogleAdsApi return $terms; } + public function get_ad_keywords_30_days( $customer_id ) + { + $gaql = "SELECT " + . "campaign.id, " + . "ad_group.id, " + . "ad_group_criterion.keyword.text, " + . "ad_group_criterion.keyword.match_type, " + . "metrics.impressions, " + . "metrics.clicks, " + . "metrics.cost_micros, " + . "metrics.conversions, " + . "metrics.conversions_value " + . "FROM ad_group_criterion " + . "WHERE campaign.status != 'REMOVED' " + . "AND ad_group.status != 'REMOVED' " + . "AND ad_group_criterion.type = 'KEYWORD' " + . "AND ad_group_criterion.negative = FALSE " + . "AND campaign.advertising_channel_type = 'SEARCH' " + . "AND metrics.clicks > 0 " + . "AND segments.date DURING LAST_30_DAYS"; + + $results = $this -> search_stream( $customer_id, $gaql ); + if ( $results === false ) return false; + + return $this -> aggregate_ad_keywords( $results ); + } + + public function get_ad_keywords_all_time( $customer_id ) + { + $gaql = "SELECT " + . "campaign.id, " + . "ad_group.id, " + . "ad_group_criterion.keyword.text, " + . "ad_group_criterion.keyword.match_type, " + . "metrics.impressions, " + . "metrics.clicks, " + . "metrics.cost_micros, " + . "metrics.conversions, " + . "metrics.conversions_value " + . "FROM ad_group_criterion " + . "WHERE campaign.status != 'REMOVED' " + . "AND ad_group.status != 'REMOVED' " + . "AND ad_group_criterion.type = 'KEYWORD' " + . "AND ad_group_criterion.negative = FALSE " + . "AND campaign.advertising_channel_type = 'SEARCH' " + . "AND metrics.clicks > 0"; + + $results = $this -> search_stream( $customer_id, $gaql ); + if ( $results === false ) return false; + + return $this -> aggregate_ad_keywords( $results ); + } + private function get_pmax_search_terms_30_days( $customer_id ) { $gaql = "SELECT " @@ -1158,4 +2917,56 @@ class GoogleAdsApi return array_values( $terms ); } + + private function aggregate_ad_keywords( $results ) + { + $keywords = []; + + foreach ( $results as $row ) + { + $campaign_id = $row['campaign']['id'] ?? null; + $ad_group_id = $row['adGroup']['id'] ?? null; + $keyword_text = trim( (string) ( $row['adGroupCriterion']['keyword']['text'] ?? '' ) ); + $match_type = trim( (string) ( $row['adGroupCriterion']['keyword']['matchType'] ?? '' ) ); + + if ( !$campaign_id || !$ad_group_id || $keyword_text === '' ) + { + continue; + } + + $key = $campaign_id . '|' . $ad_group_id . '|' . strtolower( $keyword_text ) . '|' . strtolower( $match_type ); + + if ( !isset( $keywords[ $key ] ) ) + { + $keywords[ $key ] = [ + 'campaign_id' => (int) $campaign_id, + 'ad_group_id' => (int) $ad_group_id, + 'keyword_text' => $keyword_text, + 'match_type' => $match_type, + 'impressions' => 0, + 'clicks' => 0, + 'cost' => 0.0, + 'conversions' => 0.0, + 'conversion_value' => 0.0, + 'roas' => 0.0, + ]; + } + + $keywords[ $key ]['impressions'] += (int) ( $row['metrics']['impressions'] ?? 0 ); + $keywords[ $key ]['clicks'] += (int) ( $row['metrics']['clicks'] ?? 0 ); + $keywords[ $key ]['cost'] += (float) ( ( $row['metrics']['costMicros'] ?? 0 ) / 1000000 ); + $keywords[ $key ]['conversions'] += (float) ( $row['metrics']['conversions'] ?? 0 ); + $keywords[ $key ]['conversion_value'] += (float) ( $row['metrics']['conversionsValue'] ?? 0 ); + } + + foreach ( $keywords as &$item ) + { + $item['roas'] = $item['cost'] > 0 + ? round( ( $item['conversion_value'] / $item['cost'] ) * 100, 2 ) + : 0; + } + unset( $item ); + + return array_values( $keywords ); + } } diff --git a/autoload/services/class.OpenAiApi.php b/autoload/services/class.OpenAiApi.php index 636becd..378e00e 100644 --- a/autoload/services/class.OpenAiApi.php +++ b/autoload/services/class.OpenAiApi.php @@ -98,13 +98,14 @@ Twoje odpowiedzi muszą być: return $text; } - static private function call_api( $system_prompt, $user_prompt, $max_tokens = 500 ) + static private function call_api( $system_prompt, $user_prompt, $max_tokens = 500, $temperature = 0.7, $extra_payload = [] ) { $api_key = GoogleAdsApi::get_setting( 'openai_api_key' ); $model = GoogleAdsApi::get_setting( 'openai_model' ) ?: 'gpt-5-mini'; + $is_gpt5_model = ( strpos( $model, 'gpt-5' ) === 0 ); // GPT-5.x wymaga max_completion_tokens, starsze modele używają max_tokens - $tokens_key = ( strpos( $model, 'gpt-5' ) === 0 ) ? 'max_completion_tokens' : 'max_tokens'; + $tokens_key = $is_gpt5_model ? 'max_completion_tokens' : 'max_tokens'; $payload = [ 'model' => $model, @@ -112,10 +113,20 @@ Twoje odpowiedzi muszą być: [ 'role' => 'system', 'content' => $system_prompt ], [ 'role' => 'user', 'content' => $user_prompt ] ], - 'temperature' => 0.7, $tokens_key => $max_tokens ]; + // Modele GPT-5 (w tym gpt-5-mini) nie wspierają niestandardowej temperatury. + if ( !$is_gpt5_model ) + { + $payload['temperature'] = $temperature; + } + + if ( is_array( $extra_payload ) && !empty( $extra_payload ) ) + { + $payload = array_merge( $payload, $extra_payload ); + } + $ch = curl_init( self::$api_url ); curl_setopt_array( $ch, [ CURLOPT_RETURNTRANSFER => true, @@ -187,6 +198,31 @@ Twoje odpowiedzi muszą być: static public function suggest_title( $context ) { $context_text = self::build_context_text( $context ); + $keyword_planner_text = ''; + + if ( !empty( $context['keyword_planner_terms'] ) && is_array( $context['keyword_planner_terms'] ) ) + { + $keyword_lines = []; + $keyword_lines[] = 'Najpopularniejsze frazy z Google Ads Keyword Planner (na bazie URL produktu, posortowane malejąco po średniej liczbie wyszukiwań):'; + + foreach ( array_slice( $context['keyword_planner_terms'], 0, 15 ) as $term ) + { + $text = trim( (string) ( $term['keyword_text'] ?? '' ) ); + if ( $text === '' ) + { + continue; + } + + $avg_monthly = (int) ( $term['avg_monthly_searches'] ?? 0 ); + $keyword_lines[] = '- ' . $text . ' (avg miesięcznie: ' . $avg_monthly . ')'; + } + + if ( count( $keyword_lines ) > 1 ) + { + $keyword_lines[] = 'Użyj tych fraz WYBIÓRCZO i naturalnie (bez upychania słów kluczowych), tylko jeśli pasują do produktu.'; + $keyword_planner_text = "\n\n" . implode( "\n", $keyword_lines ); + } + } $prompt = 'Zaproponuj zoptymalizowany tytuł produktu dla Google Merchant Center. @@ -204,7 +240,7 @@ BEZWZGLĘDNY ZAKAZ (odrzucenie przez Google): - Cechy wymyślone — opisuj TYLKO to co wynika z oryginalnej nazwy lub treści strony produktu - Jeśli podano treść ze strony produktu, wykorzystaj ją do wzbogacenia tytułu o rzeczywiste cechy (marka, materiał, kolor, rozmiar itp.) -' . $context_text . ' +' . $context_text . $keyword_planner_text . ' Zwróć TYLKO tytuł, bez cudzysłowów, bez wyjaśnień.'; @@ -215,6 +251,31 @@ Zwróć TYLKO tytuł, bez cudzysłowów, bez wyjaśnień.'; { $context_text = self::build_context_text( $context ); $has_page = !empty( $context['page_content'] ); + $keyword_planner_text = ''; + + if ( !empty( $context['keyword_planner_terms'] ) && is_array( $context['keyword_planner_terms'] ) ) + { + $keyword_lines = []; + $keyword_lines[] = 'Najpopularniejsze frazy z Google Ads Keyword Planner (na bazie URL produktu, posortowane malejąco po średniej liczbie wyszukiwań):'; + + foreach ( array_slice( $context['keyword_planner_terms'], 0, 15 ) as $term ) + { + $text = trim( (string) ( $term['keyword_text'] ?? '' ) ); + if ( $text === '' ) + { + continue; + } + + $avg_monthly = (int) ( $term['avg_monthly_searches'] ?? 0 ); + $keyword_lines[] = '- ' . $text . ' (avg miesięcznie: ' . $avg_monthly . ')'; + } + + if ( count( $keyword_lines ) > 1 ) + { + $keyword_lines[] = 'W opisie wykorzystuj te frazy naturalnie i wyłącznie gdy realnie pasują do produktu (bez keyword stuffing).'; + $keyword_planner_text = "\n\n" . implode( "\n", $keyword_lines ); + } + } $length_guide = $has_page ? '- Napisz rozbudowany, szczegółowy opis: ok. 1000 znaków (800-1200) @@ -247,7 +308,7 @@ BEZWZGLĘDNY ZAKAZ (odrzucenie przez Google): - Opisy akcesoriów/produktów nie wchodzących w skład oferty - Cechy wymyślone — opisuj TYLKO to co wynika z nazwy lub treści strony produktu -' . $context_text . ' +' . $context_text . $keyword_planner_text . ' Zwróć TYLKO opis w formacie HTML (używając dozwolonych tagów), bez cudzysłowów, bez wyjaśnień.'; @@ -279,4 +340,118 @@ Zwróć TYLKO ID kategorii (liczbę), bez wyjaśnień.'; return self::call_api( self::$system_prompt, $prompt ); } + + static public function suggest_negative_keywords_to_exclude( $search_terms_rows, $analysis_context = [] ) + { + $rows = is_array( $search_terms_rows ) ? $search_terms_rows : []; + $rows = array_slice( $rows, 0, 150 ); + + $campaign_name = trim( (string) ( $analysis_context['campaign_name'] ?? '' ) ); + $campaign_type = trim( (string) ( $analysis_context['campaign_type'] ?? '' ) ); + $ad_group_name = trim( (string) ( $analysis_context['ad_group_name'] ?? '' ) ); + $ad_group_id = (int) ( $analysis_context['ad_group_id'] ?? 0 ); + + $context_lines = []; + $context_lines[] = 'KONTEKST KAMPANII:'; + $context_lines[] = '- Nazwa kampanii: ' . ( $campaign_name !== '' ? $campaign_name : '-' ); + $context_lines[] = '- Rodzaj kampanii: ' . ( $campaign_type !== '' ? $campaign_type : '-' ); + if ( $ad_group_id > 0 || $ad_group_name !== '' ) + { + $context_lines[] = '- Wybrana grupa reklam: ' . ( $ad_group_name !== '' ? $ad_group_name : ( '#' . $ad_group_id ) ); + } + else + { + $context_lines[] = '- Wybrana grupa reklam: wszystkie'; + } + + $lines = []; + foreach ( $rows as $row ) + { + $id = (int) ( $row['id'] ?? 0 ); + if ( $id <= 0 ) + { + continue; + } + + $lines[] = json_encode( [ + 'id' => $id, + 'phrase' => trim( (string) ( $row['search_term'] ?? '' ) ), + 'ad_group' => trim( (string) ( $row['ad_group_name'] ?? '' ) ), + 'clicks_all' => (float) ( $row['clicks_all_time'] ?? 0 ), + 'cost_all' => (float) ( $row['cost_all_time'] ?? 0 ), + 'value_all' => (float) ( $row['conversion_value_all_time'] ?? 0 ), + 'roas_all' => (float) ( $row['roas_all_time'] ?? 0 ), + 'clicks_30' => (float) ( $row['clicks_30'] ?? 0 ), + 'cost_30' => (float) ( $row['cost_30'] ?? 0 ), + 'value_30' => (float) ( $row['conversion_value_30'] ?? 0 ), + 'roas_30' => (float) ( $row['roas_30'] ?? 0 ) + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); + } + + $prompt = 'Przeanalizuj frazy wyszukiwane Google Ads i wskaż, które warto wykluczyć jako negatywne słowa kluczowe. + + ' . implode( "\n", $context_lines ) . ' + +KRYTERIA OCENY: +- Priorytet: frazy z kosztami i kliknięciami bez wartości konwersji, niski/zerowy ROAS, nietrafna intencja. +- Nie zaznaczaj wszystkich na siłę. Jeśli fraza ma potencjał, ustaw akcję "keep". +- Oceniaj zarówno dane all-time, jak i 30d. +- Powód musi być krótki, konkretny i oparty na danych. + +FORMAT ODPOWIEDZI (BEZWZGLĘDNIE): +Zwróć WYŁĄCZNIE poprawny JSON (bez markdown i bez komentarzy), zgodny ze schematem: +{ + "items": [ + { + "id": 123, + "phrase": "fraza", + "action": "exclude" lub "keep", + "reason": "krótki powód" + } + ] +} + +Zasady formatu: +- Pole id musi być identyczne z wejściowym id. +- action może mieć tylko wartości: "exclude" albo "keep". +- reason max 120 znaków. +- Nie dodawaj żadnych dodatkowych pól. + +DANE WEJŚCIOWE (JSONL, 1 rekord na linię): +' . implode( "\n", $lines ); + + $schema = [ + 'type' => 'json_schema', + 'json_schema' => [ + 'name' => 'negative_keyword_recommendations', + 'schema' => [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'items' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'id' => [ 'type' => 'integer' ], + 'phrase' => [ 'type' => 'string' ], + 'action' => [ 'type' => 'string', 'enum' => [ 'exclude', 'keep' ] ], + 'reason' => [ 'type' => 'string' ] + ], + 'required' => [ 'id', 'phrase', 'action', 'reason' ] + ] + ] + ], + 'required' => [ 'items' ] + ], + 'strict' => true + ] + ]; + + $rows_count = count( $rows ); + $max_tokens = min( 6000, max( 2200, $rows_count * 30 ) ); + + return self::call_api( self::$system_prompt, $prompt, $max_tokens, 0.2, [ 'response_format' => $schema ] ); + } } diff --git a/autoload/view/class.Users.php b/autoload/view/class.Users.php index 14c42d8..240b4e4 100644 --- a/autoload/view/class.Users.php +++ b/autoload/view/class.Users.php @@ -9,10 +9,11 @@ class Users return $tpl -> render( 'users/points-history' ); } - public static function settings( $user ) + public static function settings( $user, $cron_data = [] ) { $tpl = new \Tpl; $tpl -> user = $user; + $tpl -> cron_data = $cron_data; return $tpl -> render( 'users/settings' ); } } diff --git a/config.php b/config.php index 47370b3..444bb8a 100644 --- a/config.php +++ b/config.php @@ -3,8 +3,12 @@ $database['name'] = 'host700513_adspro'; $database['host'] = 'localhost'; $database['user'] = 'host700513_adspro'; $database['password'] = '2Ug7DvBy5MCAJtKmkCRs'; +$database['remote_host'] = 'host700513.hostido.net.pl'; $settings['email_host'] = 'mail.project-pro.pl'; $settings['email_port'] = 25; $settings['email_login'] = 'www@project-pro.pl'; $settings['email_password'] = 'ProjectPro2025!'; + +$settings['cron_products_clients_per_run'] = 1; +$settings['cron_campaigns_clients_per_run'] = 1; diff --git a/cron.php b/cron.php index f3b0013..73d9e68 100644 --- a/cron.php +++ b/cron.php @@ -43,6 +43,10 @@ $mdb = new medoo( [ 'charset' => 'utf8' ] ); +$cron_now = date( 'Y-m-d H:i:s' ); +\services\GoogleAdsApi::set_setting( 'cron_last_invoked_at', $cron_now ); +\services\GoogleAdsApi::set_setting( 'cron_last_invoked_cron_legacy_at', $cron_now ); + \R::setup( 'mysql:host=' . $database['host'] . ';dbname=' . $database['name'], $database['user'], $database['password'] ); \R::ext( 'xdispense', function( $type ) { diff --git a/docs/database.sql b/docs/database.sql index ff893eb..83931ff 100644 --- a/docs/database.sql +++ b/docs/database.sql @@ -63,6 +63,7 @@ CREATE TABLE IF NOT EXISTS `clients` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(255) NOT NULL DEFAULT '0', `google_ads_customer_id` varchar(20) DEFAULT NULL, + `google_merchant_account_id` varchar(32) DEFAULT NULL, `google_ads_start_date` date DEFAULT NULL, `deleted` int(11) DEFAULT 0, `bestseller_min_roas` int(11) DEFAULT NULL, @@ -330,6 +331,31 @@ CREATE TABLE IF NOT EXISTS `campaign_search_terms` ( KEY `idx_campaign_search_terms_ad_group_id` (`ad_group_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `campaign_keywords` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `campaign_id` int(11) NOT NULL, + `ad_group_id` int(11) NOT NULL, + `keyword_text` varchar(255) NOT NULL, + `match_type` varchar(40) DEFAULT NULL, + `impressions_30` int(11) NOT NULL DEFAULT 0, + `clicks_30` int(11) NOT NULL DEFAULT 0, + `cost_30` decimal(20,6) NOT NULL DEFAULT 0.000000, + `conversions_30` decimal(20,6) NOT NULL DEFAULT 0.000000, + `conversion_value_30` decimal(20,6) NOT NULL DEFAULT 0.000000, + `roas_30` decimal(20,6) NOT NULL DEFAULT 0.000000, + `impressions_all_time` int(11) NOT NULL DEFAULT 0, + `clicks_all_time` int(11) NOT NULL DEFAULT 0, + `cost_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000, + `conversions_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000, + `conversion_value_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000, + `roas_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000, + `date_sync` date DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_campaign_keywords` (`campaign_id`,`ad_group_id`,`keyword_text`(191),`match_type`), + KEY `idx_campaign_keywords_campaign_id` (`campaign_id`), + KEY `idx_campaign_keywords_ad_group_id` (`ad_group_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `campaign_negative_keywords` ( `id` int(11) NOT NULL AUTO_INCREMENT, `campaign_id` int(11) NOT NULL, diff --git a/docs/memory.md b/docs/memory.md index a9aabb9..b34b711 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -36,6 +36,12 @@ Ten plik sluzy jako trwala pamiec dla Claude Code. Zapisuj tu wzorce, decyzje i - Frazy wyszukiwane dodane do wykluczonych oznaczane czerwonym kolorem (klasa CSS `term-is-negative`) - Negatywne slowa kluczowe dodawane przez Google Ads API i zapisywane lokalnie w `campaign_negative_keywords` - Klucze API przechowywane w tabeli `settings` (key-value) +- Frazy z Google Ads Keyword Planner dla URL produktu sa cachowane w `products_keyword_planner_terms` i ponownie uzywane przy generowaniu tytulu AI +- Zmiany produktowe (`title`, `description`, `google_product_category`, `custom_label_4`) sa synchronizowane bezposrednio do Merchant API i logowane per pole w `products_merchant_sync_log` +- `cron_products` dziala batchowo po klientach (`clients_per_run`), domyslnie `10` (max `100`), aby ograniczyc liczbe wywolan; odpowiedz zawiera `estimated_calls_remaining_in_phase` +- `cron_campaigns` dziala batchowo po klientach (`clients_per_run`), domyslnie `2` (max `20`), a odpowiedz zawiera `estimated_calls_remaining_today` +- Zmiana listy klientow (np. reaktywacja klienta) nie powinna resetowac postepu `cron_products`; pipeline zachowuje przetworzonych i dopina nowych klientow +- W `cron_campaigns` stan `processed_ids` jest normalizowany do aktualnej listy aktywnych klientow, aby uniknac rozjazdow postepu po zmianach aktywnosci ## Preferencje uzytkownika diff --git a/index.php b/index.php index 002e61b..80653a1 100644 --- a/index.php +++ b/index.php @@ -43,6 +43,7 @@ $route_aliases = [ 'logout' => ['users', 'logout'], 'settings' => ['users', 'settings'], 'settings/save' => ['users', 'settings_save'], + 'settings/cron_status' => ['users', 'settings_cron_status'], 'settings/save_google_ads' => ['users', 'settings_save_google_ads'], 'settings/save_openai' => ['users', 'settings_save_openai'], 'settings/save_claude' => ['users', 'settings_save_claude'], diff --git a/layout/style.css b/layout/style.css index 8bc73a1..a85f8f1 100644 --- a/layout/style.css +++ b/layout/style.css @@ -1,1679 +1 @@ -* { - box-sizing: border-box; -} - -body { - font-family: "Roboto", sans-serif; - margin: 0; - padding: 0; - font-size: 14px; - color: #4E5E6A; - background: #F4F6F9; -} - -.hide { - display: none; -} - -small { - font-size: 0.75em; -} - -.text-right { - text-align: right; -} - -.text-bold { - font-weight: 700 !important; -} - -.nowrap { - white-space: nowrap; -} - -body.unlogged { - background: #F4F6F9; - margin: 0; - padding: 0; -} - -.login-container { - display: flex; - min-height: 100vh; -} - -.login-brand { - flex: 0 0 45%; - background: linear-gradient(135deg, #1E2A3A 0%, #2C3E57 50%, #6690F4 100%); - display: flex; - align-items: center; - justify-content: center; - padding: 60px; - position: relative; - overflow: hidden; -} -.login-brand::before { - content: ""; - position: absolute; - top: -50%; - right: -50%; - width: 100%; - height: 100%; - background: radial-gradient(circle, rgba(102, 144, 244, 0.15) 0%, transparent 70%); - border-radius: 50%; -} -.login-brand .brand-content { - position: relative; - z-index: 1; - color: #FFFFFF; - max-width: 400px; -} -.login-brand .brand-logo { - font-size: 48px; - font-weight: 300; - margin-bottom: 20px; - letter-spacing: -1px; -} -.login-brand .brand-logo strong { - font-weight: 700; -} -.login-brand .brand-tagline { - font-size: 18px; - opacity: 0.85; - line-height: 1.6; - margin-bottom: 50px; -} -.login-brand .brand-features .feature { - display: flex; - align-items: center; - gap: 15px; - margin-bottom: 20px; - opacity: 0.8; -} -.login-brand .brand-features .feature i { - font-size: 20px; - width: 40px; - height: 40px; - display: flex; - align-items: center; - justify-content: center; - background: rgba(255, 255, 255, 0.1); - border-radius: 10px; -} -.login-brand .brand-features .feature span { - font-size: 15px; -} - -.login-form-wrapper { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - padding: 60px; - background: #FFFFFF; -} - -.login-box { - width: 100%; - max-width: 420px; -} -.login-box .login-header { - margin-bottom: 35px; -} -.login-box .login-header h1 { - font-size: 28px; - font-weight: 700; - color: #2D3748; - margin: 0 0 8px; -} -.login-box .login-header p { - color: #718096; - font-size: 15px; - margin: 0; -} -.login-box .form-group { - margin-bottom: 20px; -} -.login-box .form-group label { - display: block; - font-size: 13px; - font-weight: 600; - color: #2D3748; - margin-bottom: 6px; -} -.login-box .input-with-icon { - position: relative; -} -.login-box .input-with-icon i { - position: absolute; - left: 14px; - top: 50%; - transform: translateY(-50%); - color: #A0AEC0; - font-size: 14px; -} -.login-box .input-with-icon .form-control { - padding-left: 42px; -} -.login-box .form-control { - width: 100%; - height: 46px; - border: 2px solid #E2E8F0; - border-radius: 8px; - padding: 0 14px; - font-size: 14px; - font-family: "Roboto", sans-serif; - color: #2D3748; - transition: border-color 0.3s, box-shadow 0.3s; -} -.login-box .form-control::placeholder { - color: #CBD5E0; -} -.login-box .form-control:focus { - border-color: #6690F4; - box-shadow: 0 0 0 3px rgba(102, 144, 244, 0.15); - outline: none; -} -.login-box .form-error { - color: #CC0000; - font-size: 12px; - margin-top: 4px; -} -.login-box .checkbox-group .checkbox-label { - display: flex; - align-items: center; - gap: 8px; - cursor: pointer; - font-size: 13px; - color: #718096; - font-weight: 400; -} -.login-box .checkbox-group .checkbox-label input[type=checkbox] { - width: 16px; - height: 16px; - accent-color: #6690F4; -} -.login-box .btn-login { - width: 100%; - height: 48px; - font-size: 15px; - font-weight: 600; - border-radius: 8px; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; -} -.login-box .btn-login.disabled { - opacity: 0.7; - pointer-events: none; -} -.login-box .alert { - display: none; - padding: 12px 16px; - border-radius: 8px; - font-size: 13px; - margin-bottom: 20px; -} -.login-box .alert.alert-danger { - background: #FFF5F5; - color: #CC0000; - border: 1px solid #FED7D7; -} -.login-box .alert.alert-success { - background: #F0FFF4; - color: #276749; - border: 1px solid #C6F6D5; -} - -@media (max-width: 768px) { - .login-brand { - display: none; - } - .login-form-wrapper { - padding: 30px 20px; - } -} -body.logged { - display: flex; - min-height: 100vh; - background: #F4F6F9; -} - -.sidebar { - width: 260px; - min-height: 100vh; - background: #1E2A3A; - position: fixed; - top: 0; - left: 0; - z-index: 1000; - display: flex; - flex-direction: column; - transition: width 0.3s ease; - overflow: hidden; -} -.sidebar.collapsed { - width: 70px; -} -.sidebar.collapsed .sidebar-header { - padding: 16px 0; - justify-content: center; -} -.sidebar.collapsed .sidebar-header .sidebar-logo { - display: none; -} -.sidebar.collapsed .sidebar-header .sidebar-toggle i { - transform: rotate(180deg); -} -.sidebar.collapsed .sidebar-nav ul li a { - padding: 12px 0; - justify-content: center; -} -.sidebar.collapsed .sidebar-nav ul li a span { - display: none; -} -.sidebar.collapsed .sidebar-nav ul li a i { - margin-right: 0; - font-size: 18px; -} -.sidebar.collapsed .sidebar-footer .sidebar-user { - justify-content: center; -} -.sidebar.collapsed .sidebar-footer .sidebar-user .user-info { - display: none; -} -.sidebar.collapsed .sidebar-footer .sidebar-logout { - justify-content: center; -} -.sidebar.collapsed .sidebar-footer .sidebar-logout span { - display: none; -} -.sidebar.collapsed .nav-divider { - margin: 8px 15px; -} - -.sidebar-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 20px 20px 16px; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); -} -.sidebar-header .sidebar-logo a { - color: #FFFFFF; - text-decoration: none; - font-size: 24px; - font-weight: 300; - letter-spacing: -0.5px; -} -.sidebar-header .sidebar-logo a strong { - font-weight: 700; -} -.sidebar-header .sidebar-toggle { - background: none; - border: none; - color: #A8B7C7; - cursor: pointer; - padding: 6px; - border-radius: 6px; - transition: all 0.3s; -} -.sidebar-header .sidebar-toggle:hover { - background: rgba(255, 255, 255, 0.08); - color: #FFFFFF; -} -.sidebar-header .sidebar-toggle i { - transition: transform 0.3s; -} - -.sidebar-nav { - flex: 1; - padding: 12px 0; - overflow-y: auto; -} -.sidebar-nav ul { - list-style: none; - margin: 0; - padding: 0; -} -.sidebar-nav ul li.nav-divider { - height: 1px; - background: rgba(255, 255, 255, 0.08); - margin: 8px 20px; -} -.sidebar-nav ul li a { - display: flex; - align-items: center; - padding: 11px 20px; - color: #A8B7C7; - text-decoration: none; - font-size: 14px; - transition: all 0.2s; - border-left: 3px solid transparent; -} -.sidebar-nav ul li a i { - width: 20px; - text-align: center; - margin-right: 12px; - font-size: 15px; -} -.sidebar-nav ul li a:hover { - background: #263548; - color: #FFFFFF; -} -.sidebar-nav ul li.active > a { - background: rgba(102, 144, 244, 0.15); - color: #FFFFFF; - border-left-color: #6690F4; -} -.sidebar-nav ul li.active > a i { - color: #6690F4; -} - -.sidebar-footer { - padding: 16px 20px; - border-top: 1px solid rgba(255, 255, 255, 0.08); -} -.sidebar-footer .sidebar-user { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 12px; -} -.sidebar-footer .sidebar-user .user-avatar { - width: 34px; - height: 34px; - border-radius: 50%; - background: rgba(102, 144, 244, 0.2); - display: flex; - align-items: center; - justify-content: center; - color: #6690F4; - font-size: 14px; - flex-shrink: 0; -} -.sidebar-footer .sidebar-user .user-info { - overflow: hidden; -} -.sidebar-footer .sidebar-user .user-info .user-email { - color: #A8B7C7; - font-size: 12px; - display: block; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.sidebar-footer .sidebar-logout { - display: flex; - align-items: center; - gap: 8px; - color: #E53E3E; - text-decoration: none; - font-size: 13px; - padding: 8px 10px; - border-radius: 6px; - transition: all 0.2s; -} -.sidebar-footer .sidebar-logout i { - font-size: 14px; -} -.sidebar-footer .sidebar-logout:hover { - background: rgba(229, 62, 62, 0.1); -} - -.main-wrapper { - margin-left: 260px; - flex: 1; - min-height: 100vh; - transition: margin-left 0.3s ease; - display: flex; - flex-direction: column; -} -.main-wrapper.expanded { - margin-left: 70px; -} - -.topbar { - height: 56px; - background: #FFFFFF; - border-bottom: 1px solid #E2E8F0; - display: flex; - align-items: center; - padding: 0 25px; - position: sticky; - top: 0; - z-index: 500; -} -.topbar .topbar-toggle { - background: none; - border: none; - color: #4E5E6A; - cursor: pointer; - padding: 8px 10px; - border-radius: 6px; - font-size: 16px; - margin-right: 15px; - transition: all 0.2s; -} -.topbar .topbar-toggle:hover { - background: #F4F6F9; -} -.topbar .topbar-breadcrumb { - font-size: 16px; - font-weight: 600; - color: #2D3748; -} - -.content { - flex: 1; - padding: 25px; -} - -.app-alert { - background: #EBF8FF; - border: 1px solid #BEE3F8; - color: #2B6CB0; - padding: 12px 16px; - border-radius: 8px; - margin-bottom: 20px; - font-size: 14px; -} - -.btn { - padding: 10px 20px; - transition: all 0.2s ease; - color: #FFFFFF; - border: 0; - border-radius: 6px; - cursor: pointer; - display: inline-flex; - text-decoration: none; - gap: 6px; - justify-content: center; - align-items: center; - font-size: 14px; - font-family: "Roboto", sans-serif; - font-weight: 500; -} -.btn.btn_small, .btn.btn-xs, .btn.btn-sm { - padding: 5px 10px; - font-size: 12px; -} -.btn.btn_small i, .btn.btn-xs i, .btn.btn-sm i { - font-size: 11px; -} -.btn.btn-success { - background: #57B951; -} -.btn.btn-success:hover { - background: #4a9c3b; -} -.btn.btn-primary { - background: #6690F4; -} -.btn.btn-primary:hover { - background: #3164db; -} -.btn.btn-danger { - background: #CC0000; -} -.btn.btn-danger:hover { - background: #b30000; -} -.btn.disabled { - opacity: 0.6; - pointer-events: none; -} - -.form-control { - border: 1px solid #E2E8F0; - border-radius: 6px; - height: 38px; - width: 100%; - padding: 6px 12px; - font-family: "Roboto", sans-serif; - font-size: 14px; - color: #2D3748; - transition: border-color 0.2s, box-shadow 0.2s; -} -.form-control option { - padding: 5px; -} -.form-control:focus { - border-color: #6690F4; - box-shadow: 0 0 0 3px rgba(102, 144, 244, 0.1); - outline: none; -} - -input[type=checkbox] { - border: 1px solid #E2E8F0; -} - -table { - border-collapse: collapse; - font-size: 13px; -} - -.table { - width: 100%; -} -.table th, -.table td { - border: 1px solid #E2E8F0; - padding: 8px 10px; -} -.table th { - background: #F7FAFC; - font-weight: 600; - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.03em; - color: #718096; -} -.table td.center { - text-align: center; -} -.table td.left { - text-align: left; -} -.table.table-sm td { - padding: 5px !important; -} -.table input.form-control { - font-size: 13px; - height: 32px; -} - -.card { - background: #FFFFFF; - padding: 20px; - border-radius: 8px; - color: #2D3748; - font-size: 14px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); -} -.card.mb25 { - margin-bottom: 20px; -} -.card .card-header { - font-weight: 600; - font-size: 15px; -} -.card .card-body { - padding-top: 12px; -} -.card .card-body table th, -.card .card-body table td { - font-size: 13px; -} -.card .card-body table th.bold, -.card .card-body table td.bold { - font-weight: 600; -} -.card .card-body table th.text-right, -.card .card-body table td.text-right { - text-align: right; -} -.card .card-body table th.text-center, -.card .card-body table td.text-center { - text-align: center; -} - -.action_menu { - display: flex; - margin-bottom: 20px; - gap: 12px; -} -.action_menu .btn { - padding: 8px 16px; -} -.action_menu .btn.btn_add { - background: #57B951; -} -.action_menu .btn.btn_add:hover { - background: #4a9c3b; -} -.action_menu .btn.btn_cancel { - background: #CC0000; -} -.action_menu .btn.btn_cancel:hover { - background: #b30000; -} - -.settings-card { - background: #FFFFFF; - border-radius: 10px; - padding: 28px; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); -} -.settings-card .settings-card-header { - display: flex; - align-items: center; - gap: 14px; - margin-bottom: 24px; - padding-bottom: 16px; - border-bottom: 1px solid #E2E8F0; -} -.settings-card .settings-card-header .settings-card-icon { - width: 44px; - height: 44px; - border-radius: 10px; - background: rgb(225.706097561, 233.7475609756, 252.893902439); - color: #6690F4; - display: flex; - align-items: center; - justify-content: center; - font-size: 18px; - flex-shrink: 0; -} -.settings-card .settings-card-header h3 { - margin: 0; - font-size: 17px; - font-weight: 600; - color: #2D3748; -} -.settings-card .settings-card-header small { - color: #8899A6; - font-size: 13px; -} -.settings-card .settings-field { - margin-bottom: 18px; -} -.settings-card .settings-field label { - display: block; - font-size: 13px; - font-weight: 600; - color: #2D3748; - margin-bottom: 6px; -} -.settings-card .settings-input-wrap { - position: relative; -} -.settings-card .settings-input-wrap .settings-input-icon { - position: absolute; - left: 12px; - top: 50%; - transform: translateY(-50%); - color: #A0AEC0; - font-size: 14px; - pointer-events: none; -} -.settings-card .settings-input-wrap .form-control { - padding-left: 38px; -} -.settings-card .settings-input-wrap .settings-toggle-pw { - position: absolute; - right: 4px; - top: 50%; - transform: translateY(-50%); - background: none; - border: none; - color: #A0AEC0; - cursor: pointer; - padding: 6px 10px; - font-size: 14px; - transition: color 0.2s; -} -.settings-card .settings-input-wrap .settings-toggle-pw:hover { - color: #6690F4; -} -.settings-card .settings-toggle-label { - display: inline-flex; - align-items: center; - gap: 10px; - cursor: pointer; - font-size: 14px; - font-weight: 500; - user-select: none; -} -.settings-card .settings-toggle-checkbox { - display: none; -} -.settings-card .settings-toggle-checkbox + .settings-toggle-switch { - position: relative; - width: 44px; - height: 24px; - background: #ccc; - border-radius: 12px; - transition: background 0.2s; - flex-shrink: 0; -} -.settings-card .settings-toggle-checkbox + .settings-toggle-switch::after { - content: ""; - position: absolute; - top: 3px; - left: 3px; - width: 18px; - height: 18px; - background: #fff; - border-radius: 50%; - transition: transform 0.2s; -} -.settings-card .settings-toggle-checkbox:checked + .settings-toggle-switch { - background: #22C55E; -} -.settings-card .settings-toggle-checkbox:checked + .settings-toggle-switch::after { - transform: translateX(20px); -} -.settings-card .settings-fields-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 0 24px; -} -@media (max-width: 768px) { - .settings-card .settings-fields-grid { - grid-template-columns: 1fr; - } -} -.settings-card .settings-alert-error { - display: flex; - align-items: center; - gap: 10px; - background: #FFF5F5; - color: #CC0000; - border: 1px solid #FED7D7; - border-radius: 8px; - padding: 12px 16px; - margin-bottom: 20px; - font-size: 13px; -} -.settings-card .settings-alert-error i { - font-size: 16px; - flex-shrink: 0; -} - -.clients-page .clients-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20px; -} -.clients-page .clients-header h2 { - margin: 0; - font-size: 20px; - font-weight: 600; - color: #2D3748; -} -.clients-page .clients-header h2 i { - color: #6690F4; - margin-right: 8px; -} -.clients-page .clients-table-wrap { - background: #FFFFFF; - border-radius: 10px; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); - overflow: hidden; -} -.clients-page .clients-table-wrap .table { - margin: 0; -} -.clients-page .clients-table-wrap .table thead th { - background: #F8FAFC; - border-bottom: 2px solid #E2E8F0; - font-size: 12px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.5px; - color: #8899A6; - padding: 14px 20px; -} -.clients-page .clients-table-wrap .table tbody td { - padding: 14px 20px; - vertical-align: middle; - border-bottom: 1px solid #F1F5F9; -} -.clients-page .clients-table-wrap .table tbody tr:hover { - background: #F8FAFC; -} -.clients-page .clients-table-wrap .table .client-id { - color: #8899A6; - font-size: 13px; - font-weight: 600; -} -.clients-page .clients-table-wrap .table .client-name { - font-weight: 600; - color: #2D3748; -} -.clients-page .badge-id { - display: inline-block; - background: #EEF2FF; - color: #6690F4; - font-size: 13px; - font-weight: 600; - padding: 4px 10px; - border-radius: 6px; - font-family: monospace; -} -.clients-page .actions-cell { - text-align: center; - white-space: nowrap; -} -.clients-page .btn-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 34px; - height: 34px; - border-radius: 8px; - border: none; - cursor: pointer; - font-size: 14px; - transition: all 0.2s; - margin: 0 2px; -} -.clients-page .btn-icon.btn-icon-edit { - background: #EEF2FF; - color: #6690F4; -} -.clients-page .btn-icon.btn-icon-edit:hover { - background: #6690F4; - color: #FFFFFF; -} -.clients-page .btn-icon.btn-icon-delete { - background: #FFF5F5; - color: #CC0000; -} -.clients-page .btn-icon.btn-icon-delete:hover { - background: #CC0000; - color: #FFFFFF; -} -.clients-page .empty-state { - text-align: center; - padding: 50px 20px !important; - color: #A0AEC0; -} -.clients-page .empty-state i { - font-size: 40px; - margin-bottom: 12px; - display: block; -} -.clients-page .empty-state p { - margin: 0; - font-size: 15px; -} - -.btn-secondary { - background: #E2E8F0; - color: #2D3748; - border: none; - padding: 8px 18px; - border-radius: 6px; - font-size: 14px; - cursor: pointer; - transition: background 0.2s; -} -.btn-secondary:hover { - background: #CBD5E0; -} - -.campaigns-page .campaigns-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20px; -} -.campaigns-page .campaigns-header h2 { - margin: 0; - font-size: 20px; - font-weight: 600; - color: #2D3748; -} -.campaigns-page .campaigns-header h2 i { - color: #6690F4; - margin-right: 8px; -} -.campaigns-page .campaigns-filters { - display: flex; - gap: 20px; - margin-bottom: 20px; -} -.campaigns-page .campaigns-filters .filter-group { - flex: 1; -} -.campaigns-page .campaigns-filters .filter-group label { - display: block; - font-size: 12px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.5px; - color: #8899A6; - margin-bottom: 6px; -} -.campaigns-page .campaigns-filters .filter-group label i { - margin-right: 4px; -} -.campaigns-page .campaigns-filters .filter-group .form-control { - width: 100%; - padding: 10px 14px; - border: 1px solid #E2E8F0; - border-radius: 8px; - font-size: 14px; - color: #2D3748; - background: #FFFFFF; - transition: border-color 0.2s; - appearance: none; - -webkit-appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238899A6' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 12px center; - padding-right: 32px; -} -.campaigns-page .campaigns-filters .filter-group .form-control:focus { - outline: none; - border-color: #6690F4; - box-shadow: 0 0 0 3px rgba(102, 144, 244, 0.1); -} -.campaigns-page .campaigns-filters .filter-group .filter-with-action { - display: flex; - gap: 8px; -} -.campaigns-page .campaigns-filters .filter-group .filter-with-action .form-control { - flex: 1; -} -.campaigns-page .campaigns-filters .filter-group .filter-with-action .btn-icon { - flex-shrink: 0; - width: 42px; - height: 42px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 8px; - border: none; - cursor: pointer; - font-size: 14px; - transition: all 0.2s; -} -.campaigns-page .campaigns-filters .filter-group .filter-with-action .btn-icon.btn-icon-delete { - background: #FFF5F5; - color: #CC0000; -} -.campaigns-page .campaigns-filters .filter-group .filter-with-action .btn-icon.btn-icon-delete:hover { - background: #CC0000; - color: #FFFFFF; -} -.campaigns-page .campaigns-chart-wrap { - background: #FFFFFF; - border-radius: 10px; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); - padding: 20px; - margin-bottom: 20px; - min-height: 350px; -} -.campaigns-page .campaigns-table-wrap { - background: #FFFFFF; - border-radius: 10px; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); - overflow: hidden; -} -.campaigns-page .campaigns-table-wrap .table { - margin: 0; - width: 100% !important; -} -.campaigns-page .campaigns-table-wrap .table thead th { - background: #F8FAFC; - border-bottom: 2px solid #E2E8F0; - font-size: 12px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.5px; - color: #8899A6; - padding: 12px 16px; - white-space: nowrap; -} -.campaigns-page .campaigns-table-wrap .table tbody td { - padding: 10px 16px; - vertical-align: middle; - border-bottom: 1px solid #F1F5F9; - font-size: 13px; -} -.campaigns-page .campaigns-table-wrap .table tbody tr:hover { - background: #F8FAFC; -} -.campaigns-page .campaigns-table-wrap .dt-layout-row { - padding: 14px 20px; - margin: 0 !important; - border-top: 1px solid #F1F5F9; -} -.campaigns-page .campaigns-table-wrap .dt-layout-row:first-child { - display: none; -} -.campaigns-page .campaigns-table-wrap .dt-info { - font-size: 13px; - color: #8899A6; -} -.campaigns-page .campaigns-table-wrap .dt-paging .pagination { - margin: 0; - padding: 0; - list-style: none; - display: flex; - align-items: center; - gap: 6px; -} -.campaigns-page .campaigns-table-wrap .dt-paging .pagination .page-item .page-link { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 36px; - width: fit-content; - height: 36px; - padding: 0 14px; - border-radius: 8px; - font-size: 13px; - font-weight: 500; - border: 1px solid #E2E8F0; - background: #FFFFFF; - color: #4E5E6A; - cursor: pointer; - transition: all 0.2s; - text-decoration: none; - line-height: 1; - white-space: nowrap; -} -.campaigns-page .campaigns-table-wrap .dt-paging .pagination .page-item .page-link:hover { - background: #EEF2FF; - color: #6690F4; - border-color: #6690F4; -} -.campaigns-page .campaigns-table-wrap .dt-paging .pagination .page-item.active .page-link { - background: #6690F4; - color: #FFFFFF; - border-color: #6690F4; - font-weight: 600; -} -.campaigns-page .campaigns-table-wrap .dt-paging .pagination .page-item.disabled .page-link { - opacity: 0.35; - cursor: default; - pointer-events: none; -} -.campaigns-page .campaigns-table-wrap .dt-processing { - background: rgba(255, 255, 255, 0.9); - color: #4E5E6A; - font-size: 14px; -} -.campaigns-page .delete-history-entry { - display: inline-flex; - align-items: center; - justify-content: center; - width: 30px; - height: 30px; - border-radius: 6px; - border: none; - cursor: pointer; - font-size: 12px; - background: #FFF5F5; - color: #CC0000; - transition: all 0.2s; -} -.campaigns-page .delete-history-entry:hover { - background: #CC0000; - color: #FFFFFF; -} - -.products-page .products-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20px; -} -.products-page .products-header h2 { - margin: 0; - font-size: 20px; - font-weight: 600; - color: #2D3748; -} -.products-page .products-header h2 i { - color: #6690F4; - margin-right: 8px; -} -.products-page .products-filters { - display: flex; - gap: 20px; - margin-bottom: 16px; -} -.products-page .products-filters .filter-group label { - display: block; - font-size: 12px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.5px; - color: #8899A6; - margin-bottom: 6px; -} -.products-page .products-filters .filter-group label i { - margin-right: 4px; -} -.products-page .products-filters .filter-group .form-control { - width: 100%; - padding: 10px 14px; - border: 1px solid #E2E8F0; - border-radius: 8px; - font-size: 14px; - color: #2D3748; - background: #FFFFFF; - transition: border-color 0.2s; -} -.products-page .products-filters .filter-group .form-control:focus { - outline: none; - border-color: #6690F4; - box-shadow: 0 0 0 3px rgba(102, 144, 244, 0.1); -} -.products-page .products-filters .filter-group select.form-control { - appearance: none; - -webkit-appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238899A6' d='M6 8L1 3h10z'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 12px center; - padding-right: 32px; -} -.products-page .products-filters .filter-group.filter-group-client { - flex: 1; -} -.products-page .products-filters .filter-group.filter-group-roas { - flex: 0 0 200px; -} -.products-page .products-actions { - margin-bottom: 12px; -} -.products-page .products-actions .btn-danger { - padding: 7px 14px; - font-size: 13px; - border-radius: 6px; - border: none; - cursor: pointer; - transition: all 0.2s; -} -.products-page .products-actions .btn-danger:disabled { - opacity: 0.4; - cursor: default; -} -.products-page .products-table-wrap { - background: #FFFFFF; - border-radius: 10px; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); - overflow: hidden; -} -.products-page .products-table-wrap .table { - margin: 0; - width: 100% !important; -} -.products-page .products-table-wrap .table thead th { - background: #F8FAFC; - border-bottom: 2px solid #E2E8F0; - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.3px; - color: #8899A6; - padding: 10px 8px; - white-space: nowrap; -} -.products-page .products-table-wrap .table tbody td { - padding: 6px 8px; - vertical-align: middle; - border-bottom: 1px solid #F1F5F9; - font-size: 12px; -} -.products-page .products-table-wrap .table tbody tr:hover { - background: #F8FAFC; -} -.products-page .products-table-wrap .table input.min_roas, -.products-page .products-table-wrap .table input.form-control-sm, -.products-page .products-table-wrap .table select.custom_label_4, -.products-page .products-table-wrap .table select.form-control-sm { - padding: 3px 6px; - font-size: 12px; - border: 1px solid #E2E8F0; - border-radius: 4px; - background: #FFFFFF; -} -.products-page .products-table-wrap .dt-layout-row { - padding: 14px 20px; - margin: 0 !important; - border-top: 1px solid #F1F5F9; -} -.products-page .products-table-wrap .dt-layout-row:first-child { - display: none; -} -.products-page .products-table-wrap .dt-info { - font-size: 13px; - color: #8899A6; -} -.products-page .products-table-wrap .dt-paging .pagination { - margin: 0; - padding: 0; - list-style: none; - display: flex; - align-items: center; - gap: 6px; -} -.products-page .products-table-wrap .dt-paging .pagination .page-item .page-link { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 36px; - height: 36px; - padding: 0 14px; - border-radius: 8px; - font-size: 13px; - font-weight: 500; - border: 1px solid #E2E8F0; - background: #FFFFFF; - color: #4E5E6A; - cursor: pointer; - transition: all 0.2s; - text-decoration: none; - line-height: 1; - white-space: nowrap; -} -.products-page .products-table-wrap .dt-paging .pagination .page-item .page-link:hover { - background: #EEF2FF; - color: #6690F4; - border-color: #6690F4; -} -.products-page .products-table-wrap .dt-paging .pagination .page-item.active .page-link { - background: #6690F4; - color: #FFFFFF; - border-color: #6690F4; - font-weight: 600; -} -.products-page .products-table-wrap .dt-paging .pagination .page-item.disabled .page-link { - opacity: 0.35; - cursor: default; - pointer-events: none; -} -.products-page .products-table-wrap .dt-processing { - background: rgba(255, 255, 255, 0.9); - color: #4E5E6A; - font-size: 14px; -} -.products-page .delete-product { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: 6px; - border: none; - cursor: pointer; - font-size: 12px; - background: #FFF5F5; - color: #CC0000; - transition: all 0.2s; -} -.products-page .delete-product:hover { - background: #CC0000; - color: #FFFFFF; -} -.products-page .edit-product-title { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: 6px; - border: none; - cursor: pointer; - font-size: 12px; - background: #EEF2FF; - color: #6690F4; - transition: all 0.2s; -} -.products-page .edit-product-title:hover { - background: #6690F4; - color: #FFFFFF; -} - -.desc-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 4px; -} -.desc-header label { - margin: 0; -} - -.desc-tabs { - display: flex; - gap: 2px; - background: #eee; - border-radius: 6px; - padding: 2px; -} - -.desc-tab { - border: none; - background: transparent; - padding: 4px 12px; - font-size: 12px; - border-radius: 4px; - cursor: pointer; - color: #666; - transition: all 0.15s ease; -} -.desc-tab i { - margin-right: 4px; -} -.desc-tab.active { - background: #fff; - color: #333; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); - font-weight: 500; -} -.desc-tab:hover:not(.active) { - color: #333; -} - -.desc-wrap { - flex: 1; - min-width: 0; -} - -.desc-preview ul, .desc-preview ol { - margin: 6px 0; - padding-left: 20px; -} -.desc-preview li { - margin-bottom: 3px; -} -.desc-preview b, .desc-preview strong { - font-weight: 600; -} - -.input-with-ai { - display: flex; - gap: 8px; - align-items: flex-start; -} -.input-with-ai .form-control { - flex: 1; -} - -.btn-ai-suggest { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 6px 12px; - border-radius: 8px; - border: 1px solid #C084FC; - background: linear-gradient(135deg, #F3E8FF, #EDE9FE); - color: #7C3AED; - font-size: 12px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s; - white-space: nowrap; - min-height: 38px; -} -.btn-ai-suggest i { - font-size: 13px; -} -.btn-ai-suggest:hover { - background: linear-gradient(135deg, #7C3AED, #6D28D9); - color: #FFF; - border-color: #6D28D9; -} -.btn-ai-suggest:disabled { - opacity: 0.7; - cursor: wait; -} -.btn-ai-suggest.btn-ai-claude { - border-color: #D97706; - background: linear-gradient(135deg, #FEF3C7, #FDE68A); - color: #92400E; -} -.btn-ai-suggest.btn-ai-claude:hover { - background: linear-gradient(135deg, #D97706, #B45309); - color: #FFF; - border-color: #B45309; -} - -.form_container { - background: #FFFFFF; - padding: 25px; - max-width: 1300px; - border-radius: 8px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); -} -.form_container.full { - max-width: 100%; -} -.form_container .form_group { - margin-bottom: 12px; - display: flex; -} -.form_container .form_group > .label { - width: 300px; - display: inline-flex; - align-items: flex-start; - justify-content: right; - padding-right: 12px; -} -.form_container .form_group .input { - width: calc(100% - 300px); -} - -.default_popup { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.45); - display: none; - z-index: 2000; -} -.default_popup .popup_content { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - background: #FFFFFF; - padding: 25px; - border-radius: 10px; - max-width: 1140px; - width: 95%; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15); -} -.default_popup .popup_content .popup_header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 15px; -} -.default_popup .popup_content .popup_header .title { - font-size: 18px; - font-weight: 600; -} -.default_popup .popup_content .close { - cursor: pointer; - color: #A0AEC0; - font-size: 18px; - padding: 4px; -} -.default_popup .popup_content .close:hover { - color: #CC0000; -} - -.dt-layout-table { - margin-bottom: 20px; -} - -.pagination button { - border: 1px solid #E2E8F0; - background: #FFFFFF; - display: inline-flex; - height: 32px; - width: 32px; - align-items: center; - justify-content: center; - margin: 0 2px; - border-radius: 4px; - transition: all 0.2s; - cursor: pointer; -} -.pagination button:hover { - background: #F4F6F9; - border-color: #6690F4; -} - -table#products a { - color: inherit; - text-decoration: none; -} -table#products .table-product-title { - display: flex; - justify-content: space-between; -} -table#products .edit-product-title { - display: flex; - height: 25px; - align-items: center; - justify-content: center; - width: 25px; - cursor: pointer; - background: #FFFFFF; - border: 1px solid #CBD5E0; - color: #CBD5E0; - border-radius: 4px; -} -table#products .edit-product-title:hover { - background: #CBD5E0; - color: #FFFFFF; -} -table#products a.custom_name { - color: #57b951 !important; -} - -.chart-with-form { - display: flex; - gap: 20px; - align-items: flex-start; -} - -.chart-area { - flex: 1 1 auto; - min-width: 0; -} - -.comment-form { - width: 360px; - flex: 0 0 360px; -} -.comment-form .form-group { - margin-bottom: 12px; -} -.comment-form label { - display: block; - font-weight: 600; - margin-bottom: 6px; - font-size: 13px; -} -.comment-form input[type=date], -.comment-form textarea { - width: 100%; - border: 1px solid #E2E8F0; - border-radius: 6px; - padding: 8px 12px; - font-size: 14px; - font-family: "Roboto", sans-serif; -} -.comment-form textarea { - min-height: 120px; - resize: vertical; -} -.comment-form .btn { - padding: 8px 16px; -} -.comment-form .btn[disabled] { - opacity: 0.6; - cursor: not-allowed; -} -.comment-form .hint { - font-size: 12px; - color: #718096; -} - -.jconfirm-box .form-group .select2-container, -.adspro-dialog-box .form-group .select2-container { - width: 100% !important; - margin-top: 8px; -} - -.jconfirm-box .select2-container--default .select2-selection--single, -.adspro-dialog-box .select2-container--default .select2-selection--single { - background-color: #FFFFFF; - border: 1px solid #E2E8F0; - border-radius: 6px; - min-height: 42px; - display: flex; - align-items: center; - padding: 4px 12px; - box-shadow: none; - transition: border-color 0.2s, box-shadow 0.2s; - font-size: 14px; -} - -.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__rendered, -.adspro-dialog-box .select2-container--default .select2-selection--single .select2-selection__rendered { - padding-left: 0; - line-height: 1.4; - color: #495057; -} - -.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__placeholder, -.adspro-dialog-box .select2-container--default .select2-selection--single .select2-selection__placeholder { - color: #CBD5E0; -} - -.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__arrow, -.adspro-dialog-box .select2-container--default .select2-selection--single .select2-selection__arrow { - height: 100%; - right: 8px; -} - -.jconfirm-box .select2-container--default.select2-container--focus .select2-selection--single, -.jconfirm-box .select2-container--default .select2-selection--single:hover, -.adspro-dialog-box .select2-container--default.select2-container--focus .select2-selection--single, -.adspro-dialog-box .select2-container--default .select2-selection--single:hover { - border-color: #6690F4; - box-shadow: 0 0 0 3px rgba(102, 144, 244, 0.1); - outline: 0; -} - -.jconfirm-box .select2-container .select2-dropdown, -.adspro-dialog-box .select2-container .select2-dropdown { - border-color: #E2E8F0; - border-radius: 0 0 6px 6px; - font-size: 14px; -} - -.jconfirm-box .select2-container .select2-search--dropdown .select2-search__field, -.adspro-dialog-box .select2-container .select2-search--dropdown .select2-search__field { - padding: 6px 10px; - border-radius: 4px; - border: 1px solid #E2E8F0; - font-size: 14px; -} - -.jconfirm-box .select2-container--default .select2-results__option--highlighted[aria-selected], -.adspro-dialog-box .select2-container--default .select2-results__option--highlighted[aria-selected] { - background-color: #6690F4; - color: #FFFFFF; -} - -@media (max-width: 992px) { - .sidebar { - transform: translateX(-100%); - } - .sidebar.mobile-open { - transform: translateX(0); - } - .main-wrapper { - margin-left: 0 !important; - } -} - -/*# sourceMappingURL=style.css.map */ - +*{box-sizing:border-box}body{font-family:"Roboto",sans-serif;margin:0;padding:0;font-size:14px;color:#4e5e6a;background:#f4f6f9}.hide{display:none}small{font-size:.75em}.text-right{text-align:right}.text-bold{font-weight:700 !important}.nowrap{white-space:nowrap}body.unlogged{background:#f4f6f9;margin:0;padding:0}.login-container{display:flex;min-height:100vh}.login-brand{flex:0 0 45%;background:linear-gradient(135deg, #1E2A3A 0%, #2C3E57 50%, #6690F4 100%);display:flex;align-items:center;justify-content:center;padding:60px;position:relative;overflow:hidden}.login-brand::before{content:"";position:absolute;top:-50%;right:-50%;width:100%;height:100%;background:radial-gradient(circle, rgba(102, 144, 244, 0.15) 0%, transparent 70%);border-radius:50%}.login-brand .brand-content{position:relative;z-index:1;color:#fff;max-width:400px}.login-brand .brand-logo{font-size:48px;font-weight:300;margin-bottom:20px;letter-spacing:-1px}.login-brand .brand-logo strong{font-weight:700}.login-brand .brand-tagline{font-size:18px;opacity:.85;line-height:1.6;margin-bottom:50px}.login-brand .brand-features .feature{display:flex;align-items:center;gap:15px;margin-bottom:20px;opacity:.8}.login-brand .brand-features .feature i{font-size:20px;width:40px;height:40px;display:flex;align-items:center;justify-content:center;background:hsla(0,0%,100%,.1);border-radius:10px}.login-brand .brand-features .feature span{font-size:15px}.login-form-wrapper{flex:1;display:flex;align-items:center;justify-content:center;padding:60px;background:#fff}.login-box{width:100%;max-width:420px}.login-box .login-header{margin-bottom:35px}.login-box .login-header h1{font-size:28px;font-weight:700;color:#2d3748;margin:0 0 8px}.login-box .login-header p{color:#718096;font-size:15px;margin:0}.login-box .form-group{margin-bottom:20px}.login-box .form-group label{display:block;font-size:13px;font-weight:600;color:#2d3748;margin-bottom:6px}.login-box .input-with-icon{position:relative}.login-box .input-with-icon i{position:absolute;left:14px;top:50%;transform:translateY(-50%);color:#a0aec0;font-size:14px}.login-box .input-with-icon .form-control{padding-left:42px}.login-box .form-control{width:100%;height:46px;border:2px solid #e2e8f0;border-radius:8px;padding:0 14px;font-size:14px;font-family:"Roboto",sans-serif;color:#2d3748;transition:border-color .3s,box-shadow .3s}.login-box .form-control::placeholder{color:#cbd5e0}.login-box .form-control:focus{border-color:#6690f4;box-shadow:0 0 0 3px rgba(102,144,244,.15);outline:none}.login-box .form-error{color:#c00;font-size:12px;margin-top:4px}.login-box .checkbox-group .checkbox-label{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:#718096;font-weight:400}.login-box .checkbox-group .checkbox-label input[type=checkbox]{width:16px;height:16px;accent-color:#6690f4}.login-box .btn-login{width:100%;height:48px;font-size:15px;font-weight:600;border-radius:8px;display:flex;align-items:center;justify-content:center;gap:8px}.login-box .btn-login.disabled{opacity:.7;pointer-events:none}.login-box .alert{display:none;padding:12px 16px;border-radius:8px;font-size:13px;margin-bottom:20px}.login-box .alert.alert-danger{background:#fff5f5;color:#c00;border:1px solid #fed7d7}.login-box .alert.alert-success{background:#f0fff4;color:#276749;border:1px solid #c6f6d5}@media(max-width: 768px){.login-brand{display:none}.login-form-wrapper{padding:30px 20px}}body.logged{display:flex;min-height:100vh;background:#f4f6f9}.sidebar{width:260px;min-height:100vh;background:#1e2a3a;position:fixed;top:0;left:0;z-index:1000;display:flex;flex-direction:column;transition:width .3s ease;overflow:hidden}.sidebar.collapsed{width:70px}.sidebar.collapsed .sidebar-header{padding:16px 0;justify-content:center}.sidebar.collapsed .sidebar-header .sidebar-logo{display:none}.sidebar.collapsed .sidebar-header .sidebar-toggle i{transform:rotate(180deg)}.sidebar.collapsed .sidebar-nav ul li a{padding:12px 0;justify-content:center}.sidebar.collapsed .sidebar-nav ul li a span{display:none}.sidebar.collapsed .sidebar-nav ul li a i{margin-right:0;font-size:18px}.sidebar.collapsed .sidebar-nav ul li.nav-group .nav-group-label{padding:12px 0;justify-content:center}.sidebar.collapsed .sidebar-nav ul li.nav-group .nav-group-label span{display:none}.sidebar.collapsed .sidebar-nav ul li.nav-group .nav-group-label i{margin-right:0;font-size:18px}.sidebar.collapsed .sidebar-footer .sidebar-user{justify-content:center}.sidebar.collapsed .sidebar-footer .sidebar-user .user-info{display:none}.sidebar.collapsed .sidebar-footer .sidebar-logout{justify-content:center}.sidebar.collapsed .sidebar-footer .sidebar-logout span{display:none}.sidebar.collapsed .nav-divider{margin:8px 15px}.sidebar-header{display:flex;align-items:center;justify-content:space-between;padding:20px 20px 16px;border-bottom:1px solid hsla(0,0%,100%,.08)}.sidebar-header .sidebar-logo a{color:#fff;text-decoration:none;font-size:24px;font-weight:300;letter-spacing:-0.5px}.sidebar-header .sidebar-logo a strong{font-weight:700}.sidebar-header .sidebar-toggle{background:none;border:none;color:#a8b7c7;cursor:pointer;padding:6px;border-radius:6px;transition:all .3s}.sidebar-header .sidebar-toggle:hover{background:hsla(0,0%,100%,.08);color:#fff}.sidebar-header .sidebar-toggle i{transition:transform .3s}.sidebar-nav{flex:1;padding:12px 0;overflow-y:auto}.sidebar-nav ul{list-style:none;margin:0;padding:0}.sidebar-nav ul li.nav-group{margin-bottom:4px}.sidebar-nav ul li.nav-group .nav-group-label{display:flex;align-items:center;padding:11px 20px;color:#d5deea;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;border-left:3px solid rgba(0,0,0,0)}.sidebar-nav ul li.nav-group .nav-group-label i{width:20px;text-align:center;margin-right:12px;font-size:14px;color:#b6c4d3}.sidebar-nav ul li.nav-group .nav-submenu{margin:0;padding:0;list-style:none}.sidebar-nav ul li.nav-group .nav-submenu li a{padding-left:44px}.sidebar-nav ul li.nav-group.active>.nav-group-label{color:#fff;background:rgba(102,144,244,.12);border-left-color:#6690f4}.sidebar-nav ul li.nav-group.active>.nav-group-label i{color:#6690f4}.sidebar-nav ul li.nav-divider{height:1px;background:hsla(0,0%,100%,.08);margin:8px 20px}.sidebar-nav ul li a{display:flex;align-items:center;padding:11px 20px;color:#a8b7c7;text-decoration:none;font-size:14px;transition:all .2s;border-left:3px solid rgba(0,0,0,0)}.sidebar-nav ul li a i{width:20px;text-align:center;margin-right:12px;font-size:15px}.sidebar-nav ul li a:hover{background:#263548;color:#fff}.sidebar-nav ul li.active>a{background:rgba(102,144,244,.15);color:#fff;border-left-color:#6690f4}.sidebar-nav ul li.active>a i{color:#6690f4}.sidebar-footer{padding:16px 20px;border-top:1px solid hsla(0,0%,100%,.08)}.sidebar-footer .sidebar-user{display:flex;align-items:center;gap:10px;margin-bottom:12px}.sidebar-footer .sidebar-user .user-avatar{width:34px;height:34px;border-radius:50%;background:rgba(102,144,244,.2);display:flex;align-items:center;justify-content:center;color:#6690f4;font-size:14px;flex-shrink:0}.sidebar-footer .sidebar-user .user-info{overflow:hidden}.sidebar-footer .sidebar-user .user-info .user-email{color:#a8b7c7;font-size:12px;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sidebar-footer .sidebar-logout{display:flex;align-items:center;gap:8px;color:#e53e3e;text-decoration:none;font-size:13px;padding:8px 10px;border-radius:6px;transition:all .2s}.sidebar-footer .sidebar-logout i{font-size:14px}.sidebar-footer .sidebar-logout:hover{background:rgba(229,62,62,.1)}.main-wrapper{margin-left:260px;flex:1;min-height:100vh;transition:margin-left .3s ease;display:flex;flex-direction:column}.main-wrapper.expanded{margin-left:70px}.topbar{height:56px;background:#fff;border-bottom:1px solid #e2e8f0;display:flex;align-items:center;padding:0 25px;position:sticky;top:0;z-index:500}.topbar .topbar-toggle{background:none;border:none;color:#4e5e6a;cursor:pointer;padding:8px 10px;border-radius:6px;font-size:16px;margin-right:15px;transition:all .2s}.topbar .topbar-toggle:hover{background:#f4f6f9}.topbar .topbar-breadcrumb{font-size:16px;font-weight:600;color:#2d3748}.content{flex:1;padding:25px}.app-alert{background:#ebf8ff;border:1px solid #bee3f8;color:#2b6cb0;padding:12px 16px;border-radius:8px;margin-bottom:20px;font-size:14px}.btn{padding:10px 20px;transition:all .2s ease;color:#fff;border:0;border-radius:6px;cursor:pointer;display:inline-flex;text-decoration:none;gap:6px;justify-content:center;align-items:center;font-size:14px;font-family:"Roboto",sans-serif;font-weight:500}.btn.btn_small,.btn.btn-xs,.btn.btn-sm{padding:5px 10px;font-size:12px}.btn.btn_small i,.btn.btn-xs i,.btn.btn-sm i{font-size:11px}.btn.btn-success{background:#57b951}.btn.btn-success:hover{background:#4a9c3b}.btn.btn-primary{background:#6690f4}.btn.btn-primary:hover{background:#3164db}.btn.btn-danger{background:#c00}.btn.btn-danger:hover{background:#b30000}.btn.disabled{opacity:.6;pointer-events:none}.form-control{border:1px solid #e2e8f0;border-radius:6px;height:38px;width:100%;padding:6px 12px;font-family:"Roboto",sans-serif;font-size:14px;color:#2d3748;transition:border-color .2s,box-shadow .2s}.form-control option{padding:5px}.form-control:focus{border-color:#6690f4;box-shadow:0 0 0 3px rgba(102,144,244,.1);outline:none}input[type=checkbox]{border:1px solid #e2e8f0}table{border-collapse:collapse;font-size:13px}.table{width:100%}.table th,.table td{border:1px solid #e2e8f0;padding:8px 10px}.table th{background:#f7fafc;font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:.03em;color:#718096}.table td.center{text-align:center}.table td.left{text-align:left}.table.table-sm td{padding:5px !important}.table input.form-control{font-size:13px;height:32px}.card{background:#fff;padding:20px;border-radius:8px;color:#2d3748;font-size:14px;box-shadow:0 1px 3px rgba(0,0,0,.06)}.card.mb25{margin-bottom:20px}.card .card-header{font-weight:600;font-size:15px}.card .card-body{padding-top:12px}.card .card-body table th,.card .card-body table td{font-size:13px}.card .card-body table th.bold,.card .card-body table td.bold{font-weight:600}.card .card-body table th.text-right,.card .card-body table td.text-right{text-align:right}.card .card-body table th.text-center,.card .card-body table td.text-center{text-align:center}.action_menu{display:flex;margin-bottom:20px;gap:12px}.action_menu .btn{padding:8px 16px}.action_menu .btn.btn_add{background:#57b951}.action_menu .btn.btn_add:hover{background:#4a9c3b}.action_menu .btn.btn_cancel{background:#c00}.action_menu .btn.btn_cancel:hover{background:#b30000}.settings-tabs{display:flex;gap:8px;margin-bottom:18px}.settings-tabs .settings-tab{display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border-radius:8px;text-decoration:none;color:#6b7a89;background:#e9eef5;border:1px solid #d8e0ea;font-size:13px;font-weight:600;transition:all .2s}.settings-tabs .settings-tab:hover{color:#2d3748;background:#dde6f2}.settings-tabs .settings-tab.active{color:#fff;background:#6690f4;border-color:#6690f4}.settings-card{background:#fff;border-radius:10px;padding:28px;box-shadow:0 1px 4px rgba(0,0,0,.06)}.settings-card .settings-card-header{display:flex;align-items:center;gap:14px;margin-bottom:24px;padding-bottom:16px;border-bottom:1px solid #e2e8f0}.settings-card .settings-card-header .settings-card-icon{width:44px;height:44px;border-radius:10px;background:rgb(225.706097561,233.7475609756,252.893902439);color:#6690f4;display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0}.settings-card .settings-card-header h3{margin:0;font-size:17px;font-weight:600;color:#2d3748}.settings-card .settings-card-header small{color:#8899a6;font-size:13px}.settings-card .settings-field{margin-bottom:18px}.settings-card .settings-field label{display:block;font-size:13px;font-weight:600;color:#2d3748;margin-bottom:6px}.settings-card .settings-input-wrap{position:relative}.settings-card .settings-input-wrap .settings-input-icon{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:#a0aec0;font-size:14px;pointer-events:none}.settings-card .settings-input-wrap .form-control{padding-left:38px}.settings-card .settings-input-wrap .settings-toggle-pw{position:absolute;right:4px;top:50%;transform:translateY(-50%);background:none;border:none;color:#a0aec0;cursor:pointer;padding:6px 10px;font-size:14px;transition:color .2s}.settings-card .settings-input-wrap .settings-toggle-pw:hover{color:#6690f4}.settings-card .settings-field .settings-toggle-label{display:inline-flex;align-items:center;gap:10px;cursor:pointer;font-size:14px;font-weight:500;user-select:none;margin-bottom:0;width:100%}.settings-card .settings-field .settings-toggle-label .settings-toggle-text{flex:1 1 auto;min-width:0;line-height:1.35}.settings-card .settings-toggle-checkbox{display:none}.settings-card .settings-toggle-checkbox+.settings-toggle-switch{display:inline-block;position:relative;width:44px;height:24px;background:#ccc;border-radius:12px;transition:background .2s;flex-shrink:0}.settings-card .settings-toggle-checkbox+.settings-toggle-switch::after{content:"";position:absolute;top:3px;left:3px;width:18px;height:18px;background:#fff;border-radius:50%;transition:transform .2s}.settings-card .settings-toggle-checkbox:checked+.settings-toggle-switch{background:#22c55e}.settings-card .settings-toggle-checkbox:checked+.settings-toggle-switch::after{transform:translateX(20px)}.settings-card .settings-fields-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 24px}@media(max-width: 768px){.settings-card .settings-fields-grid{grid-template-columns:1fr}}.settings-card .settings-alert-error{display:flex;align-items:center;gap:10px;background:#fff5f5;color:#c00;border:1px solid #fed7d7;border-radius:8px;padding:12px 16px;margin-bottom:20px;font-size:13px}.settings-card .settings-alert-error i{font-size:16px;flex-shrink:0}.clients-page .clients-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.clients-page .clients-header h2{margin:0;font-size:20px;font-weight:600;color:#2d3748}.clients-page .clients-header h2 i{color:#6690f4;margin-right:8px}.clients-page .clients-table-wrap{background:#fff;border-radius:10px;box-shadow:0 1px 4px rgba(0,0,0,.06);overflow:hidden}.clients-page .clients-table-wrap .table{margin:0}.clients-page .clients-table-wrap .table thead th{background:#f8fafc;border-bottom:2px solid #e2e8f0;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#8899a6;padding:14px 20px}.clients-page .clients-table-wrap .table tbody td{padding:14px 20px;vertical-align:middle;border-bottom:1px solid #f1f5f9}.clients-page .clients-table-wrap .table tbody tr:hover{background:#f8fafc}.clients-page .clients-table-wrap .table .client-id{color:#8899a6;font-size:13px;font-weight:600}.clients-page .clients-table-wrap .table .client-name{font-weight:600;color:#2d3748}.clients-page .badge-id{display:inline-block;background:#eef2ff;color:#6690f4;font-size:13px;font-weight:600;padding:4px 10px;border-radius:6px;font-family:monospace}.clients-page .actions-cell{text-align:center;white-space:nowrap}.clients-page .btn-icon{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:8px;border:none;cursor:pointer;font-size:14px;transition:all .2s;margin:0 2px}.clients-page .btn-icon.btn-icon-edit{background:#eef2ff;color:#6690f4}.clients-page .btn-icon.btn-icon-edit:hover{background:#6690f4;color:#fff}.clients-page .btn-icon.btn-icon-delete{background:#fff5f5;color:#c00}.clients-page .btn-icon.btn-icon-delete:hover{background:#c00;color:#fff}.clients-page .empty-state{text-align:center;padding:50px 20px !important;color:#a0aec0}.clients-page .empty-state i{font-size:40px;margin-bottom:12px;display:block}.clients-page .empty-state p{margin:0;font-size:15px}.btn-secondary{background:#e2e8f0;color:#2d3748;border:none;padding:8px 18px;border-radius:6px;font-size:14px;cursor:pointer;transition:background .2s}.btn-secondary:hover{background:#cbd5e0}.campaigns-page .campaigns-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.campaigns-page .campaigns-header h2{margin:0;font-size:20px;font-weight:600;color:#2d3748}.campaigns-page .campaigns-header h2 i{color:#6690f4;margin-right:8px}.campaigns-page .campaigns-filters{display:flex;gap:20px;margin-bottom:20px}.campaigns-page .campaigns-filters .filter-group{flex:1}.campaigns-page .campaigns-filters .filter-group label{display:block;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#8899a6;margin-bottom:6px}.campaigns-page .campaigns-filters .filter-group label i{margin-right:4px}.campaigns-page .campaigns-filters .filter-group .form-control{width:100%;padding:10px 14px;border:1px solid #e2e8f0;border-radius:8px;font-size:14px;color:#2d3748;background:#fff;transition:border-color .2s;appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238899A6' d='M6 8L1 3h10z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;padding-right:32px}.campaigns-page .campaigns-filters .filter-group .form-control:focus{outline:none;border-color:#6690f4;box-shadow:0 0 0 3px rgba(102,144,244,.1)}.campaigns-page .campaigns-filters .filter-group .filter-with-action{display:flex;gap:8px}.campaigns-page .campaigns-filters .filter-group .filter-with-action .form-control{flex:1}.campaigns-page .campaigns-filters .filter-group .filter-with-action .btn-icon{flex-shrink:0;width:42px;height:42px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;border:none;cursor:pointer;font-size:14px;transition:all .2s}.campaigns-page .campaigns-filters .filter-group .filter-with-action .btn-icon.btn-icon-delete{background:#fff5f5;color:#c00}.campaigns-page .campaigns-filters .filter-group .filter-with-action .btn-icon.btn-icon-delete:hover{background:#c00;color:#fff}.campaigns-page .campaigns-chart-wrap{background:#fff;border-radius:10px;box-shadow:0 1px 4px rgba(0,0,0,.06);padding:20px;margin-bottom:20px;min-height:350px}.campaigns-page .campaigns-table-wrap{background:#fff;border-radius:10px;box-shadow:0 1px 4px rgba(0,0,0,.06);overflow:hidden}.campaigns-page .campaigns-table-wrap .table{margin:0;width:100% !important}.campaigns-page .campaigns-table-wrap .table thead th{background:#f8fafc;border-bottom:2px solid #e2e8f0;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#8899a6;padding:12px 16px;white-space:nowrap}.campaigns-page .campaigns-table-wrap .table tbody td{padding:10px 16px;vertical-align:middle;border-bottom:1px solid #f1f5f9;font-size:13px}.campaigns-page .campaigns-table-wrap .table tbody tr:hover{background:#f8fafc}.campaigns-page .campaigns-table-wrap .dt-layout-row{padding:14px 20px;margin:0 !important;border-top:1px solid #f1f5f9}.campaigns-page .campaigns-table-wrap .dt-layout-row:first-child{display:none}.campaigns-page .campaigns-table-wrap .dt-info{font-size:13px;color:#8899a6}.campaigns-page .campaigns-table-wrap .dt-paging .pagination{margin:0;padding:0;list-style:none;display:flex;align-items:center;gap:6px}.campaigns-page .campaigns-table-wrap .dt-paging .pagination .page-item .page-link{display:inline-flex;align-items:center;justify-content:center;min-width:36px;width:fit-content;height:36px;padding:0 14px;border-radius:8px;font-size:13px;font-weight:500;border:1px solid #e2e8f0;background:#fff;color:#4e5e6a;cursor:pointer;transition:all .2s;text-decoration:none;line-height:1;white-space:nowrap}.campaigns-page .campaigns-table-wrap .dt-paging .pagination .page-item .page-link:hover{background:#eef2ff;color:#6690f4;border-color:#6690f4}.campaigns-page .campaigns-table-wrap .dt-paging .pagination .page-item.active .page-link{background:#6690f4;color:#fff;border-color:#6690f4;font-weight:600}.campaigns-page .campaigns-table-wrap .dt-paging .pagination .page-item.disabled .page-link{opacity:.35;cursor:default;pointer-events:none}.campaigns-page .campaigns-table-wrap .dt-processing{background:hsla(0,0%,100%,.9);color:#4e5e6a;font-size:14px}.campaigns-page .delete-history-entry{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:6px;border:none;cursor:pointer;font-size:12px;background:#fff5f5;color:#c00;transition:all .2s}.campaigns-page .delete-history-entry:hover{background:#c00;color:#fff}.products-page .products-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.products-page .products-header h2{margin:0;font-size:20px;font-weight:600;color:#2d3748}.products-page .products-header h2 i{color:#6690f4;margin-right:8px}.products-page .products-filters{display:flex;flex-wrap:wrap;align-items:flex-end;gap:20px;margin-bottom:16px}.products-page .products-filters .filter-group{flex:1 1 220px;min-width:0}.products-page .products-filters .filter-group label{display:block;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#8899a6;margin-bottom:6px}.products-page .products-filters .filter-group label i{margin-right:4px}.products-page .products-filters .filter-group .form-control{width:100%;padding:10px 14px;border:1px solid #e2e8f0;border-radius:8px;font-size:14px;color:#2d3748;background:#fff;transition:border-color .2s}.products-page .products-filters .filter-group .form-control:focus{outline:none;border-color:#6690f4;box-shadow:0 0 0 3px rgba(102,144,244,.1)}.products-page .products-filters .filter-group select.form-control{appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238899A6' d='M6 8L1 3h10z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;padding-right:32px}.products-page .products-filters .filter-group.filter-group-client,.products-page .products-filters .filter-group.filter-group-campaign,.products-page .products-filters .filter-group.filter-group-ad-group{flex:1 1 260px}.products-page .products-filters .filter-group.filter-group-roas{flex:0 0 200px}.products-page .products-filters .filter-group.filter-group-columns{flex:0 0 240px}.products-page .products-actions{margin-bottom:12px}.products-page .products-actions .btn-danger{padding:7px 14px;font-size:13px;border-radius:6px;border:none;cursor:pointer;transition:all .2s}.products-page .products-actions .btn-danger:disabled{opacity:.4;cursor:default}.products-page .products-table-wrap{background:#fff;border-radius:10px;box-shadow:0 1px 4px rgba(0,0,0,.06);overflow:hidden}.products-page .products-table-wrap .table{margin:0;width:100% !important}.products-page .products-table-wrap .table thead th{background:#f8fafc;border-bottom:2px solid #e2e8f0;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.3px;color:#8899a6;padding:10px 8px;white-space:nowrap}.products-page .products-table-wrap .table tbody td{padding:6px 8px;vertical-align:middle;border-bottom:1px solid #f1f5f9;font-size:12px}.products-page .products-table-wrap .table tbody tr:hover{background:#f8fafc}.products-page .products-table-wrap .table input.min_roas,.products-page .products-table-wrap .table input.form-control-sm,.products-page .products-table-wrap .table select.custom_label_4,.products-page .products-table-wrap .table select.form-control-sm{padding:3px 6px;font-size:12px;border:1px solid #e2e8f0;border-radius:4px;background:#fff}.products-page .products-table-wrap .dt-layout-row{padding:14px 20px;margin:0 !important;border-top:1px solid #f1f5f9}.products-page .products-table-wrap .dt-layout-row:first-child{display:none}.products-page .products-table-wrap .dt-info{font-size:13px;color:#8899a6}.products-page .products-table-wrap .dt-paging .pagination{margin:0;padding:0;list-style:none;display:flex;align-items:center;gap:6px}.products-page .products-table-wrap .dt-paging .pagination .page-item .page-link{display:inline-flex;align-items:center;justify-content:center;min-width:36px;height:36px;padding:0 14px;border-radius:8px;font-size:13px;font-weight:500;border:1px solid #e2e8f0;background:#fff;color:#4e5e6a;cursor:pointer;transition:all .2s;text-decoration:none;line-height:1;white-space:nowrap}.products-page .products-table-wrap .dt-paging .pagination .page-item .page-link:hover{background:#eef2ff;color:#6690f4;border-color:#6690f4}.products-page .products-table-wrap .dt-paging .pagination .page-item.active .page-link{background:#6690f4;color:#fff;border-color:#6690f4;font-weight:600}.products-page .products-table-wrap .dt-paging .pagination .page-item.disabled .page-link{opacity:.35;cursor:default;pointer-events:none}.products-page .products-table-wrap .dt-processing{background:hsla(0,0%,100%,.9);color:#4e5e6a;font-size:14px}.products-page .delete-product{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;border:none;cursor:pointer;font-size:12px;background:#fff5f5;color:#c00;transition:all .2s}.products-page .delete-product:hover{background:#c00;color:#fff}.products-page .edit-product-title{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;border:none;cursor:pointer;font-size:12px;background:#eef2ff;color:#6690f4;transition:all .2s}.products-page .edit-product-title:hover{background:#6690f4;color:#fff}.desc-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px}.desc-header label{margin:0}.desc-tabs{display:flex;gap:2px;background:#eee;border-radius:6px;padding:2px}.desc-tab{border:none;background:rgba(0,0,0,0);padding:4px 12px;font-size:12px;border-radius:4px;cursor:pointer;color:#666;transition:all .15s ease}.desc-tab i{margin-right:4px}.desc-tab.active{background:#fff;color:#333;box-shadow:0 1px 3px rgba(0,0,0,.12);font-weight:500}.desc-tab:hover:not(.active){color:#333}.desc-wrap{flex:1;min-width:0}.desc-preview ul,.desc-preview ol{margin:6px 0;padding-left:20px}.desc-preview li{margin-bottom:3px}.desc-preview b,.desc-preview strong{font-weight:600}.input-with-ai{display:flex;gap:8px;align-items:flex-start}.input-with-ai .form-control{flex:1}.btn-ai-suggest{display:inline-flex;align-items:center;gap:4px;padding:6px 12px;border-radius:8px;border:1px solid #c084fc;background:linear-gradient(135deg, #F3E8FF, #EDE9FE);color:#7c3aed;font-size:12px;font-weight:600;cursor:pointer;transition:all .2s;white-space:nowrap;min-height:38px}.btn-ai-suggest i{font-size:13px}.btn-ai-suggest:hover{background:linear-gradient(135deg, #7C3AED, #6D28D9);color:#fff;border-color:#6d28d9}.btn-ai-suggest:disabled{opacity:.7;cursor:wait}.btn-ai-suggest.btn-ai-claude{border-color:#d97706;background:linear-gradient(135deg, #FEF3C7, #FDE68A);color:#92400e}.btn-ai-suggest.btn-ai-claude:hover{background:linear-gradient(135deg, #D97706, #B45309);color:#fff;border-color:#b45309}.form_container{background:#fff;padding:25px;max-width:1300px;border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,.06)}.form_container.full{max-width:100%}.form_container .form_group{margin-bottom:12px;display:flex}.form_container .form_group>.label{width:300px;display:inline-flex;align-items:flex-start;justify-content:right;padding-right:12px}.form_container .form_group .input{width:calc(100% - 300px)}.default_popup{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.45);display:none;z-index:2000}.default_popup .popup_content{position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);background:#fff;padding:25px;border-radius:10px;max-width:1140px;width:95%;box-shadow:0 20px 60px rgba(0,0,0,.15)}.default_popup .popup_content .popup_header{display:flex;justify-content:space-between;align-items:center;margin-bottom:15px}.default_popup .popup_content .popup_header .title{font-size:18px;font-weight:600}.default_popup .popup_content .close{cursor:pointer;color:#a0aec0;font-size:18px;padding:4px}.default_popup .popup_content .close:hover{color:#c00}.dt-layout-table{margin-bottom:20px}.pagination button{border:1px solid #e2e8f0;background:#fff;display:inline-flex;height:32px;width:32px;align-items:center;justify-content:center;margin:0 2px;border-radius:4px;transition:all .2s;cursor:pointer}.pagination button:hover{background:#f4f6f9;border-color:#6690f4}table#products a{color:inherit;text-decoration:none}table#products .table-product-title{display:flex;justify-content:space-between}table#products .edit-product-title{display:flex;height:25px;align-items:center;justify-content:center;width:25px;cursor:pointer;background:#fff;border:1px solid #cbd5e0;color:#cbd5e0;border-radius:4px}table#products .edit-product-title:hover{background:#cbd5e0;color:#fff}table#products a.custom_name{color:#57b951 !important}.product-history-page .product-history-meta{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px}.product-history-page .product-history-meta span{display:inline-flex;align-items:center;padding:5px 10px;border-radius:999px;font-size:12px;font-weight:600;color:#4e5e6a;background:#eef2ff;border:1px solid #d9e2ff}.product-history-page .product-history-chart-wrap{background:#fff;border-radius:10px;box-shadow:0 1px 4px rgba(0,0,0,.06);padding:20px;margin-bottom:16px}.product-history-page .chart-with-form{display:flex;gap:20px;align-items:flex-start}.product-history-page .chart-area{flex:1 1 auto;min-width:0}.product-history-page .product-history-chart{min-height:360px}.product-history-page .comment-form{width:340px;flex:0 0 340px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;padding:14px}.product-history-page .comment-form .form-group{margin-bottom:12px}.product-history-page .comment-form label{display:block;font-weight:600;margin-bottom:6px;font-size:13px;color:#52606d}.product-history-page .comment-form input[type=date],.product-history-page .comment-form textarea{width:100%;border:1px solid #e2e8f0;border-radius:6px;padding:8px 12px;font-size:14px;font-family:"Roboto",sans-serif;background:#fff}.product-history-page .comment-form input[type=date]:focus,.product-history-page .comment-form textarea:focus{outline:none;border-color:#6690f4;box-shadow:0 0 0 3px rgba(102,144,244,.1)}.product-history-page .comment-form textarea{min-height:110px;resize:vertical}.product-history-page .comment-form .btn{width:100%;justify-content:center;padding:10px 16px}.product-history-page .comment-form .btn[disabled]{opacity:.6;cursor:not-allowed}.product-history-page .products-table-wrap{overflow-x:auto}.product-history-page .products-table-wrap .table{min-width:980px}.product-history-page .products-table-wrap .comment-cell{display:flex;align-items:center;justify-content:space-between;gap:10px}.product-history-page .products-table-wrap .comment-text{word-break:break-word}.product-history-page .products-table-wrap .delete-comment{color:#c00;text-decoration:none;font-weight:600;white-space:nowrap}.product-history-page .products-table-wrap .delete-comment:hover{text-decoration:underline}.product-history-page .products-table-wrap .dt-paging .pagination .page-item{list-style:none}.cron-status-overview{display:flex;flex-wrap:wrap;gap:10px 20px;margin-bottom:20px;color:#4e5e6a;font-size:13px}.cron-progress-list{margin-bottom:20px}.cron-progress-item{margin-bottom:14px}.cron-progress-item:last-child{margin-bottom:0}.cron-progress-item .cron-progress-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:6px;font-size:13px}.cron-progress-item .cron-progress-head strong{color:#2d3748;font-weight:600}.cron-progress-item .cron-progress-head span{color:#6b7a89;font-size:12px;font-weight:600;white-space:nowrap}.cron-progress-item small{display:block;margin-top:5px;color:#789;font-size:12px}.cron-progress-bar{width:100%;height:10px;border-radius:999px;background:#e9eef5;overflow:hidden}.cron-progress-bar>span{display:block;height:100%;background:linear-gradient(90deg, #5A9BFF 0%, #2E6BDF 100%)}.cron-url-list{margin-bottom:20px}.cron-url-item{border:1px solid #e2e8f0;border-radius:8px;background:#f8fafc;padding:10px 12px;margin-bottom:10px}.cron-url-item:last-child{margin-bottom:0}.cron-url-item .cron-url-top{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:6px}.cron-url-item .cron-url-top strong{color:#2d3748;font-size:13px;font-weight:600}.cron-url-item .cron-url-top small{color:#7a8794;font-size:11px;white-space:nowrap}.cron-url-item code{display:block;background:#eef2f7;border:1px solid #dde4ed;border-radius:6px;padding:6px 8px;color:#2e3b49;font-size:12px;overflow-x:auto}@media(max-width: 1200px){.product-history-page .chart-with-form{flex-direction:column}.product-history-page .comment-form{width:100%;flex:1 1 auto}}.jconfirm-box .form-group .select2-container,.adspro-dialog-box .form-group .select2-container{width:100% !important;margin-top:8px}.jconfirm-box .select2-container--default .select2-selection--single,.adspro-dialog-box .select2-container--default .select2-selection--single{background-color:#fff;border:1px solid #e2e8f0;border-radius:6px;min-height:42px;display:flex;align-items:center;padding:4px 12px;box-shadow:none;transition:border-color .2s,box-shadow .2s;font-size:14px}.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__rendered,.adspro-dialog-box .select2-container--default .select2-selection--single .select2-selection__rendered{padding-left:0;line-height:1.4;color:#495057}.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__placeholder,.adspro-dialog-box .select2-container--default .select2-selection--single .select2-selection__placeholder{color:#cbd5e0}.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__arrow,.adspro-dialog-box .select2-container--default .select2-selection--single .select2-selection__arrow{height:100%;right:8px}.jconfirm-box .select2-container--default.select2-container--focus .select2-selection--single,.jconfirm-box .select2-container--default .select2-selection--single:hover,.adspro-dialog-box .select2-container--default.select2-container--focus .select2-selection--single,.adspro-dialog-box .select2-container--default .select2-selection--single:hover{border-color:#6690f4;box-shadow:0 0 0 3px rgba(102,144,244,.1);outline:0}.jconfirm-box .select2-container .select2-dropdown,.adspro-dialog-box .select2-container .select2-dropdown{border-color:#e2e8f0;border-radius:0 0 6px 6px;font-size:14px}.jconfirm-box .select2-container .select2-search--dropdown .select2-search__field,.adspro-dialog-box .select2-container .select2-search--dropdown .select2-search__field{padding:6px 10px;border-radius:4px;border:1px solid #e2e8f0;font-size:14px}.jconfirm-box .select2-container--default .select2-results__option--highlighted[aria-selected],.adspro-dialog-box .select2-container--default .select2-results__option--highlighted[aria-selected]{background-color:#6690f4;color:#fff}@media(max-width: 992px){.sidebar{transform:translateX(-100%)}.sidebar.mobile-open{transform:translateX(0)}.main-wrapper{margin-left:0 !important}}.campaign-terms-wrap{display:flex;flex-direction:column;gap:20px;margin-top:20px}.campaign-terms-page{max-width:100%;overflow:hidden}.campaign-terms-page .campaigns-filters{flex-wrap:wrap}.campaign-terms-page .campaigns-filters .filter-group{min-width:220px}.campaign-terms-page .campaigns-filters .filter-group.terms-columns-group{min-width:280px}.campaign-terms-page .terms-card-toggle{margin-left:auto;width:28px;height:28px;border:1px solid #e2e8f0;border-radius:6px;background:#fff;color:#475569;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;transition:all .2s}.campaign-terms-page .terms-card-toggle:hover{background:#f8fafc;border-color:#cbd5e1}.campaign-terms-page .terms-adgroups-card.is-collapsed .campaigns-extra-table-wrap{display:none}.campaign-terms-page .terms-search-toolbar{display:flex;align-items:center;gap:10px;padding:10px 12px;border-bottom:1px solid #eef2f7;background:#fff}.campaign-terms-page .terms-search-toolbar label{font-size:12px;font-weight:600;color:#475569;display:inline-flex;align-items:center;gap:6px;margin:0;white-space:nowrap}.campaign-terms-page .terms-search-toolbar .terms-search-toolbar-label{min-width:86px}.campaign-terms-page .terms-search-toolbar #terms_min_clicks_all,.campaign-terms-page .terms-search-toolbar #terms_max_clicks_all{width:160px;height:32px}.campaign-terms-page .terms-search-toolbar #terms_min_conversions_all,.campaign-terms-page .terms-search-toolbar #terms_max_conversions_all{width:130px;max-width:130px}.campaign-terms-page .terms-search-selected-label{margin:0;font-size:12px;color:#475569;font-weight:600;white-space:nowrap}.campaign-terms-page .terms-ai-analyze-btn{margin-left:auto;display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 12px;border-radius:6px;border:1px solid #bfdbfe;background:#eff6ff;color:#1d4ed8;font-size:12px;font-weight:600;cursor:pointer;transition:all .2s}.campaign-terms-page .terms-ai-analyze-btn:hover{background:#dbeafe;border-color:#93c5fd}.campaign-terms-page .terms-ai-analyze-btn:disabled{opacity:.6;cursor:wait}.campaign-terms-page .terms-negative-toolbar{display:flex;align-items:center;gap:10px;padding:10px 12px;border-bottom:1px solid #eef2f7;background:#fff}.campaign-terms-page .terms-negative-bulk-btn{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 12px;border-radius:6px;border:1px solid #fecaca;background:#fef2f2;color:#dc2626;font-size:12px;font-weight:600;cursor:pointer;transition:all .2s}.campaign-terms-page .terms-negative-bulk-btn:hover{background:#fee2e2;border-color:#fca5a5}.campaign-terms-page .terms-negative-bulk-btn:disabled{opacity:.5;cursor:not-allowed}.campaign-terms-page table.campaigns-extra-table>thead>tr>th{position:sticky;top:0;z-index:2;background-color:#111827 !important;color:#e5e7eb !important;border-bottom:1px solid #0b1220 !important;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;padding:10px 12px;white-space:nowrap}.campaign-terms-page #terms_search_table thead th .dt-column-order,.campaign-terms-page #terms_negative_table thead th .dt-column-order{display:none !important}.campaign-terms-page #terms_search_table thead th.dt-orderable-asc,.campaign-terms-page #terms_search_table thead th.dt-orderable-desc,.campaign-terms-page #terms_negative_table thead th.dt-orderable-asc,.campaign-terms-page #terms_negative_table thead th.dt-orderable-desc{cursor:pointer;padding-right:34px;overflow:hidden}.campaign-terms-page #terms_search_table thead th .dt-column-title,.campaign-terms-page #terms_negative_table thead th .dt-column-title{display:block;overflow:hidden;text-overflow:ellipsis;padding-right:2px}.campaign-terms-page #terms_search_table thead th.dt-orderable-asc::after,.campaign-terms-page #terms_search_table thead th.dt-orderable-desc::after,.campaign-terms-page #terms_negative_table thead th.dt-orderable-asc::after,.campaign-terms-page #terms_negative_table thead th.dt-orderable-desc::after{content:"↕";position:absolute;right:10px;top:50%;transform:translateY(-50%);width:16px;height:16px;border-radius:999px;font-size:12px;font-weight:700;line-height:16px;text-align:center;color:#e5e7eb;background:#374151}.campaign-terms-page #terms_search_table thead th.dt-ordering-asc::after,.campaign-terms-page #terms_negative_table thead th.dt-ordering-asc::after,.campaign-terms-page #terms_search_table thead th[aria-sort=ascending]::after,.campaign-terms-page #terms_negative_table thead th[aria-sort=ascending]::after{content:"▲";color:#fff;background:#2563eb}.campaign-terms-page #terms_search_table thead th.dt-ordering-desc::after,.campaign-terms-page #terms_negative_table thead th.dt-ordering-desc::after,.campaign-terms-page #terms_search_table thead th[aria-sort=descending]::after,.campaign-terms-page #terms_negative_table thead th[aria-sort=descending]::after{content:"▼";color:#fff;background:#2563eb}.campaign-terms-page #terms_negative_select_all,.campaign-terms-page .terms-negative-select-row,.campaign-terms-page #terms_search_select_all,.campaign-terms-page .terms-search-select-row{width:14px;height:14px;cursor:pointer}.campaign-terms-page .dt-layout-row:first-child{display:none}.campaign-terms-page .dt-layout-row{padding:10px 12px;margin:0 !important;border-top:1px solid #f1f5f9}.campaign-terms-page .dt-info{font-size:12px;color:#64748b}.campaign-terms-page .dt-paging .pagination{margin:0;padding:0;list-style:none !important;display:flex;align-items:center;gap:6px}.campaign-terms-page .dt-paging .pagination .page-item{list-style:none !important}.campaign-terms-page .dt-paging .pagination .page-item .page-link{display:inline-flex;align-items:center;justify-content:center;min-width:36px;width:fit-content;height:32px;padding:0 12px;border-radius:6px;font-size:12px;font-weight:500;border:1px solid #e2e8f0;background:#fff;color:#4e5e6a;text-decoration:none;line-height:1;white-space:nowrap}.campaign-terms-page .dt-paging .pagination .page-item .page-link:hover{background:#eef2ff;color:#6690f4;border-color:#6690f4}.campaign-terms-page .dt-paging .pagination .page-item.previous .page-link,.campaign-terms-page .dt-paging .pagination .page-item.next .page-link{min-width:72px}.campaign-terms-page .dt-paging .pagination .page-item.active .page-link{background:#6690f4;color:#fff;border-color:#6690f4}.campaign-terms-page .dt-paging .pagination .page-item.disabled .page-link{opacity:.35;cursor:default;pointer-events:none}.terms-columns-box{display:flex;flex-direction:column;gap:6px}.terms-columns-control{border:1px solid #e2e8f0;border-radius:6px;background:#fff;overflow:hidden}.terms-columns-control summary{cursor:pointer;padding:8px 10px;font-size:12px;font-weight:600;color:#334155;list-style:none}.terms-columns-control summary::-webkit-details-marker{display:none}.terms-columns-control summary::after{content:"▼";float:right;font-size:10px;color:#64748b;margin-top:2px}.terms-columns-control[open] summary::after{content:"▲"}.terms-columns-list{border-top:1px solid #eef2f7;padding:8px 10px;max-height:180px;overflow-y:auto}.terms-columns-list .terms-col-item{display:flex;align-items:center;gap:8px;font-size:12px;color:#334155;margin-bottom:6px}.terms-columns-list .terms-col-item:last-child{margin-bottom:0}.terms-columns-list .terms-col-item input[type=checkbox]{margin:0}.campaigns-extra-card{background:#fff;border-radius:10px;box-shadow:0 1px 4px rgba(0,0,0,.06);overflow:hidden}.campaigns-extra-card-title{padding:14px 16px;border-bottom:1px solid #e2e8f0;font-size:13px;font-weight:700;color:#334155;display:flex;align-items:center;gap:8px}.campaigns-extra-card-title .terms-card-title-label{display:inline-flex;align-items:center;gap:8px}.campaigns-extra-table-wrap{overflow:auto}.campaigns-extra-table{margin:0;width:100%;table-layout:fixed}.campaigns-extra-table tbody td{padding:9px 12px;border-bottom:1px solid #f1f5f9;font-size:13px;color:#334155;vertical-align:middle;white-space:nowrap}.campaigns-extra-table td.num-cell{text-align:right;white-space:nowrap}.campaigns-extra-table td.text-cell{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.campaigns-extra-table th.terms-negative-select-cell,.campaigns-extra-table td.terms-negative-select-cell,.campaigns-extra-table th.terms-search-select-cell,.campaigns-extra-table td.terms-search-select-cell{text-align:center}.campaigns-extra-table th.phrase-nowrap,.campaigns-extra-table td.phrase-nowrap{white-space:nowrap !important;overflow:hidden;text-overflow:ellipsis}.campaigns-extra-table .terms-add-negative-btn,.campaigns-extra-table .terms-remove-negative-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;cursor:pointer;transition:all .2s}.campaigns-extra-table .terms-add-negative-btn{border:1px solid #e2e8f0;background:#eef2ff;color:#3b82f6}.campaigns-extra-table .terms-add-negative-btn:hover{background:#3b82f6;color:#fff;border-color:#3b82f6}.campaigns-extra-table .terms-remove-negative-btn{border:1px solid #fecaca;background:#fef2f2;color:#dc2626}.campaigns-extra-table .terms-remove-negative-btn:hover{background:#dc2626;color:#fff;border-color:#dc2626}.campaigns-extra-table tbody tr:hover{background:#f8fafc}.campaigns-extra-table tbody tr.term-is-negative td{color:#dc2626}.campaigns-extra-table tbody tr.term-is-negative:hover{background:#fef2f2}.campaigns-empty-row{text-align:center;color:#94a3b8 !important;font-style:italic}.terms-ai-modal-toolbar{display:flex;align-items:center;gap:10px;margin-bottom:10px}.terms-ai-modal-toolbar label{font-size:12px;font-weight:600;color:#334155;margin:0}.terms-ai-modal-toolbar .form-control{width:200px;height:32px}.terms-ai-summary{font-size:12px;color:#64748b;margin-bottom:10px}.terms-ai-results-wrap{border:1px solid #e2e8f0;border-radius:8px;max-height:420px;overflow:auto}.terms-ai-results-table{width:100%;border-collapse:collapse;font-size:12px}.terms-ai-results-table th,.terms-ai-results-table td{border-bottom:1px solid #eef2f7;padding:8px;vertical-align:middle}.terms-ai-results-table th{position:sticky;top:0;background:#f8fafc;color:#334155;font-weight:700}.terms-ai-results-table td.term-col{min-width:260px;max-width:380px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.terms-ai-results-table td.reason-col{min-width:320px}.terms-ai-action-badge{display:inline-flex;align-items:center;justify-content:center;border-radius:999px;padding:2px 8px;font-size:11px;font-weight:700}.terms-ai-action-badge.action-exclude{background:#fee2e2;color:#b91c1c}.terms-ai-action-badge.action-keep{background:#dcfce7;color:#166534}.products-page .products-filters .filter-group.filter-group-columns{min-width:240px}.products-columns-control{border:1px solid #e2e8f0;border-radius:6px;background:#fff;overflow:hidden}.products-columns-control summary{cursor:pointer;padding:8px 10px;font-size:12px;font-weight:600;color:#334155;list-style:none}.products-columns-control summary::-webkit-details-marker{display:none}.products-columns-control summary::after{content:"▼";float:right;font-size:10px;color:#64748b;margin-top:2px}.products-columns-control[open] summary::after{content:"▲"}.products-columns-list{border-top:1px solid #eef2f7;padding:8px 10px;max-height:220px;overflow-y:auto}.products-columns-list .products-col-item{display:flex;align-items:center;gap:8px;font-size:12px;color:#334155;margin-bottom:6px}.products-columns-list .products-col-item:last-child{margin-bottom:0}.products-columns-list .products-col-item input[type=checkbox]{margin:0}#products th:last-child,#products td:last-child{white-space:nowrap}#products .products-row-actions{display:inline-flex;align-items:center;gap:4px}#products .products-row-actions .btn{width:38px;height:32px;padding:0;display:inline-flex;align-items:center;justify-content:center;border-radius:4px !important}#products .products-row-actions .btn i{line-height:1}.products-page table#products>thead>tr>th{position:sticky;top:0;z-index:2;background-color:#111827 !important;color:#e5e7eb !important;border-bottom:1px solid #0b1220 !important;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;padding:10px 12px;white-space:nowrap}.products-page #products thead th .dt-column-order{display:none !important}.products-page #products thead th.dt-orderable-asc,.products-page #products thead th.dt-orderable-desc{cursor:pointer;padding-right:34px;overflow:hidden}.products-page #products thead th .dt-column-title{display:block;overflow:hidden;text-overflow:ellipsis;padding-right:2px}.products-page #products thead th.dt-orderable-asc::after,.products-page #products thead th.dt-orderable-desc::after{content:"↕";position:absolute;right:10px;top:50%;transform:translateY(-50%);width:16px;height:16px;border-radius:999px;font-size:12px;font-weight:700;line-height:16px;text-align:center;color:#e5e7eb;background:#374151}.products-page #products thead th.dt-ordering-asc::after,.products-page #products thead th[aria-sort=ascending]::after{content:"▲";color:#fff;background:#2563eb}.products-page #products thead th.dt-ordering-desc::after,.products-page #products thead th[aria-sort=descending]::after{content:"▼";color:#fff;background:#2563eb}/*# sourceMappingURL=style.css.map */ diff --git a/layout/style.css.map b/layout/style.css.map index 6e9e74d..aef6f1e 100644 --- a/layout/style.css.map +++ b/layout/style.css.map @@ -1 +1 @@ -{"version":3,"sourceRoot":"","sources":["style.scss"],"names":[],"mappings":"AA4BA;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA,OAzBM;EA0BN,YA5BW;;;AA+Bb;EACE;;;AAIF;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAMF;EACE,YAxDW;EAyDX;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA,OAzFK;EA0FL;;AAGF;EACE;EACA;EACA;EACA;;AAEA;EACE;;AAIJ;EACE;EACA;EACA;EACA;;AAIA;EACE;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;;;AAMR;EACE;EACA;EACA;EACA;EACA;EACA,YA/IO;;;AAkJT;EACE;EACA;;AAEA;EACE;;AAEA;EACE;EACA;EACA,OA1JM;EA2JN;;AAGF;EACE;EACA;EACA;;AAIJ;EACE;;AAEA;EACE;EACA;EACA;EACA,OA5KM;EA6KN;;AAIJ;EACE;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;;AAIJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA,OA1MQ;EA2MR;;AAEA;EACE;;AAGF;EACE,cA3NK;EA4NL;EACA;;AAIJ;EACE,OArNM;EAsNN;EACA;;AAIA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA,cApPG;;AAyPT;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;;AAIJ;EACE;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA,OAtQI;EAuQJ;;AAGF;EACE;EACA;EACA;;;AAMN;EACE;IACE;;EAGF;IACE;;;AAOJ;EACE;EACA;EACA,YA1SW;;;AA8Sb;EACE,OAnSa;EAoSb;EACA,YArTW;EAsTX;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE,OA/Se;;AAiTf;EACE;EACA;;AAEA;EACE;;AAGF;EACE;;AAIJ;EACE;EACA;;AAEA;EACE;;AAGF;EACE;EACA;;AAKF;EACE;;AAEA;EACE;;AAIJ;EACE;;AAEA;EACE;;AAKN;EACE;;;AAKN;EACE;EACA;EACA;EACA;EACA;;AAEA;EACE,OAxXK;EAyXL;EACA;EACA;EACA;;AAEA;EACE;;AAIJ;EACE;EACA;EACA,OA1YW;EA2YX;EACA;EACA;EACA;;AAEA;EACE;EACA,OA9YG;;AAiZL;EACE;;;AAKN;EACE;EACA;EACA;;AAEA;EACE;EACA;EACA;;AAGE;EACE;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA,OAhbO;EAibP;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;;AAGF;EACE,YA7bM;EA8bN,OA3bD;;AA+bH;EACE;EACA,OAjcC;EAkcD,mBAzcG;;AA2cH;EACE,OA5cC;;;AAmdX;EACE;EACA;;AAEA;EACE;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA,OAreK;EAseL;EACA;;AAGF;EACE;;AAEA;EACE,OA3eO;EA4eP;EACA;EACA;EACA;EACA;;AAKN;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;;AAGF;EACE;;;AAMN;EACE,aA7fa;EA8fb;EACA;EACA;EACA;EACA;;AAEA;EACE,aApgBe;;;AAygBnB;EACE,QAzgBa;EA0gBb,YAvhBO;EAwhBP;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA,OAliBI;EAmiBJ;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE,YA7iBO;;AAijBX;EACE;EACA;EACA,OAjjBQ;;;AAsjBZ;EACE;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAQF;EACE;EACA;EACA,OA/kBO;EAglBP;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EAGE;EACA;;AAEA;EACE;;AAIJ;EACE,YApmBO;;AAsmBP;EACE,YAtmBS;;AA0mBb;EACE,YAvnBO;;AAynBP;EACE,YAznBS;;AA6nBb;EACE,YAlnBM;;AAonBN;EACE,YApnBQ;;AAwnBZ;EACE;EACA;;;AAKJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA,OA5oBU;EA6oBV;;AAEA;EACE;;AAGF;EACE,cA7pBO;EA8pBP;EACA;;;AAIJ;EACE;;;AAIF;EACE;EACA;;;AAGF;EACE;;AAEA;AAAA;EAEE;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;;AAGF;EACE;;AAGF;EACE;;AAGF;EACE;EACA;;;AAKJ;EACE,YA5sBO;EA6sBP;EACA;EACA,OA7sBU;EA8sBV;EACA;;AAEA;EACE;;AAGF;EACE;EACA;;AAGF;EACE;;AAIE;AAAA;EAEE;;AAEA;AAAA;EACE;;AAGF;AAAA;EACE;;AAGF;AAAA;EACE;;;AAQV;EACE;EACA;EACA;;AAEA;EACE;;AAEA;EACE,YA3vBK;;AA6vBL;EACE,YA7vBO;;AAiwBX;EACE,YAjwBI;;AAmwBJ;EACE,YAnwBM;;;AA0wBd;EACE,YAlxBO;EAmxBP;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;EACA,OA3yBK;EA4yBL;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA,OA9yBM;;AAizBR;EACE;EACA;;AAIJ;EACE;;AAEA;EACE;EACA;EACA;EACA,OA9zBM;EA+zBN;;AAIJ;EACE;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE,OA32BG;;AAg3BT;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAIJ;EACE;;AAEA;EACE;;AAKN;EACE;EACA;EACA;;AAEA;EALF;IAMI;;;AAIJ;EACE;EACA;EACA;EACA;EACA,OA95BM;EA+5BN;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;;;AAOJ;EACE;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA,OA57BM;;AA87BN;EACE,OAx8BG;EAy8BH;;AAKN;EACE,YAx8BK;EAy8BL;EACA;EACA;;AAEA;EACE;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;;AAGF;EACE;;AAGF;EACE;EACA;EACA;;AAGF;EACE;EACA,OA3+BI;;AAg/BV;EACE;EACA;EACA,OA5/BO;EA6/BP;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA,OAxhCK;;AA0hCL;EACE,YA3hCG;EA4hCH,OArhCC;;AAyhCL;EACE;EACA,OArhCI;;AAuhCJ;EACE,YAxhCE;EAyhCF,OA/hCC;;AAoiCP;EACE;EACA;EACA;;AAEA;EACE;EACA;EACA;;AAGF;EACE;EACA;;;AAKN;EACE;EACA,OAtjCU;EAujCV;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;;;AAQF;EACE;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA,OAjlCM;;AAmlCN;EACE,OA7lCG;EA8lCH;;AAKN;EACE;EACA;EACA;;AAEA;EACE;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;;AAIJ;EACE;EACA;EACA;EACA;EACA;EACA,OAtnCI;EAunCJ,YAznCC;EA0nCD;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA,cA3oCC;EA4oCD;;AAIJ;EACE;EACA;;AAEA;EACE;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA,OA1pCF;;AA4pCE;EACE,YA7pCJ;EA8pCI,OApqCL;;AA4qCP;EACE,YA7qCK;EA8qCL;EACA;EACA;EACA;EACA;;AAGF;EACE,YAtrCK;EAurCL;EACA;EACA;;AAEA;EACE;EACA;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;;AAGF;EACE;;AAKJ;EACE;EACA;EACA;;AAGA;EACE;;AAIJ;EACE;EACA;;AAIA;EACE;EACA;EACA;EACA;EACA;EACA;;AAGE;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,YA9vCH;EA+vCG,OA9vCJ;EA+vCI;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA,OA/wCH;EAgxCG,cAhxCH;;AAoxCD;EACE,YArxCD;EAsxCC,OA/wCH;EAgxCG,cAvxCD;EAwxCC;;AAGF;EACE;EACA;EACA;;AAMR;EACE;EACA,OA9xCE;EA+xCF;;AAIJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,OAzyCM;EA0yCN;;AAEA;EACE,YA7yCI;EA8yCJ,OApzCG;;;AA6zCP;EACE;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA,OAr0CM;;AAu0CN;EACE,OAj1CG;EAk1CH;;AAKN;EACE;EACA;EACA;;AAGE;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EAAI;;AAGN;EACE;EACA;EACA;EACA;EACA;EACA,OAt2CI;EAu2CJ,YAz2CC;EA02CD;;AAEA;EACE;EACA,cAr3CC;EAs3CD;;AAIJ;EACE;EACA;EACA;EACA;EACA;EACA;;AAGF;EAAwB;;AACxB;EAAsB;;AAI1B;EACE;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;;AAKN;EACE,YAp5CK;EAq5CL;EACA;EACA;;AAEA;EACE;EACA;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;;AAGF;EACE;;AAIF;AAAA;AAAA;AAAA;EAIE;EACA;EACA;EACA;EACA,YA77CC;;AAk8CL;EACE;EACA;EACA;;AAEA;EACE;;AAIJ;EACE;EACA;;AAIA;EACE;EACA;EACA;EACA;EACA;EACA;;AAGE;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,YAt+CH;EAu+CG,OAt+CJ;EAu+CI;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA,OAv/CH;EAw/CG,cAx/CH;;AA4/CD;EACE,YA7/CD;EA8/CC,OAv/CH;EAw/CG,cA//CD;EAggDC;;AAGF;EACE;EACA;EACA;;AAMR;EACE;EACA,OAtgDE;EAugDF;;AAKJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,OAlhDM;EAmhDN;;AAEA;EACE,YAthDI;EAuhDJ,OA7hDG;;AAkiDP;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,OApjDO;EAqjDP;;AAEA;EACE,YAxjDK;EAyjDL,OAljDG;;;AAwjDT;EACE;EACA;EACA;EACA;;AAEA;EAAQ;;;AAGV;EACE;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EAAI;;AAEJ;EACE;EACA;EACA;EACA;;AAGF;EACE;;;AAIJ;EACE;EACA;;;AAIA;EAAS;EAAe;;AACxB;EAAK;;AACL;EAAY;;;AAGd;EACE;EACA;EACA;;AAEA;EACE;;;AAIJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;;AAGF;EACE;EACA;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;EACA;;AAEA;EACE;EACA;EACA;;;AAMN;EACE,YApqDO;EAqqDP;EACA;EACA;EACA;;AAEA;EACE;;AAGF;EACE;EACA;;AAEA;EACE;EACA;EACA;EACA;EACA;;AAGF;EACE;;;AAMN;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;EACA,YAhtDK;EAitDL;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;;AAEA;EACE;EACA;;AAIJ;EACE;EACA;EACA;EACA;;AAEA;EACE,OApuDE;;;AA2uDV;EACE;;;AAIA;EACE;EACA,YAxvDK;EAyvDL;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE,YArwDO;EAswDP,cA5wDK;;;AAqxDT;EACE;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA,YA/xDK;EAgyDL;EACA;EACA;;AAEA;EACE;EACA,OAtyDG;;AA0yDP;EACE;;;AAKJ;EACE;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;;AAEA;EACE;;AAGF;EACE;EACA;EACA;EACA;;AAGF;AAAA;EAEE;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;;AAGF;EACE;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;;AAKJ;AAAA;EAEE;EACA;;;AAGF;AAAA;EAEE,kBAj3DO;EAk3DP;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;AAAA;EAEE;EACA;EACA;;;AAGF;AAAA;EAEE;;;AAGF;AAAA;EAEE;EACA;;;AAGF;AAAA;AAAA;AAAA;EAIE,cA15DS;EA25DT;EACA;;;AAGF;AAAA;EAEE,cAv5DQ;EAw5DR;EACA;;;AAGF;AAAA;EAEE;EACA;EACA;EACA;;;AAGF;AAAA;EAEE,kBAh7DS;EAi7DT,OA16DO;;;AAg7DT;EACE;IACE;;EAEA;IACE;;EAIJ;IACE","file":"style.css"} \ No newline at end of file +{"version":3,"sourceRoot":"","sources":["style.scss"],"names":[],"mappings":"CA4BA,EACE,sBAGF,KACE,gCACA,SACA,UACA,eACA,MAzBM,QA0BN,WA5BW,QA+Bb,MACE,aAIF,MACE,gBAGF,YACE,iBAGF,WACE,2BAGF,QACE,mBAMF,cACE,WAxDW,QAyDX,SACA,UAGF,iBACE,aACA,iBAGF,aACE,aACA,0EACA,aACA,mBACA,uBACA,aACA,kBACA,gBAEA,qBACE,WACA,kBACA,SACA,WACA,WACA,YACA,kFACA,kBAGF,4BACE,kBACA,UACA,MAzFK,KA0FL,gBAGF,yBACE,eACA,gBACA,mBACA,oBAEA,gCACE,gBAIJ,4BACE,eACA,YACA,gBACA,mBAIA,sCACE,aACA,mBACA,SACA,mBACA,WAEA,wCACE,eACA,WACA,YACA,aACA,mBACA,uBACA,8BACA,mBAGF,2CACE,eAMR,oBACE,OACA,aACA,mBACA,uBACA,aACA,WA/IO,KAkJT,WACE,WACA,gBAEA,yBACE,mBAEA,4BACE,eACA,gBACA,MA1JM,QA2JN,eAGF,2BACE,cACA,eACA,SAIJ,uBACE,mBAEA,6BACE,cACA,eACA,gBACA,MA5KM,QA6KN,kBAIJ,4BACE,kBAEA,8BACE,kBACA,UACA,QACA,2BACA,cACA,eAGF,0CACE,kBAIJ,yBACE,WACA,YACA,yBACA,kBACA,eACA,eACA,gCACA,MA1MQ,QA2MR,2CAEA,sCACE,cAGF,+BACE,aA3NK,QA4NL,2CACA,aAIJ,uBACE,MArNM,KAsNN,eACA,eAIA,2CACE,aACA,mBACA,QACA,eACA,eACA,cACA,gBAEA,gEACE,WACA,YACA,aApPG,QAyPT,sBACE,WACA,YACA,eACA,gBACA,kBACA,aACA,mBACA,uBACA,QAEA,+BACE,WACA,oBAIJ,kBACE,aACA,kBACA,kBACA,eACA,mBAEA,+BACE,mBACA,MAtQI,KAuQJ,yBAGF,gCACE,mBACA,cACA,yBAMN,yBACE,aACE,aAGF,oBACE,mBAOJ,YACE,aACA,iBACA,WA1SW,QA8Sb,SACE,MAnSa,MAoSb,iBACA,WArTW,QAsTX,eACA,MACA,OACA,aACA,aACA,sBACA,0BACA,gBAEA,mBACE,MA/Se,KAiTf,mCACE,eACA,uBAEA,iDACE,aAGF,qDACE,yBAIJ,wCACE,eACA,uBAEA,6CACE,aAGF,0CACE,eACA,eAIJ,iEACE,eACA,uBAEA,sEACE,aAGF,mEACE,eACA,eAKF,iDACE,uBAEA,4DACE,aAIJ,mDACE,uBAEA,wDACE,aAKN,gCACE,gBAKN,gBACE,aACA,mBACA,8BACA,uBACA,4CAEA,gCACE,MAtYK,KAuYL,qBACA,eACA,gBACA,sBAEA,uCACE,gBAIJ,gCACE,gBACA,YACA,MAxZW,QAyZX,eACA,YACA,kBACA,mBAEA,sCACE,+BACA,MA5ZG,KA+ZL,kCACE,yBAKN,aACE,OACA,eACA,gBAEA,gBACE,gBACA,SACA,UAGE,6BACE,kBAEA,8CACE,aACA,mBACA,kBACA,cACA,eACA,gBACA,yBACA,oBACA,oCAEA,gDACE,WACA,kBACA,kBACA,eACA,cAIJ,0CACE,SACA,UACA,gBAEA,+CACE,kBAIJ,qDACE,MAldD,KAmdC,iCACA,kBA3dC,QA6dD,uDACE,MA9dD,QAmeL,+BACE,WACA,+BACA,gBAGF,qBACE,aACA,mBACA,kBACA,MA1eO,QA2eP,qBACA,eACA,mBACA,oCAEA,uBACE,WACA,kBACA,kBACA,eAGF,2BACE,WAvfM,QAwfN,MArfD,KAyfH,4BACE,iCACA,MA3fC,KA4fD,kBAngBG,QAqgBH,8BACE,MAtgBC,QA6gBX,gBACE,kBACA,yCAEA,8BACE,aACA,mBACA,SACA,mBAEA,2CACE,WACA,YACA,kBACA,gCACA,aACA,mBACA,uBACA,MA/hBK,QAgiBL,eACA,cAGF,yCACE,gBAEA,qDACE,MAriBO,QAsiBP,eACA,cACA,mBACA,gBACA,uBAKN,gCACE,aACA,mBACA,QACA,cACA,qBACA,eACA,iBACA,kBACA,mBAEA,kCACE,eAGF,sCACE,8BAMN,cACE,YAvjBa,MAwjBb,OACA,iBACA,gCACA,aACA,sBAEA,uBACE,YA9jBe,KAmkBnB,QACE,OAnkBa,KAokBb,WAjlBO,KAklBP,gCACA,aACA,mBACA,eACA,gBACA,MACA,YAEA,uBACE,gBACA,YACA,MA5lBI,QA6lBJ,eACA,iBACA,kBACA,eACA,kBACA,mBAEA,6BACE,WAvmBO,QA2mBX,2BACE,eACA,gBACA,MA3mBQ,QAgnBZ,SACE,OACA,aAGF,WACE,mBACA,yBACA,cACA,kBACA,kBACA,mBACA,eAQF,KACE,kBACA,wBACA,MAzoBO,KA0oBP,SACA,kBACA,eACA,oBACA,qBACA,QACA,uBACA,mBACA,eACA,gCACA,gBAEA,uCAGE,iBACA,eAEA,6CACE,eAIJ,iBACE,WA9pBO,QAgqBP,uBACE,WAhqBS,QAoqBb,iBACE,WAjrBO,QAmrBP,uBACE,WAnrBS,QAurBb,gBACE,WA5qBM,KA8qBN,sBACE,WA9qBQ,QAkrBZ,cACE,WACA,oBAKJ,cACE,yBACA,kBACA,YACA,WACA,iBACA,gCACA,eACA,MAtsBU,QAusBV,2CAEA,qBACE,YAGF,oBACE,aAvtBO,QAwtBP,0CACA,aAIJ,qBACE,yBAIF,MACE,yBACA,eAGF,OACE,WAEA,oBAEE,yBACA,iBAGF,UACE,mBACA,gBACA,eACA,yBACA,qBACA,cAGF,iBACE,kBAGF,eACE,gBAGF,mBACE,uBAGF,0BACE,eACA,YAKJ,MACE,WAtwBO,KAuwBP,aACA,kBACA,MAvwBU,QAwwBV,eACA,qCAEA,WACE,mBAGF,mBACE,gBACA,eAGF,iBACE,iBAIE,oDAEE,eAEA,8DACE,gBAGF,0EACE,iBAGF,4EACE,kBAQV,aACE,aACA,mBACA,SAEA,kBACE,iBAEA,0BACE,WArzBK,QAuzBL,gCACE,WAvzBO,QA2zBX,6BACE,WA3zBI,KA6zBJ,mCACE,WA7zBM,QAo0Bd,eACE,aACA,QACA,mBAEA,6BACE,oBACA,mBACA,QACA,iBACA,kBACA,qBACA,cACA,mBACA,yBACA,eACA,gBACA,mBAEA,mCACE,MA71BM,QA81BN,mBAGF,oCACE,MAp2BG,KAq2BH,WA52BK,QA62BL,aA72BK,QAk3BX,eACE,WA52BO,KA62BP,mBACA,aACA,qCAEA,qCACE,aACA,mBACA,SACA,mBACA,oBACA,gCAEA,yDACE,WACA,YACA,mBACA,2DACA,MAr4BK,QAs4BL,aACA,mBACA,uBACA,eACA,cAGF,wCACE,SACA,eACA,gBACA,MAx4BM,QA24BR,2CACE,cACA,eAIJ,+BACE,mBAEA,qCACE,cACA,eACA,gBACA,MAx5BM,QAy5BN,kBAIJ,oCACE,kBAEA,yDACE,kBACA,UACA,QACA,2BACA,cACA,eACA,oBAGF,kDACE,kBAGF,wDACE,kBACA,UACA,QACA,2BACA,gBACA,YACA,cACA,eACA,iBACA,eACA,qBAEA,8DACE,MAr8BG,QA08BT,sDACE,oBACA,mBACA,SACA,eACA,eACA,gBACA,iBACA,gBACA,WAEA,4EACE,cACA,YACA,iBAIJ,yCACE,aAEA,iEACE,qBACA,kBACA,WACA,YACA,gBACA,mBACA,0BACA,cAEA,wEACE,WACA,kBACA,QACA,SACA,WACA,YACA,gBACA,kBACA,yBAIJ,yEACE,mBAEA,gFACE,2BAKN,qCACE,aACA,8BACA,WAEA,yBALF,qCAMI,2BAIJ,qCACE,aACA,mBACA,SACA,mBACA,MAjgCM,KAkgCN,yBACA,kBACA,kBACA,mBACA,eAEA,uCACE,eACA,cAOJ,8BACE,aACA,8BACA,mBACA,mBAEA,iCACE,SACA,eACA,gBACA,MA/hCM,QAiiCN,mCACE,MA3iCG,QA4iCH,iBAKN,kCACE,WA3iCK,KA4iCL,mBACA,qCACA,gBAEA,yCACE,SAEA,kDACE,mBACA,gCACA,eACA,gBACA,yBACA,oBACA,cACA,kBAGF,kDACE,kBACA,sBACA,gCAGF,wDACE,mBAGF,oDACE,cACA,eACA,gBAGF,sDACE,gBACA,MA9kCI,QAmlCV,wBACE,qBACA,mBACA,MA/lCO,QAgmCP,eACA,gBACA,iBACA,kBACA,sBAGF,4BACE,kBACA,mBAGF,wBACE,oBACA,mBACA,uBACA,WACA,YACA,kBACA,YACA,eACA,eACA,mBACA,aAEA,sCACE,mBACA,MA3nCK,QA6nCL,4CACE,WA9nCG,QA+nCH,MAxnCC,KA4nCL,wCACE,mBACA,MAxnCI,KA0nCJ,8CACE,WA3nCE,KA4nCF,MAloCC,KAuoCP,2BACE,kBACA,6BACA,cAEA,6BACE,eACA,mBACA,cAGF,6BACE,SACA,eAKN,eACE,mBACA,MAzpCU,QA0pCV,YACA,iBACA,kBACA,eACA,eACA,0BAEA,qBACE,mBAQF,kCACE,aACA,8BACA,mBACA,mBAEA,qCACE,SACA,eACA,gBACA,MAprCM,QAsrCN,uCACE,MAhsCG,QAisCH,iBAKN,mCACE,aACA,SACA,mBAEA,iDACE,OAEA,uDACE,cACA,eACA,gBACA,yBACA,oBACA,cACA,kBAEA,yDACE,iBAIJ,+DACE,WACA,kBACA,yBACA,kBACA,eACA,MAztCI,QA0tCJ,WA5tCC,KA6tCD,4BACA,gBACA,wBACA,0LACA,4BACA,sCACA,mBAEA,qEACE,aACA,aA9uCC,QA+uCD,0CAIJ,qEACE,aACA,QAEA,mFACE,OAGF,+EACE,cACA,WACA,YACA,oBACA,mBACA,uBACA,kBACA,YACA,eACA,eACA,mBAEA,+FACE,mBACA,MA7vCF,KA+vCE,qGACE,WAhwCJ,KAiwCI,MAvwCL,KA+wCP,sCACE,WAhxCK,KAixCL,mBACA,qCACA,aACA,mBACA,iBAGF,sCACE,WAzxCK,KA0xCL,mBACA,qCACA,gBAEA,6CACE,SACA,sBAEA,sDACE,mBACA,gCACA,eACA,gBACA,yBACA,oBACA,cACA,kBACA,mBAGF,sDACE,kBACA,sBACA,gCACA,eAGF,4DACE,mBAKJ,qDACE,kBACA,oBACA,6BAGA,iEACE,aAIJ,+CACE,eACA,cAIA,6DACE,SACA,UACA,gBACA,aACA,mBACA,QAGE,mFACE,oBACA,mBACA,uBACA,eACA,kBACA,YACA,eACA,kBACA,eACA,gBACA,yBACA,WAj2CH,KAk2CG,MAj2CJ,QAk2CI,eACA,mBACA,qBACA,cACA,mBAEA,yFACE,mBACA,MAl3CH,QAm3CG,aAn3CH,QAu3CD,0FACE,WAx3CD,QAy3CC,MAl3CH,KAm3CG,aA13CD,QA23CC,gBAGF,4FACE,YACA,eACA,oBAMR,qDACE,8BACA,MAj4CE,QAk4CF,eAIJ,sCACE,oBACA,mBACA,uBACA,WACA,YACA,kBACA,YACA,eACA,eACA,mBACA,MA54CM,KA64CN,mBAEA,4CACE,WAh5CI,KAi5CJ,MAv5CG,KAg6CP,gCACE,aACA,8BACA,mBACA,mBAEA,mCACE,SACA,eACA,gBACA,MAx6CM,QA06CN,qCACE,MAp7CG,QAq7CH,iBAKN,iCACE,aACA,eACA,qBACA,SACA,mBAEA,+CACE,eACA,YAEA,qDACE,cACA,eACA,gBACA,yBACA,oBACA,cACA,kBAEA,wEAGF,6DACE,WACA,kBACA,yBACA,kBACA,eACA,MA98CI,QA+8CJ,WAj9CC,KAk9CD,4BAEA,mEACE,aACA,aA79CC,QA89CD,0CAIJ,mEACE,gBACA,wBACA,0LACA,4BACA,sCACA,mBAGF,6MAE0B,eAC1B,gFACA,mFAIJ,iCACE,mBAEA,6CACE,iBACA,eACA,kBACA,YACA,eACA,mBAEA,sDACE,WACA,eAKN,oCACE,WA//CK,KAggDL,mBACA,qCACA,gBAEA,2CACE,SACA,sBAEA,oDACE,mBACA,gCACA,eACA,gBACA,yBACA,oBACA,cACA,iBACA,mBAGF,oDACE,gBACA,sBACA,gCACA,eAGF,0DACE,mBAIF,8PAIE,gBACA,eACA,yBACA,kBACA,WAxiDC,KA6iDL,mDACE,kBACA,oBACA,6BAEA,+DACE,aAIJ,6CACE,eACA,cAIA,2DACE,SACA,UACA,gBACA,aACA,mBACA,QAGE,iFACE,oBACA,mBACA,uBACA,eACA,YACA,eACA,kBACA,eACA,gBACA,yBACA,WAjlDH,KAklDG,MAjlDJ,QAklDI,eACA,mBACA,qBACA,cACA,mBAEA,uFACE,mBACA,MAlmDH,QAmmDG,aAnmDH,QAumDD,wFACE,WAxmDD,QAymDC,MAlmDH,KAmmDG,aA1mDD,QA2mDC,gBAGF,0FACE,YACA,eACA,oBAMR,mDACE,8BACA,MAjnDE,QAknDF,eAKJ,+BACE,oBACA,mBACA,uBACA,WACA,YACA,kBACA,YACA,eACA,eACA,mBACA,MA7nDM,KA8nDN,mBAEA,qCACE,WAjoDI,KAkoDJ,MAxoDG,KA6oDP,mCACE,oBACA,mBACA,uBACA,WACA,YACA,kBACA,YACA,eACA,eACA,mBACA,MA/pDO,QAgqDP,mBAEA,yCACE,WAnqDK,QAoqDL,MA7pDG,KAmqDT,aACE,aACA,8BACA,mBACA,kBAEA,4BAGF,WACE,aACA,QACA,gBACA,kBACA,YAGF,UACE,YACA,yBACA,iBACA,eACA,kBACA,eACA,WACA,yBAEA,6BAEA,iBACE,gBACA,WACA,qCACA,gBAGF,6BACE,WAIJ,WACE,OACA,YAIA,iEACA,mCACA,qDAGF,eACE,aACA,QACA,uBAEA,6BACE,OAIJ,gBACE,oBACA,mBACA,QACA,iBACA,kBACA,yBACA,qDACA,cACA,eACA,gBACA,eACA,mBACA,mBACA,gBAEA,kBACE,eAGF,sBACE,qDACA,WACA,qBAGF,yBACE,WACA,YAGF,8BACE,qBACA,qDACA,cAEA,oCACE,qDACA,WACA,qBAMN,gBACE,WA/wDO,KAgxDP,aACA,iBACA,kBACA,qCAEA,qBACE,eAGF,4BACE,mBACA,aAEA,mCACE,YACA,oBACA,uBACA,sBACA,mBAGF,mCACE,yBAMN,eACE,eACA,MACA,OACA,WACA,YACA,2BACA,aACA,aAEA,8BACE,kBACA,QACA,SACA,gCACA,WA3zDK,KA4zDL,aACA,mBACA,iBACA,UACA,uCAEA,4CACE,aACA,8BACA,mBACA,mBAEA,mDACE,eACA,gBAIJ,qCACE,eACA,cACA,eACA,YAEA,2CACE,MA/0DE,KAs1DV,iBACE,mBAIA,mBACE,yBACA,WAn2DK,KAo2DL,oBACA,YACA,WACA,mBACA,uBACA,aACA,kBACA,mBACA,eAEA,yBACE,WAh3DO,QAi3DP,aAv3DK,QAg4DT,iBACE,cACA,qBAGF,oCACE,aACA,8BAGF,mCACE,aACA,YACA,mBACA,uBACA,WACA,eACA,WA14DK,KA24DL,yBACA,cACA,kBAEA,yCACE,mBACA,MAj5DG,KAq5DP,6BACE,yBAMF,4CACE,aACA,eACA,QACA,mBAEA,iDACE,oBACA,mBACA,iBACA,oBACA,eACA,gBACA,MAx6DE,QAy6DF,mBACA,yBAIJ,kDACE,WAh7DK,KAi7DL,mBACA,qCACA,aACA,mBAGF,uCACE,aACA,SACA,uBAGF,kCACE,cACA,YAGF,6CACE,iBAGF,oCACE,YACA,eACA,mBACA,yBACA,mBACA,aAEA,gDACE,mBAGF,0CACE,cACA,gBACA,kBACA,eACA,cAGF,kGAEE,WACA,yBACA,kBACA,iBACA,eACA,gCACA,WAl+DG,KAo+DH,8GACE,aACA,aA7+DG,QA8+DH,0CAIJ,6CACE,iBACA,gBAGF,yCACE,WACA,uBACA,kBAGF,mDACE,WACA,mBAIJ,2CACE,gBAEA,kDACE,gBAGF,yDACE,aACA,mBACA,8BACA,SAGF,yDACE,sBAGF,2DACE,MAzgEI,KA0gEJ,qBACA,gBACA,mBAEA,iEACE,0BAIJ,6EACE,gBAKN,sBACE,aACA,eACA,cACA,mBACA,MAniEM,QAoiEN,eAGF,oBACE,mBAGF,oBACE,mBAEA,+BACE,gBAGF,wCACE,aACA,8BACA,mBACA,SACA,kBACA,eAEA,+CACE,MA1jEM,QA2jEN,gBAGF,6CACE,cACA,eACA,gBACA,mBAIJ,0BACE,cACA,eACA,WACA,eAIJ,mBACE,WACA,YACA,oBACA,mBACA,gBAEA,wBACE,cACA,YACA,4DAIJ,eACE,mBAGF,eACE,yBACA,kBACA,mBACA,kBACA,mBAEA,0BACE,gBAGF,6BACE,aACA,8BACA,mBACA,QACA,kBAEA,oCACE,MAnnEM,QAonEN,eACA,gBAGF,mCACE,cACA,eACA,mBAIJ,oBACE,cACA,mBACA,yBACA,kBACA,gBACA,cACA,eACA,gBAIJ,0BAEI,uCACE,sBAGF,oCACE,WACA,eAMN,+FAEE,sBACA,eAGF,+IAEE,iBAnqEO,KAoqEP,yBACA,kBACA,gBACA,aACA,mBACA,iBACA,gBACA,2CACA,eAGF,yMAEE,eACA,gBACA,cAGF,+MAEE,cAGF,mMAEE,YACA,UAGF,4VAIE,aA5sES,QA6sET,0CACA,UAGF,2GAEE,aAzsEQ,QA0sER,0BACA,eAGF,yKAEE,iBACA,kBACA,yBACA,eAGF,mMAEE,iBAluES,QAmuET,MA5tEO,KAkuET,yBACE,SACE,4BAEA,qBACE,wBAIJ,cACE,0BAOJ,qBACE,aACA,sBACA,SACA,gBAGF,qBACE,eACA,gBAEA,wCACE,eAEA,sDACE,gBAEA,0EACE,gBAKN,wCACE,iBACA,WACA,YACA,yBACA,kBACA,gBACA,cACA,oBACA,mBACA,uBACA,eACA,mBAEA,8CACE,mBACA,qBAIJ,mFACE,aAGF,2CACE,aACA,mBACA,SACA,kBACA,gCACA,gBAEA,iDACE,eACA,gBACA,cACA,oBACA,mBACA,QACA,SACA,mBAGF,uEACE,eAGF,kIAEE,YACA,YAGF,4IAEE,YACA,gBAIJ,kDACE,SACA,eACA,cACA,gBACA,mBAGF,2CACE,iBACA,oBACA,mBACA,QACA,YACA,eACA,kBACA,yBACA,mBACA,cACA,eACA,gBACA,eACA,mBAEA,iDACE,mBACA,qBAGF,oDACE,WACA,YAIJ,6CACE,aACA,mBACA,SACA,kBACA,gCACA,gBAGF,8CACE,oBACA,mBACA,QACA,YACA,eACA,kBACA,yBACA,mBACA,cACA,eACA,gBACA,eACA,mBAEA,oDACE,mBACA,qBAGF,uDACE,WACA,mBAIJ,6DACE,gBACA,MACA,UACA,oCACA,yBACA,2CACA,eACA,gBACA,yBACA,oBACA,kBACA,mBAGF,wIAEE,wBAGF,kRAIE,eACA,mBACA,gBAGF,wIAEE,cACA,gBACA,uBACA,kBAGF,8SAIE,YACA,kBACA,WACA,QACA,2BACA,WACA,YACA,oBACA,eACA,gBACA,iBACA,kBACA,cACA,mBAGF,kTAIE,YACA,WACA,mBAGF,sTAIE,YACA,WACA,mBAGF,4LAIE,WACA,YACA,eAGF,gDACE,aAGF,oCACE,kBACA,oBACA,6BAGF,8BACE,eACA,cAGF,4CACE,SACA,UACA,2BACA,aACA,mBACA,QAEA,uDACE,2BAEA,kEACE,oBACA,mBACA,uBACA,eACA,kBACA,YACA,eACA,kBACA,eACA,gBACA,yBACA,gBACA,cACA,qBACA,cACA,mBAEA,wEACE,mBACA,cACA,qBAIJ,kJAEE,eAGF,yEACE,mBACA,WACA,qBAGF,2EACE,YACA,eACA,oBAMR,mBACE,aACA,sBACA,QAGF,uBACE,yBACA,kBACA,gBACA,gBAEA,+BACE,eACA,iBACA,eACA,gBACA,cACA,gBAEA,uDACE,aAGF,sCACE,YACA,YACA,eACA,cACA,eAIJ,4CACE,YAIJ,oBACE,6BACA,iBACA,iBACA,gBAEA,oCACE,aACA,mBACA,QACA,eACA,cACA,kBAEA,+CACE,gBAGF,yDACE,SAKN,sBACE,gBACA,mBACA,qCACA,gBAGF,4BACE,kBACA,gCACA,eACA,gBACA,cACA,aACA,mBACA,QAEA,oDACE,oBACA,mBACA,QAIJ,4BACE,cAGF,uBACE,SACA,WACA,mBAEA,gCACE,iBACA,gCACA,eACA,cACA,sBACA,mBAGF,mCACE,iBACA,mBAGF,oCACE,mBACA,gBACA,uBAGF,gNAIE,kBAGF,gFAEE,8BACA,gBACA,uBAGF,iGAEE,oBACA,mBACA,uBACA,WACA,YACA,kBACA,eACA,mBAGF,+CACE,yBACA,mBACA,cAEA,qDACE,mBACA,WACA,qBAIJ,kDACE,yBACA,mBACA,cAEA,wDACE,mBACA,WACA,qBAIJ,sCACE,mBAGF,oDACE,cAGF,uDACE,mBAIJ,qBACE,kBACA,yBACA,kBAGF,wBACE,aACA,mBACA,SACA,mBAEA,8BACE,eACA,gBACA,cACA,SAGF,sCACE,YACA,YAIJ,kBACE,eACA,cACA,mBAGF,uBACE,yBACA,kBACA,iBACA,cAGF,wBACE,WACA,yBACA,eAEA,sDAEE,gCACA,YACA,sBAGF,2BACE,gBACA,MACA,mBACA,cACA,gBAGF,oCACE,gBACA,gBACA,mBACA,gBACA,uBAGF,sCACE,gBAIJ,uBACE,oBACA,mBACA,uBACA,oBACA,gBACA,eACA,gBAEA,sCACE,mBACA,cAGF,mCACE,mBACA,cAOJ,oEACE,gBAGF,0BACE,yBACA,kBACA,gBACA,gBAEA,kCACE,eACA,iBACA,eACA,gBACA,cACA,gBAEA,0DACE,aAGF,yCACE,YACA,YACA,eACA,cACA,eAIJ,+CACE,YAIJ,uBACE,6BACA,iBACA,iBACA,gBAEA,0CACE,aACA,mBACA,QACA,eACA,cACA,kBAEA,qDACE,gBAGF,+DACE,SAMJ,gDAEE,mBAGF,gCACE,oBACA,mBACA,QAEA,qCACE,WACA,YACA,UACA,oBACA,mBACA,uBACA,6BAEA,uCACE,cAMR,0CACE,gBACA,MACA,UACA,oCACA,yBACA,2CACA,eACA,gBACA,yBACA,oBACA,kBACA,mBAGF,mDACE,wBAGF,uGAEE,eACA,mBACA,gBAGF,mDACE,cACA,gBACA,uBACA,kBAGF,qHAEE,YACA,kBACA,WACA,QACA,2BACA,WACA,YACA,oBACA,eACA,gBACA,iBACA,kBACA,cACA,mBAGF,uHAEE,YACA,WACA,mBAGF,yHAEE,YACA,WACA","file":"style.css"} \ No newline at end of file diff --git a/layout/style.scss b/layout/style.scss index 4dc348c..21d40f5 100644 --- a/layout/style.scss +++ b/layout/style.scss @@ -353,6 +353,20 @@ body.logged { } } + .sidebar-nav ul li.nav-group .nav-group-label { + padding: 12px 0; + justify-content: center; + + span { + display: none; + } + + i { + margin-right: 0; + font-size: 18px; + } + } + .sidebar-footer { .sidebar-user { justify-content: center; @@ -427,6 +441,50 @@ body.logged { padding: 0; li { + &.nav-group { + margin-bottom: 4px; + + .nav-group-label { + display: flex; + align-items: center; + padding: 11px 20px; + color: #D5DEEA; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.6px; + border-left: 3px solid transparent; + + i { + width: 20px; + text-align: center; + margin-right: 12px; + font-size: 14px; + color: #B6C4D3; + } + } + + .nav-submenu { + margin: 0; + padding: 0; + list-style: none; + + li a { + padding-left: 44px; + } + } + + &.active > .nav-group-label { + color: $cWhite; + background: rgba($cPrimary, 0.12); + border-left-color: $cPrimary; + + i { + color: $cPrimary; + } + } + } + &.nav-divider { height: 1px; background: rgba($cWhite, 0.08); @@ -794,6 +852,38 @@ table { } // --- Settings page --- +.settings-tabs { + display: flex; + gap: 8px; + margin-bottom: 18px; + + .settings-tab { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + border-radius: 8px; + text-decoration: none; + color: #6B7A89; + background: #E9EEF5; + border: 1px solid #D8E0EA; + font-size: 13px; + font-weight: 600; + transition: all 0.2s; + + &:hover { + color: $cTextDark; + background: #DDE6F2; + } + + &.active { + color: $cWhite; + background: $cPrimary; + border-color: $cPrimary; + } + } +} + .settings-card { background: $cWhite; border-radius: 10px; @@ -882,7 +972,7 @@ table { } } - .settings-toggle-label { + .settings-field .settings-toggle-label { display: inline-flex; align-items: center; gap: 10px; @@ -890,12 +980,21 @@ table { font-size: 14px; font-weight: 500; user-select: none; + margin-bottom: 0; + width: 100%; + + .settings-toggle-text { + flex: 1 1 auto; + min-width: 0; + line-height: 1.35; + } } .settings-toggle-checkbox { display: none; & + .settings-toggle-switch { + display: inline-block; position: relative; width: 44px; height: 24px; @@ -1371,10 +1470,15 @@ table { .products-filters { display: flex; + flex-wrap: wrap; + align-items: flex-end; gap: 20px; margin-bottom: 16px; .filter-group { + flex: 1 1 220px; + min-width: 0; + label { display: block; font-size: 12px; @@ -1413,8 +1517,11 @@ table { padding-right: 32px; } - &.filter-group-client { flex: 1; } + &.filter-group-client, + &.filter-group-campaign, + &.filter-group-ad-group { flex: 1 1 260px; } &.filter-group-roas { flex: 0 0 200px; } + &.filter-group-columns { flex: 0 0 240px; } } } @@ -1848,60 +1955,259 @@ table#products { } } -// --- Chart with form --- -.chart-with-form { - display: flex; - gap: 20px; - align-items: flex-start; -} +// --- Product history --- +.product-history-page { + .product-history-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 14px; -.chart-area { - flex: 1 1 auto; - min-width: 0; -} - -.comment-form { - width: 360px; - flex: 0 0 360px; - - .form-group { - margin-bottom: 12px; + span { + display: inline-flex; + align-items: center; + padding: 5px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + color: $cText; + background: #EEF2FF; + border: 1px solid #D9E2FF; + } } - label { - display: block; - font-weight: 600; + .product-history-chart-wrap { + background: $cWhite; + border-radius: 10px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); + padding: 20px; + margin-bottom: 16px; + } + + .chart-with-form { + display: flex; + gap: 20px; + align-items: flex-start; + } + + .chart-area { + flex: 1 1 auto; + min-width: 0; + } + + .product-history-chart { + min-height: 360px; + } + + .comment-form { + width: 340px; + flex: 0 0 340px; + background: #F8FAFC; + border: 1px solid $cBorder; + border-radius: 10px; + padding: 14px; + + .form-group { + margin-bottom: 12px; + } + + label { + display: block; + font-weight: 600; + margin-bottom: 6px; + font-size: 13px; + color: #52606D; + } + + input[type="date"], + textarea { + width: 100%; + border: 1px solid $cBorder; + border-radius: 6px; + padding: 8px 12px; + font-size: 14px; + font-family: "Roboto", sans-serif; + background: $cWhite; + + &:focus { + outline: none; + border-color: $cPrimary; + box-shadow: 0 0 0 3px rgba($cPrimary, 0.1); + } + } + + textarea { + min-height: 110px; + resize: vertical; + } + + .btn { + width: 100%; + justify-content: center; + padding: 10px 16px; + } + + .btn[disabled] { + opacity: 0.6; + cursor: not-allowed; + } + } + + .products-table-wrap { + overflow-x: auto; + + .table { + min-width: 980px; + } + + .comment-cell { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + } + + .comment-text { + word-break: break-word; + } + + .delete-comment { + color: $cDanger; + text-decoration: none; + font-weight: 600; + white-space: nowrap; + + &:hover { + text-decoration: underline; + } + } + + .dt-paging .pagination .page-item { + list-style: none; + } + } +} + +.cron-status-overview { + display: flex; + flex-wrap: wrap; + gap: 10px 20px; + margin-bottom: 20px; + color: $cText; + font-size: 13px; +} + +.cron-progress-list { + margin-bottom: 20px; +} + +.cron-progress-item { + margin-bottom: 14px; + + &:last-child { + margin-bottom: 0; + } + + .cron-progress-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; margin-bottom: 6px; font-size: 13px; + + strong { + color: $cTextDark; + font-weight: 600; + } + + span { + color: #6B7A89; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + } } - input[type="date"], - textarea { - width: 100%; - border: 1px solid $cBorder; - border-radius: 6px; - padding: 8px 12px; - font-size: 14px; - font-family: "Roboto", sans-serif; - } - - textarea { - min-height: 120px; - resize: vertical; - } - - .btn { - padding: 8px 16px; - } - - .btn[disabled] { - opacity: 0.6; - cursor: not-allowed; - } - - .hint { + small { + display: block; + margin-top: 5px; + color: #778899; font-size: 12px; - color: #718096; + } +} + +.cron-progress-bar { + width: 100%; + height: 10px; + border-radius: 999px; + background: #E9EEF5; + overflow: hidden; + + > span { + display: block; + height: 100%; + background: linear-gradient(90deg, #5A9BFF 0%, #2E6BDF 100%); + } +} + +.cron-url-list { + margin-bottom: 20px; +} + +.cron-url-item { + border: 1px solid $cBorder; + border-radius: 8px; + background: #F8FAFC; + padding: 10px 12px; + margin-bottom: 10px; + + &:last-child { + margin-bottom: 0; + } + + .cron-url-top { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + margin-bottom: 6px; + + strong { + color: $cTextDark; + font-size: 13px; + font-weight: 600; + } + + small { + color: #7A8794; + font-size: 11px; + white-space: nowrap; + } + } + + code { + display: block; + background: #EEF2F7; + border: 1px solid #DDE4ED; + border-radius: 6px; + padding: 6px 8px; + color: #2E3B49; + font-size: 12px; + overflow-x: auto; + } +} + +@media (max-width: 1200px) { + .product-history-page { + .chart-with-form { + flex-direction: column; + } + + .comment-form { + width: 100%; + flex: 1 1 auto; + } } } @@ -1990,3 +2296,738 @@ table#products { margin-left: 0 !important; } } + +// =========================== +// CAMPAIGN TERMS VIEW +// =========================== +.campaign-terms-wrap { + display: flex; + flex-direction: column; + gap: 20px; + margin-top: 20px; +} + +.campaign-terms-page { + max-width: 100%; + overflow: hidden; + + .campaigns-filters { + flex-wrap: wrap; + + .filter-group { + min-width: 220px; + + &.terms-columns-group { + min-width: 280px; + } + } + } + + .terms-card-toggle { + margin-left: auto; + width: 28px; + height: 28px; + border: 1px solid #E2E8F0; + border-radius: 6px; + background: #FFFFFF; + color: #475569; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: #F8FAFC; + border-color: #CBD5E1; + } + } + + .terms-adgroups-card.is-collapsed .campaigns-extra-table-wrap { + display: none; + } + + .terms-search-toolbar { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-bottom: 1px solid #EEF2F7; + background: #FFFFFF; + + label { + font-size: 12px; + font-weight: 600; + color: #475569; + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; + white-space: nowrap; + } + + .terms-search-toolbar-label { + min-width: 86px; + } + + #terms_min_clicks_all, + #terms_max_clicks_all { + width: 160px; + height: 32px; + } + + #terms_min_conversions_all, + #terms_max_conversions_all { + width: 130px; + max-width: 130px; + } + } + + .terms-search-selected-label { + margin: 0; + font-size: 12px; + color: #475569; + font-weight: 600; + white-space: nowrap; + } + + .terms-ai-analyze-btn { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 12px; + border-radius: 6px; + border: 1px solid #BFDBFE; + background: #EFF6FF; + color: #1D4ED8; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: #DBEAFE; + border-color: #93C5FD; + } + + &:disabled { + opacity: 0.6; + cursor: wait; + } + } + + .terms-negative-toolbar { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-bottom: 1px solid #EEF2F7; + background: #FFFFFF; + } + + .terms-negative-bulk-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 12px; + border-radius: 6px; + border: 1px solid #FECACA; + background: #FEF2F2; + color: #DC2626; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: #FEE2E2; + border-color: #FCA5A5; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + table.campaigns-extra-table > thead > tr > th { + position: sticky; + top: 0; + z-index: 2; + background-color: #111827 !important; + color: #E5E7EB !important; + border-bottom: 1px solid #0B1220 !important; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .4px; + padding: 10px 12px; + white-space: nowrap; + } + + #terms_search_table thead th .dt-column-order, + #terms_negative_table thead th .dt-column-order { + display: none !important; + } + + #terms_search_table thead th.dt-orderable-asc, + #terms_search_table thead th.dt-orderable-desc, + #terms_negative_table thead th.dt-orderable-asc, + #terms_negative_table thead th.dt-orderable-desc { + cursor: pointer; + padding-right: 34px; + overflow: hidden; + } + + #terms_search_table thead th .dt-column-title, + #terms_negative_table thead th .dt-column-title { + display: block; + overflow: hidden; + text-overflow: ellipsis; + padding-right: 2px; + } + + #terms_search_table thead th.dt-orderable-asc::after, + #terms_search_table thead th.dt-orderable-desc::after, + #terms_negative_table thead th.dt-orderable-asc::after, + #terms_negative_table thead th.dt-orderable-desc::after { + content: '\2195'; + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + line-height: 16px; + text-align: center; + color: #E5E7EB; + background: #374151; + } + + #terms_search_table thead th.dt-ordering-asc::after, + #terms_negative_table thead th.dt-ordering-asc::after, + #terms_search_table thead th[aria-sort="ascending"]::after, + #terms_negative_table thead th[aria-sort="ascending"]::after { + content: '\25B2'; + color: #FFFFFF; + background: #2563EB; + } + + #terms_search_table thead th.dt-ordering-desc::after, + #terms_negative_table thead th.dt-ordering-desc::after, + #terms_search_table thead th[aria-sort="descending"]::after, + #terms_negative_table thead th[aria-sort="descending"]::after { + content: '\25BC'; + color: #FFFFFF; + background: #2563EB; + } + + #terms_negative_select_all, + .terms-negative-select-row, + #terms_search_select_all, + .terms-search-select-row { + width: 14px; + height: 14px; + cursor: pointer; + } + + .dt-layout-row:first-child { + display: none; + } + + .dt-layout-row { + padding: 10px 12px; + margin: 0 !important; + border-top: 1px solid #F1F5F9; + } + + .dt-info { + font-size: 12px; + color: #64748B; + } + + .dt-paging .pagination { + margin: 0; + padding: 0; + list-style: none !important; + display: flex; + align-items: center; + gap: 6px; + + .page-item { + list-style: none !important; + + .page-link { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 36px; + width: fit-content; + height: 32px; + padding: 0 12px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + border: 1px solid #E2E8F0; + background: #FFFFFF; + color: #4E5E6A; + text-decoration: none; + line-height: 1; + white-space: nowrap; + + &:hover { + background: #EEF2FF; + color: #6690F4; + border-color: #6690F4; + } + } + + &.previous .page-link, + &.next .page-link { + min-width: 72px; + } + + &.active .page-link { + background: #6690F4; + color: #FFFFFF; + border-color: #6690F4; + } + + &.disabled .page-link { + opacity: 0.35; + cursor: default; + pointer-events: none; + } + } + } +} + +.terms-columns-box { + display: flex; + flex-direction: column; + gap: 6px; +} + +.terms-columns-control { + border: 1px solid #E2E8F0; + border-radius: 6px; + background: #FFFFFF; + overflow: hidden; + + summary { + cursor: pointer; + padding: 8px 10px; + font-size: 12px; + font-weight: 600; + color: #334155; + list-style: none; + + &::-webkit-details-marker { + display: none; + } + + &::after { + content: '\25BC'; + float: right; + font-size: 10px; + color: #64748B; + margin-top: 2px; + } + } + + &[open] summary::after { + content: '\25B2'; + } +} + +.terms-columns-list { + border-top: 1px solid #EEF2F7; + padding: 8px 10px; + max-height: 180px; + overflow-y: auto; + + .terms-col-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: #334155; + margin-bottom: 6px; + + &:last-child { + margin-bottom: 0; + } + + input[type=checkbox] { + margin: 0; + } + } +} + +.campaigns-extra-card { + background: #FFFFFF; + border-radius: 10px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); + overflow: hidden; +} + +.campaigns-extra-card-title { + padding: 14px 16px; + border-bottom: 1px solid #E2E8F0; + font-size: 13px; + font-weight: 700; + color: #334155; + display: flex; + align-items: center; + gap: 8px; + + .terms-card-title-label { + display: inline-flex; + align-items: center; + gap: 8px; + } +} + +.campaigns-extra-table-wrap { + overflow: auto; +} + +.campaigns-extra-table { + margin: 0; + width: 100%; + table-layout: fixed; + + tbody td { + padding: 9px 12px; + border-bottom: 1px solid #F1F5F9; + font-size: 13px; + color: #334155; + vertical-align: middle; + white-space: nowrap; + } + + td.num-cell { + text-align: right; + white-space: nowrap; + } + + td.text-cell { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + th.terms-negative-select-cell, + td.terms-negative-select-cell, + th.terms-search-select-cell, + td.terms-search-select-cell { + text-align: center; + } + + th.phrase-nowrap, + td.phrase-nowrap { + white-space: nowrap !important; + overflow: hidden; + text-overflow: ellipsis; + } + + .terms-add-negative-btn, + .terms-remove-negative-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + } + + .terms-add-negative-btn { + border: 1px solid #E2E8F0; + background: #EEF2FF; + color: #3B82F6; + + &:hover { + background: #3B82F6; + color: #FFFFFF; + border-color: #3B82F6; + } + } + + .terms-remove-negative-btn { + border: 1px solid #FECACA; + background: #FEF2F2; + color: #DC2626; + + &:hover { + background: #DC2626; + color: #FFFFFF; + border-color: #DC2626; + } + } + + tbody tr:hover { + background: #F8FAFC; + } + + tbody tr.term-is-negative td { + color: #DC2626; + } + + tbody tr.term-is-negative:hover { + background: #FEF2F2; + } +} + +.campaigns-empty-row { + text-align: center; + color: #94A3B8 !important; + font-style: italic; +} + +.terms-ai-modal-toolbar { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; + + label { + font-size: 12px; + font-weight: 600; + color: #334155; + margin: 0; + } + + .form-control { + width: 200px; + height: 32px; + } +} + +.terms-ai-summary { + font-size: 12px; + color: #64748B; + margin-bottom: 10px; +} + +.terms-ai-results-wrap { + border: 1px solid #E2E8F0; + border-radius: 8px; + max-height: 420px; + overflow: auto; +} + +.terms-ai-results-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + + th, + td { + border-bottom: 1px solid #EEF2F7; + padding: 8px; + vertical-align: middle; + } + + th { + position: sticky; + top: 0; + background: #F8FAFC; + color: #334155; + font-weight: 700; + } + + td.term-col { + min-width: 260px; + max-width: 380px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + td.reason-col { + min-width: 320px; + } +} + +.terms-ai-action-badge { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + padding: 2px 8px; + font-size: 11px; + font-weight: 700; + + &.action-exclude { + background: #FEE2E2; + color: #B91C1C; + } + + &.action-keep { + background: #DCFCE7; + color: #166534; + } +} + +// =========================== +// PRODUCTS VIEW (INLINE MOVED) +// =========================== +.products-page .products-filters .filter-group.filter-group-columns { + min-width: 240px; +} + +.products-columns-control { + border: 1px solid #E2E8F0; + border-radius: 6px; + background: #FFFFFF; + overflow: hidden; + + summary { + cursor: pointer; + padding: 8px 10px; + font-size: 12px; + font-weight: 600; + color: #334155; + list-style: none; + + &::-webkit-details-marker { + display: none; + } + + &::after { + content: '\25BC'; + float: right; + font-size: 10px; + color: #64748B; + margin-top: 2px; + } + } + + &[open] summary::after { + content: '\25B2'; + } +} + +.products-columns-list { + border-top: 1px solid #EEF2F7; + padding: 8px 10px; + max-height: 220px; + overflow-y: auto; + + .products-col-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: #334155; + margin-bottom: 6px; + + &:last-child { + margin-bottom: 0; + } + + input[type=checkbox] { + margin: 0; + } + } +} + +#products { + th:last-child, + td:last-child { + white-space: nowrap; + } + + .products-row-actions { + display: inline-flex; + align-items: center; + gap: 4px; + + .btn { + width: 38px; + height: 32px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px !important; + + i { + line-height: 1; + } + } + } +} + +.products-page table#products > thead > tr > th { + position: sticky; + top: 0; + z-index: 2; + background-color: #111827 !important; + color: #E5E7EB !important; + border-bottom: 1px solid #0B1220 !important; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .4px; + padding: 10px 12px; + white-space: nowrap; +} + +.products-page #products thead th .dt-column-order { + display: none !important; +} + +.products-page #products thead th.dt-orderable-asc, +.products-page #products thead th.dt-orderable-desc { + cursor: pointer; + padding-right: 34px; + overflow: hidden; +} + +.products-page #products thead th .dt-column-title { + display: block; + overflow: hidden; + text-overflow: ellipsis; + padding-right: 2px; +} + +.products-page #products thead th.dt-orderable-asc::after, +.products-page #products thead th.dt-orderable-desc::after { + content: '\2195'; + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + line-height: 16px; + text-align: center; + color: #E5E7EB; + background: #374151; +} + +.products-page #products thead th.dt-ordering-asc::after, +.products-page #products thead th[aria-sort="ascending"]::after { + content: '\25B2'; + color: #FFFFFF; + background: #2563EB; +} + +.products-page #products thead th.dt-ordering-desc::after, +.products-page #products thead th[aria-sort="descending"]::after { + content: '\25BC'; + color: #FFFFFF; + background: #2563EB; +} diff --git a/libraries/adspro-dialog.css b/libraries/adspro-dialog.css index 706513f..0d63dc6 100644 --- a/libraries/adspro-dialog.css +++ b/libraries/adspro-dialog.css @@ -188,7 +188,12 @@ line-height: 1.4; } .adspro-dialog-btn:focus { - outline: none; + outline: 2px solid #6690F4; + outline-offset: 2px; +} +.adspro-dialog-btn:focus-visible { + outline: 2px solid #6690F4; + outline-offset: 2px; } /* Klasy przycisków */ diff --git a/libraries/adspro-dialog.js b/libraries/adspro-dialog.js index a54d376..6fedacb 100644 --- a/libraries/adspro-dialog.js +++ b/libraries/adspro-dialog.js @@ -48,6 +48,7 @@ this._appendToBody(); this._applyAutoClose(); this._triggerContentReady(); + this._focusInitialElement(); activeDialogs.push( this ); }, @@ -72,7 +73,7 @@ '
' + '
' + '
' + - '
' + + '
' + this._buildCloseIcon( o ) + this._buildHeader( o ) + '
' + @@ -199,7 +200,7 @@ } if ( e.key === 'Enter' ) { - if ( $( e.target ).is( 'textarea, select' ) ) return; + if ( $( e.target ).is( '.adspro-dialog-btn, textarea, select, input' ) ) return; var $enterBtn = self.$el.find( '.adspro-dialog-btn[data-enter-key="true"]' ); if ( $enterBtn.length ) { @@ -262,6 +263,40 @@ } }, + _focusInitialElement: function() + { + var self = this; + + setTimeout( function() + { + if ( self._closed ) return; + if ( $( document.activeElement ).closest( self.$el ).length ) return; + + var $focusTarget = self.$el.find( '[autofocus]:visible:not(:disabled):first' ); + if ( !$focusTarget.length ) + { + $focusTarget = self.$el.find( '.adspro-dialog-content input:visible:not(:disabled):first, .adspro-dialog-content select:visible:not(:disabled):first, .adspro-dialog-content textarea:visible:not(:disabled):first' ).first(); + } + if ( !$focusTarget.length ) + { + $focusTarget = self.$el.find( '.adspro-dialog-btn[data-enter-key="true"]:visible:not(:disabled):first' ); + } + if ( !$focusTarget.length ) + { + $focusTarget = self.$el.find( '.adspro-dialog-btn:visible:not(:disabled):first' ); + } + if ( !$focusTarget.length ) + { + $focusTarget = self.$box; + } + + if ( $focusTarget.length ) + { + $focusTarget.trigger( 'focus' ); + } + }, 20 ); + }, + // --- Metody publiczne --- close: function() diff --git a/migrations/007_clients_merchant_account_id.sql b/migrations/007_clients_merchant_account_id.sql new file mode 100644 index 0000000..56f3d6f --- /dev/null +++ b/migrations/007_clients_merchant_account_id.sql @@ -0,0 +1,2 @@ +ALTER TABLE `clients` + ADD COLUMN `google_merchant_account_id` varchar(32) DEFAULT NULL AFTER `google_ads_customer_id`; diff --git a/migrations/008_products_keyword_planner_terms.sql b/migrations/008_products_keyword_planner_terms.sql new file mode 100644 index 0000000..2da2db7 --- /dev/null +++ b/migrations/008_products_keyword_planner_terms.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS `products_keyword_planner_terms` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `product_id` INT(11) NOT NULL, + `source_url` VARCHAR(1024) NOT NULL, + `keyword_text` VARCHAR(255) NOT NULL, + `avg_monthly_searches` INT(11) NOT NULL DEFAULT 0, + `competition` VARCHAR(32) DEFAULT NULL, + `competition_index` INT(11) DEFAULT NULL, + `source_customer_id` VARCHAR(32) DEFAULT NULL, + `date_add` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_products_keyword_planner_terms` (`product_id`,`source_url`(255),`keyword_text`), + KEY `idx_products_keyword_product` (`product_id`), + KEY `idx_products_keyword_popularity` (`product_id`,`avg_monthly_searches`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/migrations/009_products_merchant_sync_log.sql b/migrations/009_products_merchant_sync_log.sql new file mode 100644 index 0000000..8fc455b --- /dev/null +++ b/migrations/009_products_merchant_sync_log.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS `products_merchant_sync_log` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `product_id` INT(11) NOT NULL, + `field_name` VARCHAR(64) NOT NULL, + `old_value` TEXT NULL, + `new_value` TEXT NULL, + `sync_status` VARCHAR(16) NOT NULL DEFAULT 'pending', + `sync_source` VARCHAR(32) NOT NULL DEFAULT 'products_ui', + `merchant_account_id` VARCHAR(32) NULL, + `merchant_product_id` VARCHAR(255) NULL, + `offer_id` VARCHAR(255) NULL, + `api_response` MEDIUMTEXT NULL, + `error_message` TEXT NULL, + `date_add` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_products_merchant_sync_product` (`product_id`), + KEY `idx_products_merchant_sync_field` (`field_name`), + KEY `idx_products_merchant_sync_status` (`sync_status`), + KEY `idx_products_merchant_sync_date` (`date_add`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/migrations/010_campaign_keywords.sql b/migrations/010_campaign_keywords.sql new file mode 100644 index 0000000..62bd044 --- /dev/null +++ b/migrations/010_campaign_keywords.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS `campaign_keywords` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `campaign_id` int(11) NOT NULL, + `ad_group_id` int(11) NOT NULL, + `keyword_text` varchar(255) NOT NULL, + `match_type` varchar(40) DEFAULT NULL, + `impressions_30` int(11) NOT NULL DEFAULT 0, + `clicks_30` int(11) NOT NULL DEFAULT 0, + `cost_30` decimal(20,6) NOT NULL DEFAULT 0.000000, + `conversions_30` decimal(20,6) NOT NULL DEFAULT 0.000000, + `conversion_value_30` decimal(20,6) NOT NULL DEFAULT 0.000000, + `roas_30` decimal(20,6) NOT NULL DEFAULT 0.000000, + `impressions_all_time` int(11) NOT NULL DEFAULT 0, + `clicks_all_time` int(11) NOT NULL DEFAULT 0, + `cost_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000, + `conversions_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000, + `conversion_value_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000, + `roas_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000, + `date_sync` date DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_campaign_keywords` (`campaign_id`,`ad_group_id`,`keyword_text`(191),`match_type`), + KEY `idx_campaign_keywords_campaign_id` (`campaign_id`), + KEY `idx_campaign_keywords_ad_group_id` (`ad_group_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/templates/campaign_terms/main_view.php b/templates/campaign_terms/main_view.php index 57e6997..d277dc8 100644 --- a/templates/campaign_terms/main_view.php +++ b/templates/campaign_terms/main_view.php @@ -44,6 +44,10 @@ Frazy wykluczajace
+
+ Frazy dodane +
+
@@ -86,26 +90,38 @@ + + + + +
+ + + - +
+ + Fraza Grupa reklam Klik. all Koszt all Wartosc allKonw. all ROAS all Klik. 30d Koszt 30d Wartosc 30dKonw. 30d ROAS 30d Akcja
Brak danych.
Brak danych.
@@ -115,17 +131,56 @@
Frazy wykluczajace
+
+ +
- + + + - + + +
Poziom + + Fraza Match typeAkcjaPoziom
Brak danych.
Brak danych.
+
+
+ +
+
+ Frazy dodane do kampanii/grup reklam +
+
+ + + + + + + + + + + + + + + + + + + +
FrazaMatch typeGrupa reklamKlik. allKoszt allWartosc allKonw. allROAS allKlik. 30dKoszt 30dWartosc 30dKonw. 30dROAS 30d
Brak danych.
@@ -133,343 +188,14 @@
- - diff --git a/templates/xml_files/main_view.php b/templates/xml_files/main_view.php new file mode 100644 index 0000000..8aee391 --- /dev/null +++ b/templates/xml_files/main_view.php @@ -0,0 +1,67 @@ +
+
+

Pliki XML

+
+ +
+ + + + + + + + + + + + + rows ): ?> + rows as $row ): ?> + + + + + + + + + + + + + + + +
#IDKlientGoogle Ads IDLink do custom feedOstatnia modyfikacjaAkcje
+ + + + - brak - + + + + + + +
Plik nie zostal jeszcze wygenerowany.
+ +
+ + + + Brak + + +
+ + +
+
+ +

Brak klientow do wyswietlenia.

+
+
+
diff --git a/tmp/debug_products_urls.php b/tmp/debug_products_urls.php new file mode 100644 index 0000000..c666b8a --- /dev/null +++ b/tmp/debug_products_urls.php @@ -0,0 +1,30 @@ + 'mysql', + 'database_name' => $database['name'], + 'server' => $database['host'], + 'username' => $database['user'], + 'password' => $database['password'], + 'charset' => 'utf8' +]); + +$client_id = 5; + +$rows = []; +$rows['products_total'] = (int)$mdb->query("SELECT COUNT(*) FROM products WHERE client_id = {$client_id}")->fetchColumn(); +$rows['products_not_deleted'] = (int)$mdb->query("SELECT COUNT(*) FROM products WHERE client_id = {$client_id} AND (deleted = 0 OR deleted IS NULL)")->fetchColumn(); +$rows['products_with_offer'] = (int)$mdb->query("SELECT COUNT(*) FROM products WHERE client_id = {$client_id} AND (deleted = 0 OR deleted IS NULL) AND TRIM(COALESCE(offer_id,'')) <> ''")->fetchColumn(); +$rows['pd_rows_for_client_products'] = (int)$mdb->query("SELECT COUNT(*) FROM products_data pd JOIN products p ON p.id = pd.product_id WHERE p.client_id = {$client_id}")->fetchColumn(); +$rows['pd_distinct_products'] = (int)$mdb->query("SELECT COUNT(DISTINCT pd.product_id) FROM products_data pd JOIN products p ON p.id = pd.product_id WHERE p.client_id = {$client_id}")->fetchColumn(); +$rows['products_with_real_url_any_pd_row'] = (int)$mdb->query("SELECT COUNT(DISTINCT p.id) FROM products p JOIN products_data pd ON pd.product_id = p.id WHERE p.client_id = {$client_id} AND (p.deleted = 0 OR p.deleted IS NULL) AND TRIM(COALESCE(p.offer_id,'')) <> '' AND TRIM(COALESCE(pd.product_url,'')) <> '' AND LOWER(TRIM(pd.product_url)) NOT IN ('0','-','null')")->fetchColumn(); +$rows['products_without_real_url'] = (int)$mdb->query("SELECT COUNT(*) FROM products p LEFT JOIN ( SELECT product_id, MAX(CASE WHEN TRIM(COALESCE(product_url,'')) = '' THEN 0 WHEN LOWER(TRIM(product_url)) IN ('0','-','null') THEN 0 ELSE 1 END) AS has_real_url FROM products_data GROUP BY product_id ) pd ON pd.product_id = p.id WHERE p.client_id = {$client_id} AND (p.deleted = 0 OR p.deleted IS NULL) AND TRIM(COALESCE(p.offer_id,'')) <> '' AND COALESCE(pd.has_real_url,0) = 0")->fetchColumn(); + +$dup = $mdb->query("SELECT COUNT(*) FROM (SELECT pd.product_id, COUNT(*) c FROM products_data pd JOIN products p ON p.id = pd.product_id WHERE p.client_id = {$client_id} GROUP BY pd.product_id HAVING COUNT(*) > 1) t")->fetchColumn(); +$rows['products_with_duplicate_pd_rows'] = (int)$dup; + +$sample = $mdb->query("SELECT p.id, p.offer_id, pd.product_url FROM products p LEFT JOIN products_data pd ON pd.product_id=p.id WHERE p.client_id={$client_id} ORDER BY p.id DESC LIMIT 10")->fetchAll(PDO::FETCH_ASSOC); + +echo json_encode(['stats'=>$rows,'sample'=>$sample], JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT);