feat: Add XML file management functionality

- Created XmlFiles control class for handling XML file views and regeneration.
- Implemented method to retrieve clients with XML feeds in the factory class.
- Added database migration to include google_merchant_account_id in clients table.
- Created migrations for products_keyword_planner_terms and products_merchant_sync_log tables.
- Added campaign_keywords table migration for managing campaign keyword data.
- Developed main view template for displaying XML files and their statuses.
- Introduced a debug script for analyzing product URLs and their statuses.
This commit is contained in:
2026-02-18 21:23:53 +01:00
parent 3dc06d505a
commit efbdcce08a
36 changed files with 8778 additions and 2615 deletions

0
TODO.md Normal file
View File

View File

@@ -3,6 +3,72 @@ namespace controls;
class CampaignTerms 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() static public function main_view()
{ {
return \Tpl::view( 'campaign_terms/main_view', [ return \Tpl::view( 'campaign_terms/main_view', [
@@ -38,13 +104,14 @@ class CampaignTerms
if ( $campaign_id <= 0 ) if ( $campaign_id <= 0 )
{ {
echo json_encode( [ 'search_terms' => [], 'negative_keywords' => [] ] ); echo json_encode( [ 'search_terms' => [], 'negative_keywords' => [], 'keywords' => [] ] );
exit; exit;
} }
echo json_encode( [ echo json_encode( [
'search_terms' => \factory\Campaigns::get_campaign_search_terms( $campaign_id, $ad_group_id ), '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; exit;
} }
@@ -58,7 +125,15 @@ class CampaignTerms
if ( $search_term_id <= 0 ) 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; exit;
} }
@@ -74,7 +149,15 @@ class CampaignTerms
$context = \factory\Campaigns::get_search_term_context( $search_term_id ); $context = \factory\Campaigns::get_search_term_context( $search_term_id );
if ( !$context ) 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; exit;
} }
@@ -97,26 +180,36 @@ class CampaignTerms
if ( $missing_data ) if ( $missing_data )
{ {
echo json_encode( [ echo json_encode( self::with_optional_debug( [
'success' => false, 'success' => false,
'message' => 'Brak wymaganych danych Google Ads dla tej frazy.', 'message' => 'Brak wymaganych danych Google Ads dla tej frazy.',
'debug' => [ ], [
'customer_id' => $customer_id, 'customer_id' => $customer_id,
'campaign_external_id' => $campaign_external_id, 'campaign_external_id' => $campaign_external_id,
'ad_group_external_id' => $ad_group_external_id, 'ad_group_external_id' => $ad_group_external_id,
'keyword_text' => $keyword_text, 'keyword_text' => $keyword_text,
'keyword_source' => $keyword_source, 'keyword_source' => $keyword_source,
'scope' => $scope, 'scope' => $scope,
'context' => $context 'context' => $context
] ] ) );
] );
exit; exit;
} }
$api = new \services\GoogleAdsApi(); $api = new \services\GoogleAdsApi();
if ( !$api -> is_configured() ) 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; exit;
} }
@@ -132,21 +225,46 @@ class CampaignTerms
if ( !( $api_result['success'] ?? false ) ) if ( !( $api_result['success'] ?? false ) )
{ {
$last_error = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ); $last_error = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' );
echo json_encode( [ echo json_encode( self::with_optional_debug( [
'success' => false, 'success' => false,
'message' => 'Nie udalo sie zapisac frazy wykluczajacej w Google Ads.', 'message' => 'Nie udalo sie zapisac frazy wykluczajacej w Google Ads.',
'error' => $last_error, 'error' => $last_error
'debug' => [ ], [
'customer_id' => $customer_id, 'customer_id' => $customer_id,
'campaign_external_id' => $campaign_external_id, 'campaign_external_id' => $campaign_external_id,
'ad_group_external_id' => $ad_group_external_id, 'ad_group_external_id' => $ad_group_external_id,
'keyword_text' => $keyword_text, 'keyword_text' => $keyword_text,
'keyword_source' => $keyword_source, 'keyword_source' => $keyword_source,
'match_type' => $match_type, 'match_type' => $match_type,
'scope' => $scope, 'scope' => $scope,
'api_result' => $api_result '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; exit;
} }
@@ -160,24 +278,395 @@ class CampaignTerms
$scope_label = $scope === 'campaign' ? 'kampanii' : 'grupy reklam'; $scope_label = $scope === 'campaign' ? 'kampanii' : 'grupy reklam';
echo json_encode( [ echo json_encode( self::with_optional_debug( [
'success' => true, 'success' => true,
'message' => ( $api_result['duplicate'] ?? false ) ? 'Fraza byla juz wykluczona na poziomie ' . $scope_label . '.' : 'Fraza zostala dodana do wykluczajacych na poziomie ' . $scope_label . '.', '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 ), 'duplicate' => (bool) ( $api_result['duplicate'] ?? false ),
'match_type' => $match_type, '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, 'scope' => $scope,
'debug' => [ 'api_response' => $api_result['response'] ?? null,
'customer_id' => $customer_id, 'sent_operation' => $api_result['sent_operation'] ?? null,
'campaign_external_id' => $campaign_external_id, 'verification' => $api_result['verification'] ?? null
'ad_group_external_id' => $ad_group_external_id, ] ) );
'keyword_text' => $keyword_text, exit;
'keyword_source' => $keyword_source, }
'scope' => $scope,
'api_response' => $api_result['response'] ?? null, static public function analyze_search_terms_with_ai()
'sent_operation' => $api_result['sent_operation'] ?? null, {
'verification' => $api_result['verification'] ?? null $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; 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;
}
} }

View File

@@ -15,6 +15,7 @@ class Clients
$id = \S::get( 'id' ); $id = \S::get( 'id' );
$name = trim( \S::get( 'name' ) ); $name = trim( \S::get( 'name' ) );
$google_ads_customer_id = trim( \S::get( 'google_ads_customer_id' ) ); $google_ads_customer_id = trim( \S::get( 'google_ads_customer_id' ) );
$google_merchant_account_id = trim( \S::get( 'google_merchant_account_id' ) );
if ( !$name ) if ( !$name )
{ {
@@ -28,6 +29,7 @@ class Clients
$data = [ $data = [
'name' => $name, 'name' => $name,
'google_ads_customer_id' => $google_ads_customer_id ?: null, '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, 'google_ads_start_date' => $google_ads_start_date ?: null,
]; ];

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,168 @@
namespace controls; namespace controls;
class Products 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() { static public function get_client_bestseller_min_roas() {
$client_id = \S::get( 'client_id' ); $client_id = \S::get( 'client_id' );
@@ -57,6 +219,233 @@ class Products
exit; 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() static public function comment_add()
{ {
$product_id = \S::get( 'product_id' ); $product_id = \S::get( 'product_id' );
@@ -102,6 +491,31 @@ class Products
exit; 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() static public function ai_suggest()
{ {
$product_id = \S::get( 'product_id' ); $product_id = \S::get( 'product_id' );
@@ -144,12 +558,51 @@ class Products
// Pobierz treść strony produktu jeśli podano URL // Pobierz treść strony produktu jeśli podano URL
$product_url = \S::get( 'product_url' ); $product_url = \S::get( 'product_url' );
$keyword_source_url = self::normalize_keyword_source_url( $product_url );
$page_content = ''; $page_content = '';
if ( $product_url && filter_var( $product_url, FILTER_VALIDATE_URL ) ) if ( $product_url && filter_var( $product_url, FILTER_VALIDATE_URL ) )
{ {
$page_content = \services\OpenAiApi::fetch_page_content( $product_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 = [ $context = [
'original_name' => $product['name'], 'original_name' => $product['name'],
'current_title' => \factory\Products::get_product_data( $product_id, 'title' ), 'current_title' => \factory\Products::get_product_data( $product_id, 'title' ),
@@ -165,6 +618,7 @@ class Products
'roas' => $product['roas'] ?? 0, 'roas' => $product['roas'] ?? 0,
'custom_label_4' => \factory\Products::get_product_data( $product_id, 'custom_label_4' ), 'custom_label_4' => \factory\Products::get_product_data( $product_id, 'custom_label_4' ),
'page_content' => $page_content, 'page_content' => $page_content,
'keyword_planner_terms' => $keyword_terms,
]; ];
$api = $provider === 'claude' ? \services\ClaudeApi::class : \services\OpenAiApi::class; $api = $provider === 'claude' ? \services\ClaudeApi::class : \services\OpenAiApi::class;
@@ -187,10 +641,19 @@ class Products
$result['provider'] = $provider; $result['provider'] = $provider;
if ( $product_url && !$page_content ) 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 ) elseif ( $page_content )
$result['page_fetched'] = true; $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 ); echo json_encode( $result );
exit; exit;
} }
@@ -257,6 +720,7 @@ class Products
$custom_class = ''; $custom_class = '';
$custom_label_4 = \factory\Products::get_product_data( $row['product_id'], 'custom_label_4' ); $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' ); $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 ) if ( $custom_name )
{ {
@@ -283,12 +747,16 @@ class Products
// ➌ ROAS liczba + pasek performance // ➌ ROAS liczba + pasek performance
$roasValue = (float)$row['roas']; $roasValue = (float)$row['roas'];
$roasDisplay = (int) round( $roasValue, 0 );
$roasNumeric = ($roasValue <= (float)$row['min_roas']) $roasNumeric = ($roasValue <= (float)$row['min_roas'])
? '<span class="text-danger text-bold">'.($roasValue).'</span>' ? '<span class="text-danger text-bold">' . $roasDisplay . '</span>'
: $roasValue; : $roasDisplay;
$roasPerfBar = $renderPerfBar($roasValue, $roas_min, $roas_max); $roasPerfBar = $renderPerfBar($roasValue, $roas_min, $roas_max);
$roasCellHtml = '<div class="roas-cell">'.$roasNumeric.$roasPerfBar.'</div>'; $roasCellHtml = '<div class="roas-cell">'.$roasNumeric.$roasPerfBar.'</div>';
$product_url_html = $product_url !== ''
? '<a href="' . htmlspecialchars( $product_url ) . '" target="_blank" rel="noopener noreferrer" title="' . htmlspecialchars( $product_url ) . '"><i class="fa-solid fa-up-right-from-square"></i> Otworz</a>'
: '';
$data['data'][] = [ $data['data'][] = [
'', // checkbox column '', // checkbox column
@@ -296,6 +764,7 @@ class Products
$row['offer_id'], $row['offer_id'],
htmlspecialchars( (string) ( $row['campaign_name'] ?? '' ) ), htmlspecialchars( (string) ( $row['campaign_name'] ?? '' ) ),
htmlspecialchars( (string) ( $row['ad_group_name'] ?? '' ) ), htmlspecialchars( (string) ( $row['ad_group_name'] ?? '' ) ),
$product_url_html,
'<div class="table-product-title" product_id="' . $row['product_id'] . '"> '<div class="table-product-title" product_id="' . $row['product_id'] . '">
<a href="/products/product_history/client_id=' . $client_id . '&product_id=' . $row['product_id'] . '&campaign_id=' . (int) ( $row['campaign_id'] ?? 0 ) . '&ad_group_id=' . (int) ( $row['ad_group_id'] ?? 0 ) . '" target="_blank" class="' . $custom_class . '"> <a href="/products/product_history/client_id=' . $client_id . '&product_id=' . $row['product_id'] . '&campaign_id=' . (int) ( $row['campaign_id'] ?? 0 ) . '&ad_group_id=' . (int) ( $row['ad_group_id'] ?? 0 ) . '" target="_blank" class="' . $custom_class . '">
' . $row['name'] . ' ' . $row['name'] . '
@@ -317,7 +786,11 @@ class Products
'<input type="text" class="form-control min_roas" product_id="' . $row['product_id'] . '" value="' . $row['min_roas'] . '" style="width: 100px;">', '<input type="text" class="form-control min_roas" product_id="' . $row['product_id'] . '" value="' . $row['min_roas'] . '" style="width: 100px;">',
'', '',
'<input type="text" class="form-control custom_label_4" product_id="' . $row['product_id'] . '" value="' . $custom_label_4 . '" style="' . $custom_label_4_color . '">', '<input type="text" class="form-control custom_label_4" product_id="' . $row['product_id'] . '" value="' . $custom_label_4 . '" style="' . $custom_label_4_color . '">',
'<button type="button" class="btn btn-danger btn-sm delete-product" product_id="' . $row['product_id'] . '"><i class="fa-solid fa-trash"></i></button>' '<div class="btn-group btn-group-sm products-row-actions" role="group">'
. '<button type="button" class="btn btn-primary assign-product-scope" product_id="' . $row['product_id'] . '" title="Dodaj produkt do kampanii/grupy"><i class="fa-solid fa-diagram-project"></i></button>'
. '<button type="button" class="btn btn-secondary view-merchant-logs" product_id="' . $row['product_id'] . '" title="Pokaż logi synchronizacji Merchant"><i class="fa-solid fa-clock-rotate-left"></i></button>'
. '<button type="button" class="btn btn-danger delete-product" product_id="' . $row['product_id'] . '" title="Usuń produkt"><i class="fa-solid fa-trash"></i></button>'
. '</div>'
]; ];
} }
@@ -368,9 +841,17 @@ class Products
{ {
$product_id = \S::get( 'product_id' ); $product_id = \S::get( 'product_id' );
$custom_label_4 = \S::get( 'custom_label_4' ); $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 ) ) 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 ); \factory\Products::add_product_comment( $product_id, 'Zmiana etykiety 4 na: ' . $custom_label_4 );
echo json_encode( [ 'status' => 'ok' ] ); echo json_encode( [ 'status' => 'ok' ] );
} }
@@ -423,7 +904,7 @@ class Products
{ {
$comment_html = '<div class="comment-cell"> $comment_html = '<div class="comment-cell">
<span class="comment-text">' . htmlspecialchars( $comment_data['comment'] ) . '</span> <span class="comment-text">' . htmlspecialchars( $comment_data['comment'] ) . '</span>
<a href="#" class="text-danger delete-comment" data-comment-id="' . $comment_data['id'] . '" style="margin-left: 10px;">Usuń</a> <a href="#" class="delete-comment" data-comment-id="' . $comment_data['id'] . '">Usuń</a>
</div>'; </div>';
} }
@@ -524,18 +1005,38 @@ class Products
$google_product_category = \S::get( 'google_product_category' ); $google_product_category = \S::get( 'google_product_category' );
$product_url = \S::get( 'product_url' ); $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 ( $product_id )
{ {
if ( $custom_title ) if ( $custom_title )
{
\factory\Products::set_product_data( $product_id, 'title', $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 ) if ( $custom_description )
{
\factory\Products::set_product_data( $product_id, 'description', $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 ) if ( $google_product_category )
{
\factory\Products::set_product_data( $product_id, 'google_product_category', $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 ?: '' ); \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.' ); \factory\Products::add_product_comment( $product_id, 'Zmiana tytułu i opisu produktu.' );

View File

@@ -71,10 +71,33 @@ class Users
} }
return \view\Users::settings( 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() public static function settings_save_google_ads()
{ {
$fields = [ $fields = [
@@ -82,6 +105,7 @@ class Users
'google_ads_client_id', 'google_ads_client_id',
'google_ads_client_secret', 'google_ads_client_secret',
'google_ads_refresh_token', 'google_ads_refresh_token',
'google_merchant_refresh_token',
'google_ads_manager_account_id', 'google_ads_manager_account_id',
]; ];
@@ -90,9 +114,13 @@ class Users
\services\GoogleAdsApi::set_setting( $field, \S::get( $field ) ); \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 // wyczyść cached token przy zmianie credentials
\services\GoogleAdsApi::set_setting( 'google_ads_access_token', null ); \services\GoogleAdsApi::set_setting( 'google_ads_access_token', null );
\services\GoogleAdsApi::set_setting( 'google_ads_access_token_expires', 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.' ); \S::alert( 'Ustawienia Google Ads zostały zapisane.' );
header( 'Location: /settings' ); header( 'Location: /settings' );
@@ -121,6 +149,293 @@ class Users
exit; 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() public static function login()
{ {
if ( $user = \factory\Users::login( if ( $user = \factory\Users::login(
@@ -155,4 +470,4 @@ class Users
return \Tpl::view( 'users/login-form' ); return \Tpl::view( 'users/login-form' );
} }
} }

View File

@@ -0,0 +1,38 @@
<?php
namespace controls;
class XmlFiles
{
static public function main_view()
{
return \Tpl::view( 'xml_files/main_view', [
'rows' => \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;
}
}

View File

@@ -107,6 +107,8 @@ class Campaigns
st.id, st.id,
st.campaign_id, st.campaign_id,
st.ad_group_id, st.ad_group_id,
c.campaign_name,
c.advertising_channel_type,
ag.ad_group_name, ag.ad_group_name,
st.search_term, st.search_term,
st.impressions_30, st.impressions_30,
@@ -122,6 +124,7 @@ class Campaigns
st.conversion_value_all_time, st.conversion_value_all_time,
st.roas_all_time st.roas_all_time
FROM campaign_search_terms AS st 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 LEFT JOIN campaign_ad_groups AS ag ON ag.id = st.ad_group_id
WHERE st.campaign_id = :campaign_id'; WHERE st.campaign_id = :campaign_id';
@@ -167,6 +170,46 @@ class Campaigns
return $mdb -> query( $sql, $params ) -> fetchAll( \PDO::FETCH_ASSOC ); 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 ) static public function get_search_term_context( $search_term_row_id )
{ {
global $mdb; global $mdb;
@@ -242,6 +285,38 @@ class Campaigns
return (int) $mdb -> id(); 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 ) static public function delete_campaign( $campaign_id )
{ {
global $mdb; global $mdb;

View File

@@ -88,32 +88,64 @@ class Products
$order_dir = strtoupper( (string) $order_dir ) === 'ASC' ? 'ASC' : 'DESC'; $order_dir = strtoupper( (string) $order_dir ) === 'ASC' ? 'ASC' : 'DESC';
$order_map = [ $order_map = [
'offer_id' => 'p.offer_id', 'offer_id' => 'offer_id',
'campaign_name' => 'c.campaign_name', 'campaign_name' => 'campaign_name',
'ad_group_name' => 'ag.ad_group_name', 'ad_group_name' => 'ad_group_name',
'name' => 'pt.name', 'name' => 'name',
'impressions' => 'pt.impressions', 'impressions' => 'impressions',
'impressions_30' => 'pt.impressions_30', 'impressions_30' => 'impressions_30',
'clicks' => 'pt.clicks', 'clicks' => 'clicks',
'clicks_30' => 'pt.clicks_30', 'clicks_30' => 'clicks_30',
'ctr' => 'pt.ctr', 'ctr' => 'ctr',
'cost' => 'pt.cost', 'cost' => 'cost',
'cpc' => 'pt.cpc', 'cpc' => 'cpc',
'conversions' => 'pt.conversions', 'conversions' => 'conversions',
'conversions_value' => 'pt.conversions_value', 'conversions_value' => 'conversions_value',
'roas' => 'pt.roas', 'roas' => 'roas',
'min_roas' => 'p.min_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 ]; $params = [ ':client_id' => (int) $client_id ];
$sql = 'SELECT pt.*, p.offer_id, p.min_roas, $sql = 'SELECT
COALESCE( c.campaign_name, \'--- brak kampanii ---\' ) AS campaign_name, p.id AS product_id,
p.offer_id,
p.min_roas,
pt.campaign_id,
CASE CASE
WHEN pt.ad_group_id = 0 THEN \'PMax (bez grup reklam)\' WHEN COUNT( DISTINCT pt.campaign_id ) > 1 THEN \'--- wiele kampanii ---\'
ELSE COALESCE( ag.ad_group_name, \'--- brak grupy reklam ---\' ) ELSE COALESCE( MAX( c.campaign_name ), \'--- brak kampanii ---\' )
END AS ad_group_name 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 FROM products_temp AS pt
INNER JOIN products AS p ON p.id = pt.product_id INNER JOIN products AS p ON p.id = pt.product_id
LEFT JOIN campaigns AS c ON c.id = pt.campaign_id LEFT JOIN campaigns AS c ON c.id = pt.campaign_id
@@ -133,7 +165,8 @@ class Products
$params[':search'] = '%' . $search . '%'; $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 ); return $mdb -> query( $sql, $params ) -> fetchAll( \PDO::FETCH_ASSOC );
} }
@@ -178,12 +211,14 @@ class Products
global $mdb; global $mdb;
$params = [ ':client_id' => (int) $client_id ]; $params = [ ':client_id' => (int) $client_id ];
$sql = 'SELECT COUNT(0) $sql = 'SELECT COUNT(0)
FROM products_temp AS pt FROM (
INNER JOIN products AS p ON p.id = pt.product_id SELECT p.id, pt.campaign_id
LEFT JOIN campaigns AS c ON c.id = pt.campaign_id FROM products_temp AS pt
LEFT JOIN campaign_ad_groups AS ag ON ag.id = pt.ad_group_id INNER JOIN products AS p ON p.id = pt.product_id
WHERE p.client_id = :client_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 ); self::build_scope_filters( $sql, $params, $campaign_id, $ad_group_id );
@@ -198,6 +233,9 @@ class Products
$params[':search'] = '%' . $search . '%'; $params[':search'] = '%' . $search . '%';
} }
$sql .= ' GROUP BY p.id, pt.campaign_id
) AS grouped_rows';
return $mdb -> query( $sql, $params ) -> fetchColumn(); return $mdb -> query( $sql, $params ) -> fetchColumn();
} }
@@ -246,6 +284,177 @@ class Products
return $mdb -> get( 'products_data', $field, [ 'product_id' => $product_id ] ); 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 ) static public function set_product_data( $product_id, $field, $value )
{ {
global $mdb; global $mdb;
@@ -406,4 +615,239 @@ class Products
else else
return $mdb -> insert( 'products_comments', [ 'product_id' => $product_id, 'comment' => $comment, 'date_add' => $date ] ); 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
] );
}
} }

View File

@@ -0,0 +1,43 @@
<?php
namespace factory;
class XmlFiles
{
static public function get_clients_with_xml_feed()
{
global $mdb;
$clients = $mdb -> 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;
}
}

View File

@@ -153,6 +153,31 @@ Twoje odpowiedzi muszą być:
static public function suggest_title( $context ) static public function suggest_title( $context )
{ {
$context_text = self::build_context_text( $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. $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 - 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.) - 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ń.'; 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 ); $context_text = self::build_context_text( $context );
$has_page = !empty( $context['page_content'] ); $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 $length_guide = $has_page
? '- Napisz rozbudowany, szczegółowy opis: ok. 1000 znaków (800-1200) ? '- 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 - 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 - 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ń.'; Zwróć TYLKO opis w formacie HTML (używając dozwolonych tagów), bez cudzysłowów, bez wyjaśnień.';

File diff suppressed because it is too large Load Diff

View File

@@ -98,13 +98,14 @@ Twoje odpowiedzi muszą być:
return $text; 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' ); $api_key = GoogleAdsApi::get_setting( 'openai_api_key' );
$model = GoogleAdsApi::get_setting( 'openai_model' ) ?: 'gpt-5-mini'; $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 // 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 = [ $payload = [
'model' => $model, 'model' => $model,
@@ -112,10 +113,20 @@ Twoje odpowiedzi muszą być:
[ 'role' => 'system', 'content' => $system_prompt ], [ 'role' => 'system', 'content' => $system_prompt ],
[ 'role' => 'user', 'content' => $user_prompt ] [ 'role' => 'user', 'content' => $user_prompt ]
], ],
'temperature' => 0.7,
$tokens_key => $max_tokens $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 ); $ch = curl_init( self::$api_url );
curl_setopt_array( $ch, [ curl_setopt_array( $ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
@@ -187,6 +198,31 @@ Twoje odpowiedzi muszą być:
static public function suggest_title( $context ) static public function suggest_title( $context )
{ {
$context_text = self::build_context_text( $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. $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 - 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.) - 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ń.'; 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 ); $context_text = self::build_context_text( $context );
$has_page = !empty( $context['page_content'] ); $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 $length_guide = $has_page
? '- Napisz rozbudowany, szczegółowy opis: ok. 1000 znaków (800-1200) ? '- 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 - 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 - 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ń.'; 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 ); 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 ] );
}
} }

View File

@@ -9,10 +9,11 @@ class Users
return $tpl -> render( 'users/points-history' ); return $tpl -> render( 'users/points-history' );
} }
public static function settings( $user ) public static function settings( $user, $cron_data = [] )
{ {
$tpl = new \Tpl; $tpl = new \Tpl;
$tpl -> user = $user; $tpl -> user = $user;
$tpl -> cron_data = $cron_data;
return $tpl -> render( 'users/settings' ); return $tpl -> render( 'users/settings' );
} }
} }

View File

@@ -3,8 +3,12 @@ $database['name'] = 'host700513_adspro';
$database['host'] = 'localhost'; $database['host'] = 'localhost';
$database['user'] = 'host700513_adspro'; $database['user'] = 'host700513_adspro';
$database['password'] = '2Ug7DvBy5MCAJtKmkCRs'; $database['password'] = '2Ug7DvBy5MCAJtKmkCRs';
$database['remote_host'] = 'host700513.hostido.net.pl';
$settings['email_host'] = 'mail.project-pro.pl'; $settings['email_host'] = 'mail.project-pro.pl';
$settings['email_port'] = 25; $settings['email_port'] = 25;
$settings['email_login'] = 'www@project-pro.pl'; $settings['email_login'] = 'www@project-pro.pl';
$settings['email_password'] = 'ProjectPro2025!'; $settings['email_password'] = 'ProjectPro2025!';
$settings['cron_products_clients_per_run'] = 1;
$settings['cron_campaigns_clients_per_run'] = 1;

View File

@@ -43,6 +43,10 @@ $mdb = new medoo( [
'charset' => 'utf8' '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::setup( 'mysql:host=' . $database['host'] . ';dbname=' . $database['name'], $database['user'], $database['password'] );
\R::ext( 'xdispense', function( $type ) \R::ext( 'xdispense', function( $type )
{ {

View File

@@ -63,6 +63,7 @@ CREATE TABLE IF NOT EXISTS `clients` (
`id` int(11) NOT NULL AUTO_INCREMENT, `id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL DEFAULT '0', `name` varchar(255) NOT NULL DEFAULT '0',
`google_ads_customer_id` varchar(20) DEFAULT NULL, `google_ads_customer_id` varchar(20) DEFAULT NULL,
`google_merchant_account_id` varchar(32) DEFAULT NULL,
`google_ads_start_date` date DEFAULT NULL, `google_ads_start_date` date DEFAULT NULL,
`deleted` int(11) DEFAULT 0, `deleted` int(11) DEFAULT 0,
`bestseller_min_roas` int(11) DEFAULT NULL, `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`) KEY `idx_campaign_search_terms_ad_group_id` (`ad_group_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) 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` ( CREATE TABLE IF NOT EXISTS `campaign_negative_keywords` (
`id` int(11) NOT NULL AUTO_INCREMENT, `id` int(11) NOT NULL AUTO_INCREMENT,
`campaign_id` int(11) NOT NULL, `campaign_id` int(11) NOT NULL,

View File

@@ -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`) - 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` - Negatywne slowa kluczowe dodawane przez Google Ads API i zapisywane lokalnie w `campaign_negative_keywords`
- Klucze API przechowywane w tabeli `settings` (key-value) - 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 ## Preferencje uzytkownika

View File

@@ -43,6 +43,7 @@ $route_aliases = [
'logout' => ['users', 'logout'], 'logout' => ['users', 'logout'],
'settings' => ['users', 'settings'], 'settings' => ['users', 'settings'],
'settings/save' => ['users', 'settings_save'], 'settings/save' => ['users', 'settings_save'],
'settings/cron_status' => ['users', 'settings_cron_status'],
'settings/save_google_ads' => ['users', 'settings_save_google_ads'], 'settings/save_google_ads' => ['users', 'settings_save_google_ads'],
'settings/save_openai' => ['users', 'settings_save_openai'], 'settings/save_openai' => ['users', 'settings_save_openai'],
'settings/save_claude' => ['users', 'settings_save_claude'], 'settings/save_claude' => ['users', 'settings_save_claude'],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -188,7 +188,12 @@
line-height: 1.4; line-height: 1.4;
} }
.adspro-dialog-btn:focus { .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 */ /* Klasy przycisków */

View File

@@ -48,6 +48,7 @@
this._appendToBody(); this._appendToBody();
this._applyAutoClose(); this._applyAutoClose();
this._triggerContentReady(); this._triggerContentReady();
this._focusInitialElement();
activeDialogs.push( this ); activeDialogs.push( this );
}, },
@@ -72,7 +73,7 @@
'<div class="adspro-dialog-bg"></div>' + '<div class="adspro-dialog-bg"></div>' +
'<div class="adspro-dialog-scrollpane">' + '<div class="adspro-dialog-scrollpane">' +
'<div class="adspro-dialog-center' + sizeClass + '">' + '<div class="adspro-dialog-center' + sizeClass + '">' +
'<div class="adspro-dialog-box jconfirm-box' + typeClass + '" style="' + sizeStyle + '">' + '<div class="adspro-dialog-box jconfirm-box' + typeClass + '" style="' + sizeStyle + '" tabindex="-1">' +
this._buildCloseIcon( o ) + this._buildCloseIcon( o ) +
this._buildHeader( o ) + this._buildHeader( o ) +
'<div class="adspro-dialog-content-pane">' + '<div class="adspro-dialog-content-pane">' +
@@ -199,7 +200,7 @@
} }
if ( e.key === 'Enter' ) if ( e.key === 'Enter' )
{ {
if ( $( e.target ).is( 'textarea, select' ) ) return; if ( $( e.target ).is( '.adspro-dialog-btn, textarea, select, input' ) ) return;
var $enterBtn = self.$el.find( '.adspro-dialog-btn[data-enter-key="true"]' ); var $enterBtn = self.$el.find( '.adspro-dialog-btn[data-enter-key="true"]' );
if ( $enterBtn.length ) if ( $enterBtn.length )
{ {
@@ -262,6 +263,40 @@
} }
}, },
_focusInitialElement: function()
{
var self = this;
setTimeout( function()
{
if ( self._closed ) return;
if ( $( document.activeElement ).closest( self.$el ).length ) return;
var $focusTarget = self.$el.find( '[autofocus]:visible:not(:disabled):first' );
if ( !$focusTarget.length )
{
$focusTarget = self.$el.find( '.adspro-dialog-content input:visible:not(:disabled):first, .adspro-dialog-content select:visible:not(:disabled):first, .adspro-dialog-content textarea:visible:not(:disabled):first' ).first();
}
if ( !$focusTarget.length )
{
$focusTarget = self.$el.find( '.adspro-dialog-btn[data-enter-key="true"]:visible:not(:disabled):first' );
}
if ( !$focusTarget.length )
{
$focusTarget = self.$el.find( '.adspro-dialog-btn:visible:not(:disabled):first' );
}
if ( !$focusTarget.length )
{
$focusTarget = self.$box;
}
if ( $focusTarget.length )
{
$focusTarget.trigger( 'focus' );
}
}, 20 );
},
// --- Metody publiczne --- // --- Metody publiczne ---
close: function() close: function()

View File

@@ -0,0 +1,2 @@
ALTER TABLE `clients`
ADD COLUMN `google_merchant_account_id` varchar(32) DEFAULT NULL AFTER `google_ads_customer_id`;

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS `products_keyword_planner_terms` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`product_id` INT(11) NOT NULL,
`source_url` VARCHAR(1024) NOT NULL,
`keyword_text` VARCHAR(255) NOT NULL,
`avg_monthly_searches` INT(11) NOT NULL DEFAULT 0,
`competition` VARCHAR(32) DEFAULT NULL,
`competition_index` INT(11) DEFAULT NULL,
`source_customer_id` VARCHAR(32) DEFAULT NULL,
`date_add` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_products_keyword_planner_terms` (`product_id`,`source_url`(255),`keyword_text`),
KEY `idx_products_keyword_product` (`product_id`),
KEY `idx_products_keyword_popularity` (`product_id`,`avg_monthly_searches`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

View File

@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS `products_merchant_sync_log` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`product_id` INT(11) NOT NULL,
`field_name` VARCHAR(64) NOT NULL,
`old_value` TEXT NULL,
`new_value` TEXT NULL,
`sync_status` VARCHAR(16) NOT NULL DEFAULT 'pending',
`sync_source` VARCHAR(32) NOT NULL DEFAULT 'products_ui',
`merchant_account_id` VARCHAR(32) NULL,
`merchant_product_id` VARCHAR(255) NULL,
`offer_id` VARCHAR(255) NULL,
`api_response` MEDIUMTEXT NULL,
`error_message` TEXT NULL,
`date_add` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_products_merchant_sync_product` (`product_id`),
KEY `idx_products_merchant_sync_field` (`field_name`),
KEY `idx_products_merchant_sync_status` (`sync_status`),
KEY `idx_products_merchant_sync_date` (`date_add`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

View File

@@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS `campaign_keywords` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`campaign_id` int(11) NOT NULL,
`ad_group_id` int(11) NOT NULL,
`keyword_text` varchar(255) NOT NULL,
`match_type` varchar(40) DEFAULT NULL,
`impressions_30` int(11) NOT NULL DEFAULT 0,
`clicks_30` int(11) NOT NULL DEFAULT 0,
`cost_30` decimal(20,6) NOT NULL DEFAULT 0.000000,
`conversions_30` decimal(20,6) NOT NULL DEFAULT 0.000000,
`conversion_value_30` decimal(20,6) NOT NULL DEFAULT 0.000000,
`roas_30` decimal(20,6) NOT NULL DEFAULT 0.000000,
`impressions_all_time` int(11) NOT NULL DEFAULT 0,
`clicks_all_time` int(11) NOT NULL DEFAULT 0,
`cost_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000,
`conversions_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000,
`conversion_value_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000,
`roas_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000,
`date_sync` date DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_campaign_keywords` (`campaign_id`,`ad_group_id`,`keyword_text`(191),`match_type`),
KEY `idx_campaign_keywords_campaign_id` (`campaign_id`),
KEY `idx_campaign_keywords_ad_group_id` (`ad_group_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@
<th style="width: 60px;">#ID</th> <th style="width: 60px;">#ID</th>
<th>Nazwa klienta</th> <th>Nazwa klienta</th>
<th>Google Ads Customer ID</th> <th>Google Ads Customer ID</th>
<th>Merchant Account ID</th>
<th>Dane od</th> <th>Dane od</th>
<th style="width: 120px; text-align: center;">Akcje</th> <th style="width: 120px; text-align: center;">Akcje</th>
</tr> </tr>
@@ -30,6 +31,13 @@
<span class="text-muted">— brak —</span> <span class="text-muted">— brak —</span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td>
<?php if ( !empty( $client['google_merchant_account_id'] ) ): ?>
<span class="badge-id"><?= htmlspecialchars( $client['google_merchant_account_id'] ); ?></span>
<?php else: ?>
<span class="text-muted">— brak —</span>
<?php endif; ?>
</td>
<td> <td>
<?php if ( $client['google_ads_start_date'] ): ?> <?php if ( $client['google_ads_start_date'] ): ?>
<?= $client['google_ads_start_date']; ?> <?= $client['google_ads_start_date']; ?>
@@ -49,7 +57,7 @@
<?php endforeach; ?> <?php endforeach; ?>
<?php else: ?> <?php else: ?>
<tr> <tr>
<td colspan="5" class="empty-state"> <td colspan="6" class="empty-state">
<i class="fa-solid fa-building"></i> <i class="fa-solid fa-building"></i>
<p>Brak klientów. Dodaj pierwszego klienta.</p> <p>Brak klientów. Dodaj pierwszego klienta.</p>
</td> </td>
@@ -78,6 +86,11 @@
<label for="client-gads-id">Google Ads Customer ID</label> <label for="client-gads-id">Google Ads Customer ID</label>
<input type="text" id="client-gads-id" name="google_ads_customer_id" class="form-control" placeholder="np. 123-456-7890 (opcjonalnie)" /> <input type="text" id="client-gads-id" name="google_ads_customer_id" class="form-control" placeholder="np. 123-456-7890 (opcjonalnie)" />
</div> </div>
<div class="settings-field">
<label for="client-gmc-id">Merchant Account ID</label>
<input type="text" id="client-gmc-id" name="google_merchant_account_id" class="form-control" placeholder="np. 123456789 (opcjonalnie)" />
<small class="text-muted">ID konta Merchant Center przypisane do klienta</small>
</div>
<div class="settings-field"> <div class="settings-field">
<label for="client-gads-start">Pobieraj dane od</label> <label for="client-gads-start">Pobieraj dane od</label>
<input type="date" id="client-gads-start" name="google_ads_start_date" class="form-control" /> <input type="date" id="client-gads-start" name="google_ads_start_date" class="form-control" />
@@ -99,6 +112,7 @@ function openClientForm()
$( '#client-id' ).val( '' ); $( '#client-id' ).val( '' );
$( '#client-name' ).val( '' ); $( '#client-name' ).val( '' );
$( '#client-gads-id' ).val( '' ); $( '#client-gads-id' ).val( '' );
$( '#client-gmc-id' ).val( '' );
$( '#client-gads-start' ).val( '' ); $( '#client-gads-start' ).val( '' );
$( '#client-modal' ).fadeIn(); $( '#client-modal' ).fadeIn();
} }
@@ -115,6 +129,7 @@ function editClient( id )
$( '#client-id' ).val( data.id ); $( '#client-id' ).val( data.id );
$( '#client-name' ).val( data.name ); $( '#client-name' ).val( data.name );
$( '#client-gads-id' ).val( data.google_ads_customer_id || '' ); $( '#client-gads-id' ).val( data.google_ads_customer_id || '' );
$( '#client-gmc-id' ).val( data.google_merchant_account_id || '' );
$( '#client-gads-start' ).val( data.google_ads_start_date || '' ); $( '#client-gads-start' ).val( data.google_ads_start_date || '' );
$( '#client-modal' ).fadeIn(); $( '#client-modal' ).fadeIn();
} ); } );
@@ -126,10 +141,17 @@ function deleteClient( id, name )
title: 'Potwierdzenie', title: 'Potwierdzenie',
content: 'Czy na pewno chcesz usunąć klienta <strong>' + name + '</strong>?', content: 'Czy na pewno chcesz usunąć klienta <strong>' + name + '</strong>?',
type: 'red', type: 'red',
onContentReady: function() {
if ( this['$$confirm'] && this['$$confirm'].length )
{
this['$$confirm'].trigger( 'focus' );
}
},
buttons: { buttons: {
confirm: { confirm: {
text: 'Usuń', text: 'Usuń',
btnClass: 'btn-red', btnClass: 'btn-red',
keys: [ 'enter' ],
action: function() { action: function() {
$.post( '/clients/delete', { id: id }, function( response ) { $.post( '/clients/delete', { id: id }, function( response ) {
var data = JSON.parse( response ); var data = JSON.parse( response );

View File

@@ -56,6 +56,7 @@
<th>Id oferty</th> <th>Id oferty</th>
<th>Kampania</th> <th>Kampania</th>
<th>Grupa reklam</th> <th>Grupa reklam</th>
<th>URL</th>
<th>Nazwa produktu</th> <th>Nazwa produktu</th>
<th>Wyśw.</th> <th>Wyśw.</th>
<th>Wyśw. (30d)</th> <th>Wyśw. (30d)</th>
@@ -83,63 +84,11 @@
$openai_enabled = \services\GoogleAdsApi::get_setting( 'openai_enabled' ) !== '0'; $openai_enabled = \services\GoogleAdsApi::get_setting( 'openai_enabled' ) !== '0';
$claude_enabled = \services\GoogleAdsApi::get_setting( 'claude_enabled' ) !== '0'; $claude_enabled = \services\GoogleAdsApi::get_setting( 'claude_enabled' ) !== '0';
?> ?>
<style>
.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;
}
.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: '\25BC';
float: right;
font-size: 10px;
color: #64748B;
margin-top: 2px;
}
.products-columns-control[open] summary::after {
content: '\25B2';
}
.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;
}
</style>
<script type="text/javascript"> <script type="text/javascript">
var AI_OPENAI_ENABLED = <?= $openai_enabled ? 'true' : 'false'; ?>; var AI_OPENAI_ENABLED = <?= $openai_enabled ? 'true' : 'false'; ?>;
var AI_CLAUDE_ENABLED = <?= $claude_enabled ? 'true' : 'false'; ?>; var AI_CLAUDE_ENABLED = <?= $claude_enabled ? 'true' : 'false'; ?>;
var PRODUCTS_COLUMNS_STORAGE_KEY = 'products.columns.visibility'; var PRODUCTS_COLUMNS_STORAGE_KEY = 'products.columns.visibility';
var PRODUCTS_LOCKED_COLUMNS = [ 0, 19 ]; var PRODUCTS_LOCKED_COLUMNS = [ 0, 20 ];
function show_toast( message, type ) function show_toast( message, type )
{ {
@@ -160,6 +109,11 @@ function show_toast( message, type )
}, 3000 ); }, 3000 );
} }
function escape_html( value )
{
return $( '<div>' ).text( value == null ? '' : String( value ) ).html();
}
var GOOGLE_TAXONOMY_ENDPOINT = '/tools/google-taxonomy.php'; var GOOGLE_TAXONOMY_ENDPOINT = '/tools/google-taxonomy.php';
var googleCategories = []; var googleCategories = [];
@@ -317,7 +271,8 @@ function products_render_columns_picker( table_instance )
} }
var id = 'products-col-toggle-' + i; var id = 'products-col-toggle-' + i;
var th = $( '#products thead th' ).eq( i ); var header_node = table_instance.column( i ).header();
var th = header_node ? $( header_node ) : $();
var title = $.trim( th.find( '.dt-column-title' ).first().text() || th.text() ) || ( 'Kolumna ' + i ); var title = $.trim( th.find( '.dt-column-title' ).first().text() || th.text() ) || ( 'Kolumna ' + i );
var checked = table_instance.column( i ).visible() ? ' checked' : ''; var checked = table_instance.column( i ).visible() ? ' checked' : '';
@@ -356,6 +311,7 @@ $( function()
{ width: '80px', name: 'offer_id' }, { width: '80px', name: 'offer_id' },
{ width: '200px', name: 'campaign_name' }, { width: '200px', name: 'campaign_name' },
{ width: '200px', name: 'ad_group_name' }, { width: '200px', name: 'ad_group_name' },
{ width: '120px', orderable: false, searchable: false },
{ name: 'name' }, { name: 'name' },
{ width: '50px', name: 'impressions' }, { width: '50px', name: 'impressions' },
{ width: '80px', name: 'impressions_30' }, { width: '80px', name: 'impressions_30' },
@@ -370,12 +326,12 @@ $( function()
{ width: '70px', name: 'min_roas' }, { width: '70px', name: 'min_roas' },
{ width: '50px', name: 'cl3', orderable: false }, { width: '50px', name: 'cl3', orderable: false },
{ width: '120px', orderable: false }, { width: '120px', orderable: false },
{ width: '50px', orderable: false, className: 'dt-center' } { width: '190px', orderable: false, className: 'dt-center' }
], ],
order: [ [ 8, 'desc' ] ], order: [ [ 9, 'desc' ] ],
language: { language: {
processing: '£adowanie...', processing: 'Ładowanie...',
emptyTable: 'Brak produktów do wyœwietlenia', emptyTable: 'Brak produktów do wyświetlenia',
info: 'Produkty _START_ - _END_ z _TOTAL_', info: 'Produkty _START_ - _END_ z _TOTAL_',
infoEmpty: '', infoEmpty: '',
paginate: { paginate: {
@@ -466,6 +422,308 @@ $( function()
} ); } );
} }
$( 'body' ).on( 'click', '.assign-product-scope', function( e )
{
e.preventDefault();
var product_id = $( this ).attr( 'product_id' );
var client_id = $( '#client_id' ).val() || '';
if ( !client_id )
{
$.alert({ title: 'Brak klienta', content: 'Najpierw wybierz klienta, aby przypisać produkt do kampanii i grupy reklam.', type: 'orange' });
return;
}
$.confirm({
title: 'Dodaj produkt do kampanii/grupy',
content: '' +
'<form class="assign-product-form">' +
'<div class="assign-step assign-step-1">' +
'<h4 style="margin-top:0">Krok 1 z 2: Kampania</h4>' +
'<div class="form-group">' +
'<label style="display:block"><input type="radio" name="campaign_mode" value="existing" checked> Istniejąca kampania</label>' +
'<select class="form-control assign-campaign-id" style="margin-top:8px"><option value="">— wybierz kampanię —</option></select>' +
'</div>' +
'<div class="form-group">' +
'<label style="display:block"><input type="radio" name="campaign_mode" value="new"> Nowa kampania</label>' +
'<input type="text" class="form-control assign-campaign-name" placeholder="Nazwa nowej kampanii" style="margin-top:8px;display:none">' +
'<div class="assign-new-campaign-options" style="display:none;margin-top:8px">' +
'<div style="display:flex;gap:8px">' +
'<input type="number" min="1" step="0.01" class="form-control assign-campaign-budget" value="50.00" placeholder="Budżet dzienny (np. 50.00 PLN)">' +
'<input type="number" min="0.1" step="0.01" class="form-control assign-default-cpc" value="1.00" placeholder="Domyślne CPC (np. 1.00 PLN)">' +
'</div>' +
'<small class="text-muted">Dotyczy tylko tworzenia nowej kampanii Standard Shopping.</small>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="assign-step assign-step-2" style="display:none">' +
'<h4 style="margin-top:0">Krok 2 z 2: Grupa reklam</h4>' +
'<div class="form-group">' +
'<label style="display:block"><input type="radio" name="ad_group_mode" value="existing" checked> Istniejąca grupa reklam</label>' +
'<select class="form-control assign-ad-group-id" style="margin-top:8px"><option value="">— wybierz grupę reklam —</option></select>' +
'</div>' +
'<div class="form-group">' +
'<label style="display:block"><input type="radio" name="ad_group_mode" value="new"> Nowa grupa reklam</label>' +
'<input type="text" class="form-control assign-ad-group-name" placeholder="Nazwa nowej grupy reklam" style="margin-top:8px;display:none">' +
'</div>' +
'</div>' +
'</form>',
useBootstrap: false,
boxWidth: '720px',
theme: 'modern',
buttons: {
back: {
text: 'Wstecz',
isHidden: true,
action: function() {
this.$content.find( '.assign-step-1' ).show();
this.$content.find( '.assign-step-2' ).hide();
this.$$back.hide();
this.$$next.show();
this.$$save.hide();
return false;
}
},
next: {
text: 'Dalej',
btnClass: 'btn-blue',
action: function() {
var $content = this.$content;
var campaign_mode = $content.find( 'input[name="campaign_mode"]:checked' ).val();
var campaign_id = $content.find( '.assign-campaign-id' ).val();
var campaign_name = $.trim( $content.find( '.assign-campaign-name' ).val() );
var campaign_daily_budget = parseFloat( $content.find( '.assign-campaign-budget' ).val() || '0' );
var default_cpc = parseFloat( $content.find( '.assign-default-cpc' ).val() || '0' );
if ( campaign_mode === 'existing' && !campaign_id )
{
$.alert( 'Wybierz kampanię.' );
return false;
}
if ( campaign_mode === 'new' && !campaign_name )
{
$.alert( 'Podaj nazwę nowej kampanii.' );
return false;
}
if ( campaign_mode === 'new' && ( isNaN( campaign_daily_budget ) || campaign_daily_budget <= 0 ) )
{
$.alert( 'Podaj poprawny budżet dzienny (większy od 0).' );
return false;
}
if ( campaign_mode === 'new' && ( isNaN( default_cpc ) || default_cpc <= 0 ) )
{
$.alert( 'Podaj poprawne domyślne CPC (większe od 0).' );
return false;
}
this.$content.find( '.assign-step-1' ).hide();
this.$content.find( '.assign-step-2' ).show();
this.$$back.show();
this.$$next.hide();
this.$$save.show();
return false;
}
},
save: {
text: 'Zapisz',
btnClass: 'btn-green',
isHidden: true,
action: function() {
var jc = this;
var $content = jc.$content;
var campaign_mode = $content.find( 'input[name="campaign_mode"]:checked' ).val();
var campaign_id = $content.find( '.assign-campaign-id' ).val() || '';
var campaign_name = $.trim( $content.find( '.assign-campaign-name' ).val() );
var campaign_daily_budget = parseFloat( $content.find( '.assign-campaign-budget' ).val() || '0' );
var default_cpc = parseFloat( $content.find( '.assign-default-cpc' ).val() || '0' );
var ad_group_mode = $content.find( 'input[name="ad_group_mode"]:checked' ).val();
var ad_group_id = $content.find( '.assign-ad-group-id' ).val() || '';
var ad_group_name = $.trim( $content.find( '.assign-ad-group-name' ).val() );
if ( campaign_mode === 'existing' && !campaign_id )
{
$.alert( 'Wybierz kampanię.' );
return false;
}
if ( campaign_mode === 'new' && !campaign_name )
{
$.alert( 'Podaj nazwę nowej kampanii.' );
return false;
}
if ( campaign_mode === 'new' && ( isNaN( campaign_daily_budget ) || campaign_daily_budget <= 0 ) )
{
$.alert( 'Podaj poprawny budżet dzienny (większy od 0).' );
return false;
}
if ( campaign_mode === 'new' && ( isNaN( default_cpc ) || default_cpc <= 0 ) )
{
$.alert( 'Podaj poprawne domyślne CPC (większe od 0).' );
return false;
}
if ( ad_group_mode === 'existing' && !ad_group_id )
{
$.alert( 'Wybierz grupę reklam.' );
return false;
}
if ( ad_group_mode === 'new' && !ad_group_name )
{
$.alert( 'Podaj nazwę nowej grupy reklam.' );
return false;
}
jc.showLoading( true );
$.ajax({
url: '/products/assign_product_scope/',
type: 'POST',
dataType: 'json',
data: {
product_id: product_id,
campaign_mode: campaign_mode,
campaign_id: campaign_id,
campaign_name: campaign_name,
campaign_daily_budget: campaign_daily_budget,
default_cpc: default_cpc,
ad_group_mode: ad_group_mode,
ad_group_id: ad_group_id,
ad_group_name: ad_group_name
},
success: function( res ) {
jc.hideLoading();
if ( res && res.status === 'ok' )
{
jc.close();
reload_products_table();
show_toast( 'Produkt został przypisany do kampanii i grupy reklam.', 'success' );
}
else
{
show_toast( ( res && res.message ) ? res.message : 'Nie udało się zapisać przypisania.', 'error' );
}
},
error: function() {
jc.hideLoading();
show_toast( 'Błąd połączenia podczas przypisywania produktu.', 'error' );
}
});
return false;
}
},
cancel: {
text: 'Anuluj'
}
},
onContentReady: function() {
var jc = this;
var $content = jc.$content;
var $campaignModeInputs = $content.find( 'input[name="campaign_mode"]' );
var $campaignSelect = $content.find( '.assign-campaign-id' );
var $campaignName = $content.find( '.assign-campaign-name' );
var $newCampaignOptions = $content.find( '.assign-new-campaign-options' );
var $adGroupModeInputs = $content.find( 'input[name="ad_group_mode"]' );
var $adGroupSelect = $content.find( '.assign-ad-group-id' );
var $adGroupName = $content.find( '.assign-ad-group-name' );
function loadCampaignsForStep()
{
$campaignSelect.empty().append( '<option value="">— wybierz kampanię —</option>' );
return $.ajax({
url: '/products/get_campaigns_list/client_id=' + client_id,
type: 'GET',
dataType: 'json'
}).done( function( res ) {
( res.campaigns || [] ).forEach( function( row ) {
$campaignSelect.append( '<option value="' + row.id + '">' + escape_html( row.campaign_name || '' ) + '</option>' );
} );
} );
}
function loadAdGroupsForStep( campaign_id )
{
$adGroupSelect.empty().append( '<option value="">— wybierz grupę reklam —</option>' );
if ( !campaign_id )
{
return;
}
$.ajax({
url: '/products/get_campaign_ad_groups/campaign_id=' + campaign_id,
type: 'GET',
dataType: 'json',
success: function( res ) {
( res.ad_groups || [] ).forEach( function( row ) {
$adGroupSelect.append( '<option value="' + row.id + '">' + escape_html( row.ad_group_name || '' ) + '</option>' );
} );
}
});
}
function toggleCampaignMode()
{
var mode = $campaignModeInputs.filter( ':checked' ).val();
var isExisting = mode === 'existing';
$campaignSelect.toggle( isExisting );
$campaignName.toggle( !isExisting );
$newCampaignOptions.toggle( !isExisting );
if ( isExisting )
{
$adGroupModeInputs.filter( '[value="existing"]' ).prop( 'disabled', false );
loadAdGroupsForStep( $campaignSelect.val() || '' );
}
else
{
$adGroupModeInputs.filter( '[value="existing"]' ).prop( 'disabled', true );
$adGroupModeInputs.filter( '[value="new"]' ).prop( 'checked', true );
$adGroupSelect.empty().append( '<option value="">— wybierz grupę reklam —</option>' );
}
toggleAdGroupMode();
}
function toggleAdGroupMode()
{
var mode = $adGroupModeInputs.filter( ':checked' ).val();
var isExisting = mode === 'existing';
$adGroupSelect.toggle( isExisting );
$adGroupName.toggle( !isExisting );
}
$campaignModeInputs.on( 'change', toggleCampaignMode );
$adGroupModeInputs.on( 'change', toggleAdGroupMode );
$campaignSelect.on( 'change', function() {
if ( $campaignModeInputs.filter( ':checked' ).val() === 'existing' )
{
loadAdGroupsForStep( $( this ).val() || '' );
}
} );
loadCampaignsForStep().always( function() {
toggleCampaignMode();
} );
}
});
});
$( 'body' ).on( 'change', '#client_id', function() $( 'body' ).on( 'change', '#client_id', function()
{ {
var client_id = $( this ).val() || ''; var client_id = $( this ).val() || '';
@@ -516,6 +774,102 @@ $( function()
} ); } );
}); });
$( 'body' ).on( 'click', '.view-merchant-logs', function( e )
{
e.preventDefault();
var product_id = $( this ).attr( 'product_id' );
$.confirm({
title: 'Logi synchronizacji Merchant (produkt #' + product_id + ')',
content: '<div class="merchant-logs-wrap" style="max-height:460px;overflow:auto">Ładowanie logów...</div>',
useBootstrap: false,
boxWidth: '1100px',
theme: 'modern',
buttons: {
close: {
text: 'Zamknij',
btnClass: 'btn-blue'
}
},
onContentReady: function()
{
var jc = this;
var $wrap = jc.$content.find( '.merchant-logs-wrap' );
$.ajax({
url: '/products/get_product_merchant_sync_logs/',
type: 'POST',
data: { product_id: product_id, limit: 100 },
success: function( response )
{
var data;
try
{
data = JSON.parse( response );
}
catch ( err )
{
$wrap.html( '<div class="text-danger">Nie udało się odczytać odpowiedzi serwera.</div>' );
return;
}
if ( data.status !== 'ok' )
{
$wrap.html( '<div class="text-danger">' + escape_html( data.message || 'Błąd pobierania logów.' ) + '</div>' );
return;
}
if ( !data.logs || !data.logs.length )
{
$wrap.html( '<div class="text-muted">Brak logów synchronizacji dla tego produktu.</div>' );
return;
}
var rows_html = '';
$.each( data.logs, function( _, log ) {
var status_class = log.sync_status === 'success'
? 'text-success'
: ( log.sync_status === 'error' ? 'text-danger' : 'text-muted' );
rows_html += '<tr>' +
'<td>' + escape_html( log.date_add || '' ) + '</td>' +
'<td>' + escape_html( log.field_name || '' ) + '</td>' +
'<td class="' + status_class + '"><b>' + escape_html( log.sync_status || '' ) + '</b></td>' +
'<td>' + escape_html( log.sync_source || '' ) + '</td>' +
'<td>' + escape_html( log.old_value || '' ) + '</td>' +
'<td>' + escape_html( log.new_value || '' ) + '</td>' +
'<td>' + escape_html( log.error_message || '' ) + '</td>' +
'</tr>';
} );
$wrap.html(
'<table class="table table-sm table-bordered table-striped" style="font-size:12px;">' +
'<thead>' +
'<tr>' +
'<th style="min-width:140px;">Data</th>' +
'<th style="min-width:120px;">Pole</th>' +
'<th style="min-width:90px;">Status</th>' +
'<th style="min-width:110px;">Źródło</th>' +
'<th style="min-width:180px;">Stara wartość</th>' +
'<th style="min-width:180px;">Nowa wartość</th>' +
'<th style="min-width:220px;">Błąd</th>' +
'</tr>' +
'</thead>' +
'<tbody>' + rows_html + '</tbody>' +
'</table>'
);
},
error: function()
{
$wrap.html( '<div class="text-danger">Nie udało się pobrać logów synchronizacji.</div>' );
}
});
}
});
});
// Usuwanie produktu // Usuwanie produktu
$( 'body' ).on( 'click', '.delete-product', function( e ) $( 'body' ).on( 'click', '.delete-product', function( e )
{ {
@@ -584,7 +938,34 @@ $( function()
$.ajax({ $.ajax({
url: '/products/save_custom_label_4/', url: '/products/save_custom_label_4/',
type: 'POST', type: 'POST',
data: { product_id: product_id, custom_label_4: custom_label_4 } data: { product_id: product_id, custom_label_4: custom_label_4 },
success: function( response )
{
var data;
try
{
data = JSON.parse( response );
}
catch ( e )
{
show_toast( 'Custom Label 4: nieprawidłowa odpowiedź serwera.', 'error' );
return;
}
if ( data.status === 'ok' )
{
show_toast( 'Custom Label 4 zapisany.', 'success' );
}
else
{
show_toast( 'Custom Label 4: zapis nie powiódł się.', 'error' );
}
},
error: function()
{
show_toast( 'Custom Label 4: błąd połączenia podczas zapisu.', 'error' );
}
}); });
}); });
@@ -673,6 +1054,7 @@ $( function()
jc.hideLoading(); jc.hideLoading();
if ( data.status == 'ok' ) { if ( data.status == 'ok' ) {
jc.close(); jc.close();
reload_products_table();
show_toast( 'Dane produktu zostały zapisane.', 'success' ); show_toast( 'Dane produktu zostały zapisane.', 'success' );
} else { } else {
show_toast( 'Błąd: ' + response, 'error' ); show_toast( 'Błąd: ' + response, 'error' );
@@ -789,10 +1171,18 @@ $( function()
} }
if ( data.warning ) { if ( data.warning ) {
show_toast( providerLabel + ': ' + data.warning, 'error' ); show_toast( providerLabel + ': ' + data.warning, 'error' );
} else if ( data.page_fetched ) {
show_toast( providerLabel + ': Sugestia wygenerowana z treÅciÄ… strony produktu', 'success' );
} else { } else {
show_toast( providerLabel + ': Sugestia wygenerowana', 'success' ); var successMessage = data.page_fetched
? providerLabel + ': Sugestia wygenerowana z treścią strony produktu'
: providerLabel + ': Sugestia wygenerowana';
if ( ( field == 'title' || field == 'description' ) && data.keyword_planner_terms_used ) {
var kwCount = parseInt( data.keyword_planner_terms_count || 0, 10 );
var kwLabel = kwCount > 0 ? ' (' + kwCount + ' fraz)' : '';
successMessage += '; użyto fraz z Keyword Planner' + kwLabel;
}
show_toast( successMessage, 'success' );
} }
} else { } else {
show_toast( providerLabel + ': ' + ( data.message || 'Wystąpił błąd AI.' ), 'error' ); show_toast( providerLabel + ': ' + ( data.message || 'Wystąpił błąd AI.' ), 'error' );

View File

@@ -1,56 +1,60 @@
<div class="admin-form theme-primary"> <div class="products-page product-history-page">
<div class="panel heading-border panel-primary"> <div class="products-header">
<div class="panel-body"> <h2><i class="fa-solid fa-chart-line"></i> Historia produktu #<?= (int) $this -> product_id; ?></h2>
<div class="chart-with-form"> <a href="/products" class="btn btn-primary btn-sm">
<div class="chart-area"> <i class="fa-solid fa-arrow-left"></i> Powrot do produktow
<figure class="highcharts-figure"> </a>
<div id="container"></div> </div>
</figure>
</div>
<!-- PRAWY PANEL: formularz komentarza --> <div class="product-history-meta">
<aside class="comment-form admin-form theme-primary"> <span>Klient: #<?= (int) $this -> client_id; ?></span>
<form id="product-comment-form" autocomplete="off"> <span>Kampania: <?= (int) ( $this -> campaign_id ?? 0 ); ?></span>
<div class="form-group"> <span>Grupa reklam: <?= (int) ( $this -> ad_group_id ?? 0 ); ?></span>
<label for="comment_date">Data</label> </div>
<input type="date" id="comment_date" name="date" required>
</div> <div class="product-history-chart-wrap">
<div class="form-group"> <div class="chart-with-form">
<label for="comment_text">Komentarz</label> <div class="chart-area">
<textarea id="comment_text" name="comment" placeholder="Wpisz komentarz..." required></textarea> <div class="product-history-chart" id="container"></div>
</div>
<button type="submit" class="btn" id="save_comment">Zapisz komentarz</button>
</form>
</aside>
</div> </div>
<aside class="comment-form">
<form id="product-comment-form" autocomplete="off">
<div class="form-group">
<label for="comment_date">Data</label>
<input type="date" id="comment_date" name="date" required>
</div>
<div class="form-group">
<label for="comment_text">Komentarz</label>
<textarea id="comment_text" name="comment" placeholder="Wpisz komentarz..." required></textarea>
</div>
<button type="submit" class="btn btn-primary btn-sm" id="save_comment">
<i class="fa-solid fa-floppy-disk"></i> Zapisz komentarz
</button>
</form>
</aside>
</div> </div>
</div> </div>
</div>
<div class="admin-form theme-primary"> <div class="products-table-wrap">
<div class="panel heading-border panel-primary"> <table class="table table-sm" id="products">
<div class="panel-body"> <thead>
<table class="table" id="products"> <tr>
<thead> <th scope="col">Id</th>
<tr> <th scope="col">Wyswietlenia</th>
<th scope="col">Id</th> <th scope="col">Klikniecia</th>
<th scope="col">Wyświetlenia</th> <th scope="col">CTR</th>
<th scope="col">Kliknięcia</th> <th scope="col">Koszt</th>
<th scope="col">CTR</th> <th scope="col">Konwersje</th>
<th scope="col">Koszt</th> <th scope="col">Wartosc konwersji</th>
<th scope="col">Konwersje</th> <th scope="col">ROAS</th>
<th scope="col">Wartość konwersji</th> <th scope="col">Data</th>
<th scope="col">ROAS</th> <th scope="col">Komentarz</th>
<th scope="col">Data</th> </tr>
<th scope="col">Komentarz</th> </thead>
</tr> <tbody></tbody>
</thead> </table>
<tbody></tbody>
</table>
</div>
</div> </div>
</div> </div>
<script src="https://code.highcharts.com/highcharts.js"></script> <script src="https://code.highcharts.com/highcharts.js"></script>
<script type="text/javascript"> <script type="text/javascript">
@@ -71,14 +75,13 @@
})(); })();
// Inicjalizacja tabeli // Inicjalizacja tabeli
var table = $('#products').DataTable(); var table = new DataTable('#products', {
table.destroy();
new DataTable('#products', {
ajax: { ajax: {
type: 'POST', type: 'POST',
url: '/products/get_product_history_table/client_id=' + client_id + '&product_id=' + product_id + '&campaign_id=' + campaign_id + '&ad_group_id=' + ad_group_id, url: '/products/get_product_history_table/client_id=' + client_id + '&product_id=' + product_id + '&campaign_id=' + campaign_id + '&ad_group_id=' + ad_group_id,
}, },
autoWidth: false,
lengthChange: false,
pageLength: 30, pageLength: 30,
processing: true, processing: true,
serverSide: true, serverSide: true,
@@ -95,7 +98,19 @@
{ width: '100px', name: 'date_add' }, { width: '100px', name: 'date_add' },
{ width: '250px', name: 'comment', orderable: false }, { width: '250px', name: 'comment', orderable: false },
], ],
order: [[0, false],[8, 'desc']] order: [ [ 8, 'desc' ] ],
language: {
processing: 'Ladowanie...',
emptyTable: 'Brak danych do wyswietlenia',
info: 'Wiersze _START_ - _END_ z _TOTAL_',
infoEmpty: '',
paginate: {
first: 'Pierwsza',
last: 'Ostatnia',
next: 'Dalej',
previous: 'Wstecz'
}
}
}); });
// WYKRES // WYKRES

View File

@@ -37,6 +37,8 @@
<body class="logged"> <body class="logged">
<?php <?php
$module = $this -> current_module; $module = $this -> current_module;
$google_ads_modules = [ 'campaigns', 'campaign_terms', 'products', 'clients', 'xml_files' ];
$is_google_ads_module = in_array( $module, $google_ads_modules, true );
?> ?>
<!-- Sidebar --> <!-- Sidebar -->
<aside class="sidebar" id="sidebar"> <aside class="sidebar" id="sidebar">
@@ -52,29 +54,43 @@
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<ul> <ul>
<li class="<?= $module === 'campaigns' ? 'active' : '' ?>"> <li class="nav-group <?= $is_google_ads_module ? 'active' : '' ?>">
<a href="/campaigns"> <div class="nav-group-label">
<i class="fa-solid fa-bullhorn"></i> <i class="fa-brands fa-google"></i>
<span>Kampanie</span> <span>Google ADS</span>
</a> </div>
</li> <ul class="nav-submenu">
<li class="<?= $module === 'campaign_terms' ? 'active' : '' ?>"> <li class="<?= $module === 'campaigns' ? 'active' : '' ?>">
<a href="/campaign_terms"> <a href="/campaigns">
<i class="fa-solid fa-list-check"></i> <i class="fa-solid fa-bullhorn"></i>
<span>Grupy i frazy</span> <span>Kampanie</span>
</a> </a>
</li> </li>
<li class="<?= $module === 'products' ? 'active' : '' ?>"> <li class="<?= $module === 'campaign_terms' ? 'active' : '' ?>">
<a href="/products"> <a href="/campaign_terms">
<i class="fa-solid fa-box-open"></i> <i class="fa-solid fa-list-check"></i>
<span>Produkty</span> <span>Grupy i frazy</span>
</a> </a>
</li> </li>
<li class="<?= $module === 'clients' ? 'active' : '' ?>"> <li class="<?= $module === 'products' ? 'active' : '' ?>">
<a href="/clients"> <a href="/products">
<i class="fa-solid fa-building"></i> <i class="fa-solid fa-box-open"></i>
<span>Klienci</span> <span>Produkty</span>
</a> </a>
</li>
<li class="<?= $module === 'clients' ? 'active' : '' ?>">
<a href="/clients">
<i class="fa-solid fa-building"></i>
<span>Klienci</span>
</a>
</li>
<li class="<?= $module === 'xml_files' ? 'active' : '' ?>">
<a href="/xml_files">
<i class="fa-solid fa-file-code"></i>
<span>Pliki XML</span>
</a>
</li>
</ul>
</li> </li>
<li class="<?= $module === 'allegro' ? 'active' : '' ?>"> <li class="<?= $module === 'allegro' ? 'active' : '' ?>">
<a href="/allegro"> <a href="/allegro">
@@ -120,6 +136,7 @@
'campaign_terms' => 'Grupy i frazy', 'campaign_terms' => 'Grupy i frazy',
'products' => 'Produkty', 'products' => 'Produkty',
'clients' => 'Klienci', 'clients' => 'Klienci',
'xml_files' => 'Pliki XML',
'allegro' => 'Allegro import', 'allegro' => 'Allegro import',
'users' => 'Ustawienia', 'users' => 'Ustawienia',
]; ];

View File

@@ -1,197 +1,292 @@
<div class="row"> <?php
<div class="col-12 col-md-6"> $settings_tab = \S::get( 'settings_tab' ) === 'cron' ? 'cron' : 'general';
<div class="settings-card"> $cron_data = is_array( $this -> cron_data ?? null ) ? $this -> cron_data : [];
<div class="settings-card-header"> $cron_progress = is_array( $cron_data['progress'] ?? null ) ? $cron_data['progress'] : [];
<div class="settings-card-icon"><i class="fa-solid fa-lock"></i></div> $cron_urls = is_array( $cron_data['urls'] ?? null ) ? $cron_data['urls'] : [];
<div> ?>
<h3>Zmiana hasła</h3>
<small>Zmień swoje stare hasło na nowe</small> <div class="settings-tabs">
</div> <a href="/settings?settings_tab=general" class="settings-tab <?= $settings_tab === 'general' ? 'active' : ''; ?>">
</div> <i class="fa-solid fa-sliders"></i> Ustawienia
<form method="POST" id="password-settings" action="/users/password_change/"> </a>
<div class="settings-field"> <a href="/settings?settings_tab=cron" class="settings-tab <?= $settings_tab === 'cron' ? 'active' : ''; ?>">
<label for="password_old">Stare hasło</label> <i class="fa-solid fa-clock-rotate-left"></i> CRON
<div class="settings-input-wrap"> </a>
<i class="fa-solid fa-key settings-input-icon"></i>
<input type="password" id="password_old" name="password_old" class="form-control" required placeholder="Wprowadź stare hasło" />
<button type="button" class="settings-toggle-pw" onclick="password_toggle( this, 'password_old' )">
<i class="fa-solid fa-eye"></i>
</button>
</div>
</div>
<div class="settings-field">
<label for="password_new">Nowe hasło</label>
<div class="settings-input-wrap">
<i class="fa-solid fa-key settings-input-icon"></i>
<input type="password" id="password_new" name="password_new" class="form-control" required placeholder="Wprowadź nowe hasło" />
<button type="button" class="settings-toggle-pw" onclick="password_toggle( this, 'password_new' )">
<i class="fa-solid fa-eye"></i>
</button>
</div>
</div>
<button type="submit" class="btn btn-success"><i class="fa-solid fa-check mr5"></i>Zmień hasło</button>
</form>
</div>
</div>
</div> </div>
<div class="row" style="margin-top: 25px;"> <?php if ( $settings_tab === 'cron' ): ?>
<div class="col-12"> <div class="row">
<div class="settings-card"> <div class="col-12">
<div class="settings-card-header"> <div class="settings-card">
<div class="settings-card-icon"><i class="fa-brands fa-google"></i></div> <div class="settings-card-header">
<div> <div class="settings-card-icon"><i class="fa-solid fa-clock-rotate-left"></i></div>
<h3>Google Ads API</h3> <div>
<small>Dane do połączenia z Google Ads REST API (wymagane do synchronizacji kampanii)</small> <h3>Status CRON</h3>
</div> <small>Adresy URL do wywołań, postęp pipeline i ostatnie uruchomienia</small>
</div>
<?php
$last_error = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' );
if ( $last_error ): ?>
<div class="settings-alert-error">
<i class="fa-solid fa-triangle-exclamation"></i>
<span><strong>Ostatni błąd API:</strong> <?= htmlspecialchars( $last_error ); ?></span>
</div>
<?php endif; ?>
<form method="POST" id="google-ads-settings" action="/settings/save_google_ads">
<div class="settings-fields-grid">
<div class="settings-field">
<label for="google_ads_developer_token">Developer Token</label>
<input type="text" id="google_ads_developer_token" name="google_ads_developer_token" class="form-control" value="<?= htmlspecialchars( \services\GoogleAdsApi::get_setting( 'google_ads_developer_token' ) ); ?>" placeholder="np. ABcdEf1234..." />
</div>
<div class="settings-field">
<label for="google_ads_client_id">OAuth2 Client ID</label>
<input type="text" id="google_ads_client_id" name="google_ads_client_id" class="form-control" value="<?= htmlspecialchars( \services\GoogleAdsApi::get_setting( 'google_ads_client_id' ) ); ?>" placeholder="np. 123456789.apps.googleusercontent.com" />
</div>
<div class="settings-field">
<label for="google_ads_client_secret">OAuth2 Client Secret</label>
<div class="settings-input-wrap">
<input type="password" id="google_ads_client_secret" name="google_ads_client_secret" class="form-control" value="<?= htmlspecialchars( \services\GoogleAdsApi::get_setting( 'google_ads_client_secret' ) ); ?>" />
<button type="button" class="settings-toggle-pw" onclick="password_toggle( this, 'google_ads_client_secret' )">
<i class="fa-solid fa-eye"></i>
</button>
</div>
</div>
<div class="settings-field">
<label for="google_ads_refresh_token">OAuth2 Refresh Token</label>
<div class="settings-input-wrap">
<input type="password" id="google_ads_refresh_token" name="google_ads_refresh_token" class="form-control" value="<?= htmlspecialchars( \services\GoogleAdsApi::get_setting( 'google_ads_refresh_token' ) ); ?>" />
<button type="button" class="settings-toggle-pw" onclick="password_toggle( this, 'google_ads_refresh_token' )">
<i class="fa-solid fa-eye"></i>
</button>
</div>
</div>
<div class="settings-field">
<label for="google_ads_manager_account_id">Manager Account ID <span class="text-muted">(opcjonalnie, dla MCC)</span></label>
<input type="text" id="google_ads_manager_account_id" name="google_ads_manager_account_id" class="form-control" value="<?= htmlspecialchars( \services\GoogleAdsApi::get_setting( 'google_ads_manager_account_id' ) ); ?>" placeholder="np. 123-456-7890" />
</div> </div>
</div> </div>
<button type="button" class="btn btn-success" onclick="$( '#google-ads-settings' ).submit();"><i class="fa-solid fa-check mr5"></i>Zapisz ustawienia Google Ads</button>
</form>
</div>
</div>
</div>
<div class="row" style="margin-top: 25px;"> <div class="cron-status-overview">
<div class="col-12"> <div><strong>Ostatnie wywołanie:</strong> <span data-cron-overall-last-invoked><?= htmlspecialchars( (string) ( $cron_data['overall_last_invoked_at'] ?? 'Brak danych' ) ); ?></span></div>
<div class="settings-card"> <div><strong>Klienci z Google Ads ID:</strong> <span data-cron-clients-total><?= (int) ( $cron_data['clients_total'] ?? 0 ); ?></span></div>
<div class="settings-card-header">
<div class="settings-card-icon"><i class="fa-solid fa-robot"></i></div>
<div>
<h3>OpenAI (ChatGPT)</h3>
<small>Klucz API i model do optymalizacji tytułów i opisów produktów przez AI</small>
</div> </div>
</div>
<form method="POST" id="openai-settings" action="/settings/save_openai">
<div class="settings-field" style="margin-bottom: 16px;">
<label class="settings-toggle-label">
<?php $openai_enabled = \services\GoogleAdsApi::get_setting( 'openai_enabled' ) !== '0'; ?>
<input type="hidden" name="openai_enabled" value="0" />
<input type="checkbox" name="openai_enabled" value="1" class="settings-toggle-checkbox" <?= $openai_enabled ? 'checked' : ''; ?> />
<span class="settings-toggle-switch"></span>
<span>Włącz OpenAI (ChatGPT)</span>
</label>
</div>
<div class="settings-fields-grid">
<div class="settings-field">
<label for="openai_api_key">API Key</label>
<div class="settings-input-wrap">
<input type="password" id="openai_api_key" name="openai_api_key" class="form-control" value="<?= htmlspecialchars( \services\GoogleAdsApi::get_setting( 'openai_api_key' ) ); ?>" placeholder="sk-..." />
<button type="button" class="settings-toggle-pw" onclick="password_toggle( this, 'openai_api_key' )">
<i class="fa-solid fa-eye"></i>
</button>
</div>
</div>
<div class="settings-field">
<label for="openai_model">Model</label>
<?php $current_model = \services\GoogleAdsApi::get_setting( 'openai_model' ) ?: 'gpt-5-mini'; ?>
<select id="openai_model" name="openai_model" class="form-control">
<option value="gpt-5.2" <?= $current_model === 'gpt-5.2' ? 'selected' : ''; ?>>GPT-5.2 (najnowszy, $1.75/$14 per 1M)</option>
<option value="gpt-5-mini" <?= $current_model === 'gpt-5-mini' ? 'selected' : ''; ?>>GPT-5 Mini (szybki, $0.25/$2 per 1M)</option>
<option value="gpt-4.1" <?= $current_model === 'gpt-4.1' ? 'selected' : ''; ?>>GPT-4.1</option>
<option value="gpt-4.1-mini" <?= $current_model === 'gpt-4.1-mini' ? 'selected' : ''; ?>>GPT-4.1 Mini</option>
<option value="gpt-4o" <?= $current_model === 'gpt-4o' ? 'selected' : ''; ?>>GPT-4o (legacy)</option>
<option value="gpt-4o-mini" <?= $current_model === 'gpt-4o-mini' ? 'selected' : ''; ?>>GPT-4o Mini (legacy)</option>
</select>
</div>
</div>
<button type="button" class="btn btn-success" onclick="$( '#openai-settings' ).submit();"><i class="fa-solid fa-check mr5"></i>Zapisz ustawienia OpenAI</button>
</form>
</div>
</div>
</div>
<div class="row" style="margin-top: 25px;"> <div class="cron-progress-list">
<div class="col-12"> <?php foreach ( $cron_progress as $progress_index => $item ): ?>
<div class="settings-card"> <?php
<div class="settings-card-header"> $processed = (int) ( $item['processed'] ?? 0 );
<div class="settings-card-icon"><i class="fa-solid fa-brain"></i></div> $total = (int) ( $item['total'] ?? 0 );
<div> $percent = (int) ( $item['percent'] ?? 0 );
<h3>Claude (Anthropic)</h3> ?>
<small>Klucz API i model Claude do optymalizacji tytułów i opisów produktów przez AI</small> <div class="cron-progress-item" data-cron-progress-item="<?= (int) $progress_index; ?>">
<div class="cron-progress-head">
<strong><?= htmlspecialchars( (string) ( $item['name'] ?? 'CRON' ) ); ?></strong>
<span data-cron-progress-summary><?= $processed; ?> / <?= $total; ?> (<?= $percent; ?>%)</span>
</div>
<div class="cron-progress-bar">
<span data-cron-progress-fill style="width: <?= $percent; ?>%;"></span>
</div>
<small data-cron-progress-meta><?= htmlspecialchars( (string) ( $item['meta'] ?? '' ) ); ?></small>
</div>
<?php endforeach; ?>
</div> </div>
<div class="cron-url-list">
<?php foreach ( $cron_urls as $url_index => $row ): ?>
<div class="cron-url-item" data-cron-url-item="<?= (int) $url_index; ?>">
<div class="cron-url-top">
<strong><?= htmlspecialchars( (string) ( $row['name'] ?? 'Cron' ) ); ?></strong>
<small>Ostatnio: <span data-cron-url-last-invoked><?= htmlspecialchars( (string) ( $row['last_invoked_at'] ?? 'Brak danych' ) ); ?></span></small>
</div>
<code><?= htmlspecialchars( (string) ( $row['url'] ?? '' ) ); ?></code>
</div>
<?php endforeach; ?>
</div>
<a href="/settings?settings_tab=cron" class="btn btn-primary btn-sm">
<i class="fa-solid fa-rotate-right mr5"></i> Odśwież status
</a>
</div> </div>
<form method="POST" id="claude-settings" action="/settings/save_claude"> </div>
<div class="settings-field" style="margin-bottom: 16px;"> </div>
<label class="settings-toggle-label"> <?php else: ?>
<?php $claude_enabled = \services\GoogleAdsApi::get_setting( 'claude_enabled' ) !== '0'; ?> <div class="row">
<input type="hidden" name="claude_enabled" value="0" /> <div class="col-12 col-md-6">
<input type="checkbox" name="claude_enabled" value="1" class="settings-toggle-checkbox" <?= $claude_enabled ? 'checked' : ''; ?> /> <div class="settings-card">
<span class="settings-toggle-switch"></span> <div class="settings-card-header">
<span>Włącz Claude (Anthropic)</span> <div class="settings-card-icon"><i class="fa-solid fa-lock"></i></div>
</label> <div>
<h3>Zmiana hasła</h3>
<small>Zmień swoje stare hasło na nowe</small>
</div>
</div> </div>
<div class="settings-fields-grid"> <form method="POST" id="password-settings" action="/users/password_change/">
<div class="settings-field"> <div class="settings-field">
<label for="claude_api_key">API Key</label> <label for="password_old">Stare hasło</label>
<div class="settings-input-wrap"> <div class="settings-input-wrap">
<input type="password" id="claude_api_key" name="claude_api_key" class="form-control" value="<?= htmlspecialchars( \services\GoogleAdsApi::get_setting( 'claude_api_key' ) ); ?>" placeholder="sk-ant-..." /> <i class="fa-solid fa-key settings-input-icon"></i>
<button type="button" class="settings-toggle-pw" onclick="password_toggle( this, 'claude_api_key' )"> <input type="password" id="password_old" name="password_old" class="form-control" required placeholder="Wprowadź stare hasło" />
<button type="button" class="settings-toggle-pw" onclick="password_toggle( this, 'password_old' )">
<i class="fa-solid fa-eye"></i> <i class="fa-solid fa-eye"></i>
</button> </button>
</div> </div>
</div> </div>
<div class="settings-field"> <div class="settings-field">
<label for="claude_model">Model</label> <label for="password_new">Nowe hasło</label>
<?php $current_claude_model = \services\GoogleAdsApi::get_setting( 'claude_model' ) ?: 'claude-sonnet-4-5-20250929'; ?> <div class="settings-input-wrap">
<select id="claude_model" name="claude_model" class="form-control"> <i class="fa-solid fa-key settings-input-icon"></i>
<option value="claude-opus-4-6" <?= $current_claude_model === 'claude-opus-4-6' ? 'selected' : ''; ?>>Claude Opus 4.6 (najpotężniejszy)</option> <input type="password" id="password_new" name="password_new" class="form-control" required placeholder="Wprowadź nowe hasło" />
<option value="claude-sonnet-4-5-20250929" <?= $current_claude_model === 'claude-sonnet-4-5-20250929' ? 'selected' : ''; ?>>Claude Sonnet 4.5 (zbalansowany)</option> <button type="button" class="settings-toggle-pw" onclick="password_toggle( this, 'password_new' )">
<option value="claude-haiku-4-5-20251001" <?= $current_claude_model === 'claude-haiku-4-5-20251001' ? 'selected' : ''; ?>>Claude Haiku 4.5 (szybki, tani)</option> <i class="fa-solid fa-eye"></i>
</select> </button>
</div>
</div> </div>
</div> <button type="submit" class="btn btn-success"><i class="fa-solid fa-check mr5"></i>Zmień hasło</button>
<button type="button" class="btn btn-success" onclick="$( '#claude-settings' ).submit();"><i class="fa-solid fa-check mr5"></i>Zapisz ustawienia Claude</button> </form>
</form> </div>
</div> </div>
</div> </div>
</div>
<div class="row" style="margin-top: 25px;">
<div class="col-12">
<div class="settings-card">
<div class="settings-card-header">
<div class="settings-card-icon"><i class="fa-brands fa-google"></i></div>
<div>
<h3>Google Ads API</h3>
<small>Dane do połączenia z Google Ads REST API (wymagane do synchronizacji kampanii)</small>
</div>
</div>
<?php
$last_error = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' );
if ( $last_error ): ?>
<div class="settings-alert-error">
<i class="fa-solid fa-triangle-exclamation"></i>
<span><strong>Ostatni błąd API:</strong> <?= htmlspecialchars( $last_error ); ?></span>
</div>
<?php endif; ?>
<form method="POST" id="google-ads-settings" action="/settings/save_google_ads">
<div class="settings-field" style="margin-bottom: 16px;">
<label class="settings-toggle-label">
<?php $google_ads_debug_enabled = \services\GoogleAdsApi::get_setting( 'google_ads_debug_enabled' ) !== '0'; ?>
<input type="hidden" name="google_ads_debug_enabled" value="0" />
<input type="checkbox" name="google_ads_debug_enabled" value="1" class="settings-toggle-checkbox" <?= $google_ads_debug_enabled ? 'checked' : ''; ?> />
<span class="settings-toggle-switch"></span>
<span class="settings-toggle-text">Włącz debug Google Ads API</span>
</label>
</div>
<div class="settings-fields-grid">
<div class="settings-field">
<label for="google_ads_developer_token">Developer Token</label>
<input type="text" id="google_ads_developer_token" name="google_ads_developer_token" class="form-control" value="<?= htmlspecialchars( \services\GoogleAdsApi::get_setting( 'google_ads_developer_token' ) ); ?>" placeholder="np. ABcdEf1234..." />
</div>
<div class="settings-field">
<label for="google_ads_client_id">OAuth2 Client ID</label>
<input type="text" id="google_ads_client_id" name="google_ads_client_id" class="form-control" value="<?= htmlspecialchars( \services\GoogleAdsApi::get_setting( 'google_ads_client_id' ) ); ?>" placeholder="np. 123456789.apps.googleusercontent.com" />
</div>
<div class="settings-field">
<label for="google_ads_client_secret">OAuth2 Client Secret</label>
<div class="settings-input-wrap">
<input type="password" id="google_ads_client_secret" name="google_ads_client_secret" class="form-control" value="<?= htmlspecialchars( \services\GoogleAdsApi::get_setting( 'google_ads_client_secret' ) ); ?>" />
<button type="button" class="settings-toggle-pw" onclick="password_toggle( this, 'google_ads_client_secret' )">
<i class="fa-solid fa-eye"></i>
</button>
</div>
</div>
<div class="settings-field">
<label for="google_ads_refresh_token">OAuth2 Refresh Token</label>
<div class="settings-input-wrap">
<input type="password" id="google_ads_refresh_token" name="google_ads_refresh_token" class="form-control" value="<?= htmlspecialchars( \services\GoogleAdsApi::get_setting( 'google_ads_refresh_token' ) ); ?>" />
<button type="button" class="settings-toggle-pw" onclick="password_toggle( this, 'google_ads_refresh_token' )">
<i class="fa-solid fa-eye"></i>
</button>
</div>
</div>
<div class="settings-field">
<label for="google_merchant_refresh_token">Merchant API Refresh Token</label>
<div class="settings-input-wrap">
<input type="password" id="google_merchant_refresh_token" name="google_merchant_refresh_token" class="form-control" value="<?= htmlspecialchars( \services\GoogleAdsApi::get_setting( 'google_merchant_refresh_token' ) ); ?>" placeholder="Refresh token dla scope content" />
<button type="button" class="settings-toggle-pw" onclick="password_toggle( this, 'google_merchant_refresh_token' )">
<i class="fa-solid fa-eye"></i>
</button>
</div>
</div>
<div class="settings-field">
<label for="google_ads_manager_account_id">Manager Account ID <span class="text-muted">(opcjonalnie, dla MCC)</span></label>
<input type="text" id="google_ads_manager_account_id" name="google_ads_manager_account_id" class="form-control" value="<?= htmlspecialchars( \services\GoogleAdsApi::get_setting( 'google_ads_manager_account_id' ) ); ?>" placeholder="np. 123-456-7890" />
</div>
</div>
<button type="button" class="btn btn-success" onclick="$( '#google-ads-settings' ).submit();"><i class="fa-solid fa-check mr5"></i>Zapisz ustawienia Google Ads</button>
</form>
</div>
</div>
</div>
<div class="row" style="margin-top: 25px;">
<div class="col-12">
<div class="settings-card">
<div class="settings-card-header">
<div class="settings-card-icon"><i class="fa-solid fa-robot"></i></div>
<div>
<h3>OpenAI (ChatGPT)</h3>
<small>Klucz API i model do optymalizacji tytułów i opisów produktów przez AI</small>
</div>
</div>
<form method="POST" id="openai-settings" action="/settings/save_openai">
<div class="settings-field" style="margin-bottom: 16px;">
<label class="settings-toggle-label">
<?php $openai_enabled = \services\GoogleAdsApi::get_setting( 'openai_enabled' ) !== '0'; ?>
<input type="hidden" name="openai_enabled" value="0" />
<input type="checkbox" name="openai_enabled" value="1" class="settings-toggle-checkbox" <?= $openai_enabled ? 'checked' : ''; ?> />
<span class="settings-toggle-switch"></span>
<span class="settings-toggle-text">Włącz OpenAI (ChatGPT)</span>
</label>
</div>
<div class="settings-fields-grid">
<div class="settings-field">
<label for="openai_api_key">API Key</label>
<div class="settings-input-wrap">
<input type="password" id="openai_api_key" name="openai_api_key" class="form-control" value="<?= htmlspecialchars( \services\GoogleAdsApi::get_setting( 'openai_api_key' ) ); ?>" placeholder="sk-..." />
<button type="button" class="settings-toggle-pw" onclick="password_toggle( this, 'openai_api_key' )">
<i class="fa-solid fa-eye"></i>
</button>
</div>
</div>
<div class="settings-field">
<label for="openai_model">Model</label>
<?php $current_model = \services\GoogleAdsApi::get_setting( 'openai_model' ) ?: 'gpt-5-mini'; ?>
<select id="openai_model" name="openai_model" class="form-control">
<option value="gpt-5.2" <?= $current_model === 'gpt-5.2' ? 'selected' : ''; ?>>GPT-5.2 (najnowszy, $1.75/$14 per 1M)</option>
<option value="gpt-5-mini" <?= $current_model === 'gpt-5-mini' ? 'selected' : ''; ?>>GPT-5 Mini (szybki, $0.25/$2 per 1M)</option>
<option value="gpt-4.1" <?= $current_model === 'gpt-4.1' ? 'selected' : ''; ?>>GPT-4.1</option>
<option value="gpt-4.1-mini" <?= $current_model === 'gpt-4.1-mini' ? 'selected' : ''; ?>>GPT-4.1 Mini</option>
<option value="gpt-4o" <?= $current_model === 'gpt-4o' ? 'selected' : ''; ?>>GPT-4o (legacy)</option>
<option value="gpt-4o-mini" <?= $current_model === 'gpt-4o-mini' ? 'selected' : ''; ?>>GPT-4o Mini (legacy)</option>
</select>
</div>
</div>
<button type="button" class="btn btn-success" onclick="$( '#openai-settings' ).submit();"><i class="fa-solid fa-check mr5"></i>Zapisz ustawienia OpenAI</button>
</form>
</div>
</div>
</div>
<div class="row" style="margin-top: 25px;">
<div class="col-12">
<div class="settings-card">
<div class="settings-card-header">
<div class="settings-card-icon"><i class="fa-solid fa-brain"></i></div>
<div>
<h3>Claude (Anthropic)</h3>
<small>Klucz API i model Claude do optymalizacji tytułów i opisów produktów przez AI</small>
</div>
</div>
<form method="POST" id="claude-settings" action="/settings/save_claude">
<div class="settings-field" style="margin-bottom: 16px;">
<label class="settings-toggle-label">
<?php $claude_enabled = \services\GoogleAdsApi::get_setting( 'claude_enabled' ) !== '0'; ?>
<input type="hidden" name="claude_enabled" value="0" />
<input type="checkbox" name="claude_enabled" value="1" class="settings-toggle-checkbox" <?= $claude_enabled ? 'checked' : ''; ?> />
<span class="settings-toggle-switch"></span>
<span class="settings-toggle-text">Włącz Claude (Anthropic)</span>
</label>
</div>
<div class="settings-fields-grid">
<div class="settings-field">
<label for="claude_api_key">API Key</label>
<div class="settings-input-wrap">
<input type="password" id="claude_api_key" name="claude_api_key" class="form-control" value="<?= htmlspecialchars( \services\GoogleAdsApi::get_setting( 'claude_api_key' ) ); ?>" placeholder="sk-ant-..." />
<button type="button" class="settings-toggle-pw" onclick="password_toggle( this, 'claude_api_key' )">
<i class="fa-solid fa-eye"></i>
</button>
</div>
</div>
<div class="settings-field">
<label for="claude_model">Model</label>
<?php $current_claude_model = \services\GoogleAdsApi::get_setting( 'claude_model' ) ?: 'claude-sonnet-4-5-20250929'; ?>
<select id="claude_model" name="claude_model" class="form-control">
<option value="claude-opus-4-6" <?= $current_claude_model === 'claude-opus-4-6' ? 'selected' : ''; ?>>Claude Opus 4.6 (najpotężniejszy)</option>
<option value="claude-sonnet-4-5-20250929" <?= $current_claude_model === 'claude-sonnet-4-5-20250929' ? 'selected' : ''; ?>>Claude Sonnet 4.5 (zbalansowany)</option>
<option value="claude-haiku-4-5-20251001" <?= $current_claude_model === 'claude-haiku-4-5-20251001' ? 'selected' : ''; ?>>Claude Haiku 4.5 (szybki, tani)</option>
</select>
</div>
</div>
<button type="button" class="btn btn-success" onclick="$( '#claude-settings' ).submit();"><i class="fa-solid fa-check mr5"></i>Zapisz ustawienia Claude</button>
</form>
</div>
</div>
</div>
<?php endif; ?>
<script type="text/javascript"> <script type="text/javascript">
function password_toggle( btn, id ) function password_toggle( btn, id )
{ {
var icon = btn.querySelector( 'i' ); var icon = btn.querySelector( 'i' );
var input = document.getElementById( id ); var input = document.getElementById( id );
if ( !input || !icon )
{
return;
}
if ( input.type === 'password' ) if ( input.type === 'password' )
{ {
@@ -206,4 +301,179 @@
icon.classList.add( 'fa-eye' ); icon.classList.add( 'fa-eye' );
} }
} }
<?php if ( $settings_tab === 'cron' ): ?>
(function()
{
var refresh_interval_ms = 60000;
var refresh_timer = null;
var cron_status_url = '/settings/cron_status';
function set_text( selector, value )
{
var node = document.querySelector( selector );
if ( !node )
{
return;
}
node.textContent = value;
}
function render_progress( progress )
{
if ( !Array.isArray( progress ) )
{
return;
}
progress.forEach( function( item, index )
{
var container = document.querySelector( '[data-cron-progress-item="' + index + '"]' );
if ( !container )
{
return;
}
var processed = parseInt( item && item.processed ? item.processed : 0, 10 );
var total = parseInt( item && item.total ? item.total : 0, 10 );
var percent = parseInt( item && item.percent ? item.percent : 0, 10 );
if ( isNaN( processed ) ) processed = 0;
if ( isNaN( total ) ) total = 0;
if ( isNaN( percent ) ) percent = 0;
percent = Math.max( 0, Math.min( 100, percent ) );
var summary = container.querySelector( '[data-cron-progress-summary]' );
if ( summary )
{
summary.textContent = processed + ' / ' + total + ' (' + percent + '%)';
}
var fill = container.querySelector( '[data-cron-progress-fill]' );
if ( fill )
{
fill.style.width = percent + '%';
}
var meta = container.querySelector( '[data-cron-progress-meta]' );
if ( meta )
{
meta.textContent = item && item.meta ? item.meta : '';
}
} );
}
function render_urls( urls )
{
if ( !Array.isArray( urls ) )
{
return;
}
urls.forEach( function( row, index )
{
var container = document.querySelector( '[data-cron-url-item="' + index + '"]' );
if ( !container )
{
return;
}
var last_invoked = container.querySelector( '[data-cron-url-last-invoked]' );
if ( last_invoked )
{
last_invoked.textContent = row && row.last_invoked_at ? row.last_invoked_at : 'Brak danych';
}
} );
}
function render_cron_data( data )
{
if ( !data || typeof data !== 'object' )
{
return;
}
set_text( '[data-cron-overall-last-invoked]', data.overall_last_invoked_at || 'Brak danych' );
set_text( '[data-cron-clients-total]', data.clients_total || 0 );
render_progress( data.progress );
render_urls( data.urls );
}
function schedule_refresh()
{
if ( refresh_timer )
{
clearTimeout( refresh_timer );
}
refresh_timer = setTimeout( refresh_status, refresh_interval_ms );
}
function refresh_status()
{
if ( document.hidden )
{
schedule_refresh();
return;
}
if ( typeof window.fetch !== 'function' )
{
window.location.reload();
return;
}
fetch( cron_status_url + '?_ts=' + Date.now(), {
method: 'GET',
credentials: 'same-origin',
cache: 'no-store',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
} )
.then( function( response )
{
if ( !response.ok )
{
throw new Error( 'HTTP ' + response.status );
}
return response.json();
} )
.then( function( payload )
{
if ( payload && payload.status === 'ok' )
{
render_cron_data( payload.data || {} );
}
} )
.catch( function()
{
} )
.finally( function()
{
schedule_refresh();
} );
}
document.addEventListener( 'visibilitychange', function()
{
if ( document.hidden )
{
if ( refresh_timer )
{
clearTimeout( refresh_timer );
}
return;
}
refresh_status();
} );
window.addEventListener( 'beforeunload', function()
{
if ( refresh_timer )
{
clearTimeout( refresh_timer );
}
} );
schedule_refresh();
})();
<?php endif; ?>
</script> </script>

View File

@@ -0,0 +1,67 @@
<div class="clients-page xml-files-page">
<div class="clients-header">
<h2><i class="fa-solid fa-file-code"></i> Pliki XML</h2>
</div>
<div class="clients-table-wrap">
<table class="table">
<thead>
<tr>
<th style="width: 60px;">#ID</th>
<th>Klient</th>
<th>Google Ads ID</th>
<th>Link do custom feed</th>
<th style="width: 180px;">Ostatnia modyfikacja</th>
<th style="width: 220px; text-align: center;">Akcje</th>
</tr>
</thead>
<tbody>
<?php if ( $this -> rows ): ?>
<?php foreach ( $this -> rows as $row ): ?>
<tr>
<td class="client-id"><?= (int) ( $row['client_id'] ?? 0 ); ?></td>
<td class="client-name"><?= htmlspecialchars( (string) ( $row['client_name'] ?? '' ) ); ?></td>
<td>
<?php if ( !empty( $row['google_ads_customer_id'] ) ): ?>
<span class="badge-id"><?= htmlspecialchars( (string) $row['google_ads_customer_id'] ); ?></span>
<?php else: ?>
<span class="text-muted">- brak -</span>
<?php endif; ?>
</td>
<td>
<a href="<?= htmlspecialchars( (string) ( $row['xml_url'] ?? '' ) ); ?>" target="_blank" rel="noopener">
<?= htmlspecialchars( (string) ( $row['xml_url'] ?? '' ) ); ?>
</a>
<?php if ( empty( $row['xml_exists'] ) ): ?>
<div class="text-muted">Plik nie zostal jeszcze wygenerowany.</div>
<?php endif; ?>
</td>
<td>
<?php if ( !empty( $row['xml_last_modified'] ) ): ?>
<?= htmlspecialchars( (string) $row['xml_last_modified'] ); ?>
<?php else: ?>
<span class="text-muted">Brak</span>
<?php endif; ?>
</td>
<td class="actions-cell" style="justify-content: center;">
<form method="POST" action="/xml_files/regenerate" style="margin: 0;">
<input type="hidden" name="client_id" value="<?= (int) ( $row['client_id'] ?? 0 ); ?>">
<button type="submit" class="btn btn-primary btn-sm">
<i class="fa-solid fa-rotate-right mr5"></i> Wymus generowanie
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr>
<td colspan="6" class="empty-state">
<i class="fa-solid fa-file-code"></i>
<p>Brak klientow do wyswietlenia.</p>
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,30 @@
<?php
require __DIR__ . '/../config.php';
require __DIR__ . '/../libraries/medoo/medoo.php';
$mdb = new medoo([
'database_type' => '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);