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 @@
'
' +
'
+
+
-
-
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 @@
+
+
+
+
+
+
+
+ #ID
+ Klient
+ Google Ads ID
+ Link do custom feed
+ Ostatnia modyfikacja
+ Akcje
+
+
+
+ rows ): ?>
+ rows as $row ): ?>
+
+ = (int) ( $row['client_id'] ?? 0 ); ?>
+ = htmlspecialchars( (string) ( $row['client_name'] ?? '' ) ); ?>
+
+
+ = htmlspecialchars( (string) $row['google_ads_customer_id'] ); ?>
+
+ - brak -
+
+
+
+
+ = htmlspecialchars( (string) ( $row['xml_url'] ?? '' ) ); ?>
+
+
+ Plik nie zostal jeszcze wygenerowany.
+
+
+
+
+ = htmlspecialchars( (string) $row['xml_last_modified'] ); ?>
+
+ 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);