feat: Add Gemini AI integration for product title and description optimization

- Implemented Gemini API service for generating optimized product titles and descriptions based on Google Merchant Center guidelines.
- Added settings for Gemini API key and model selection in user settings.
- Enhanced product management views to support AI-generated suggestions for titles and descriptions.
- Enabled state saving for various data tables across campaign, terms, logs, and products views.
- Introduced AI prompt templates for generating product descriptions and categories.
This commit is contained in:
2026-02-22 00:44:03 +01:00
parent 192eb11f66
commit 7573312038
17 changed files with 1259 additions and 536 deletions

View File

@@ -22,7 +22,12 @@
"Bash(py --version)",
"Bash(where:*)",
"Bash(python:*)",
"Bash(ls -la \"c:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\docs\"\" 2>/dev/null || echo \"docs dir not found \")"
"Bash(ls -la \"c:\\\\visual studio code\\\\projekty\\\\adsPRO\\\\docs\"\" 2>/dev/null || echo \"docs dir not found \")",
"WebFetch(domain:ai.google.dev)"
]
},
"statusLine": {
"type": "command",
"command": "npx -y @owloops/claude-powerline@latest --style=powerline"
}
}

View File

@@ -679,6 +679,19 @@ class Products
exit;
}
}
else if ( $provider === 'gemini' )
{
if ( \services\GoogleAdsApi::get_setting( 'gemini_enabled' ) === '0' )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Gemini jest wyłączony. Włącz go w Ustawieniach.' ] );
exit;
}
if ( !\services\GeminiApi::is_configured() )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Klucz API Gemini nie jest skonfigurowany. Przejdź do Ustawień.' ] );
exit;
}
}
else
{
if ( \services\GoogleAdsApi::get_setting( 'openai_enabled' ) === '0' )
@@ -713,7 +726,7 @@ class Products
$warnings = [];
$should_enrich_with_keyword_planner = in_array( $field, [ 'title', 'description' ], true )
&& in_array( $provider, [ 'openai', 'claude' ], true );
&& in_array( $provider, [ 'openai', 'claude', 'gemini' ], true );
if ( $should_enrich_with_keyword_planner && $keyword_source_url !== '' && filter_var( $keyword_source_url, FILTER_VALIDATE_URL ) )
{
@@ -765,7 +778,11 @@ class Products
'keyword_planner_terms' => $keyword_terms,
];
$api = $provider === 'claude' ? \services\ClaudeApi::class : \services\OpenAiApi::class;
$api_map = [
'claude' => \services\ClaudeApi::class,
'gemini' => \services\GeminiApi::class,
];
$api = $api_map[ $provider ] ?? \services\OpenAiApi::class;
switch ( $field )
{

View File

@@ -149,6 +149,27 @@ class Users
exit;
}
public static function settings_save_gemini()
{
\services\GoogleAdsApi::set_setting( 'gemini_enabled', \S::get( 'gemini_enabled' ) ? '1' : '0' );
\services\GoogleAdsApi::set_setting( 'gemini_api_key', \S::get( 'gemini_api_key' ) );
\services\GoogleAdsApi::set_setting( 'gemini_model', \S::get( 'gemini_model' ) );
\S::alert( 'Ustawienia Gemini zostały zapisane.' );
header( 'Location: /settings' );
exit;
}
public static function settings_save_ai_prompts()
{
\services\GoogleAdsApi::set_setting( 'ai_prompt_title_template', trim( (string) \S::get( 'ai_prompt_title_template' ) ) );
\services\GoogleAdsApi::set_setting( 'ai_prompt_description_template', trim( (string) \S::get( 'ai_prompt_description_template' ) ) );
\S::alert( 'Prompty AI zostały zapisane.' );
header( 'Location: /settings' );
exit;
}
private static function get_cron_dashboard_data()
{
global $mdb, $settings;
@@ -521,3 +542,4 @@ class Users
}
}

View File

@@ -150,6 +150,28 @@ Twoje odpowiedzi muszą być:
return implode( "\n", $lines );
}
static private function get_prompt_template( $setting_key, $default_template )
{
$template = trim( (string) GoogleAdsApi::get_setting( $setting_key ) );
if ( $template === '' )
{
return $default_template;
}
return $template;
}
static private function expand_prompt_template( $template, $vars )
{
$result = (string) $template;
foreach ( (array) $vars as $key => $value )
{
$result = str_replace( '{{' . $key . '}}', (string) $value, $result );
}
return $result;
}
static public function suggest_title( $context )
{
$context_text = self::build_context_text( $context );
@@ -179,27 +201,38 @@ Twoje odpowiedzi muszą być:
}
}
$prompt = 'Zaproponuj zoptymalizowany tytuł produktu dla Google Merchant Center.
$prompt_template = self::get_prompt_template( 'ai_prompt_title_template', OpenAiApi::get_default_title_prompt_template() );
WYMAGANIA:
- Max 150 znaków, najważniejsze info w pierwszych 70 znakach
- Struktura: [Marka jeśli jest w nazwie] [Typ produktu] [Kluczowe cechy z nazwy] [Dla kogo/okazja jeśli wynika z nazwy]
- Pisownia zdaniowa lub tytułowa, naturalny język
- Tytuł musi brzmieć jak wpis w katalogu produktowym
if ( strpos( $prompt_template, '{{context}}' ) === false )
{
$prompt_template .= "\n\n{{context}}";
}
if ( strpos( $prompt_template, '{{keyword_terms}}' ) === false )
{
$prompt_template .= "{{keyword_terms}}";
}
BEZWZGLĘDNY ZAKAZ (odrzucenie przez Google):
- Słowa promocyjne: bestseller, hit, idealny, najlepszy, polecamy, okazja, nowość, TOP
- Wykrzykniki (!), emotikony, symbole dekoracyjne
- WIELKIE LITERY (wyjątek: LED, USB, TV)
- Wezwania do działania, informacje o cenie/dostawie
- 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.)
$prompt = self::expand_prompt_template( $prompt_template, [
'context' => $context_text,
'keyword_terms' => $keyword_planner_text
] );
$prompt .= "\n\nWygeneruj 3 różne warianty tytułu (A/B/C). Zwróć WYŁĄCZNIE poprawny JSON: {\"titles\":[\"wariant A\",\"wariant B\",\"wariant C\"]}.";
' . $context_text . $keyword_planner_text . '
$result = self::call_api( self::$system_prompt, $prompt, 1200 );
if ( ( $result['status'] ?? '' ) !== 'ok' )
{
return $result;
}
Zwróć TYLKO tytuł, bez cudzysłowów, bez wyjaśnień.';
$candidates = OpenAiApi::parse_title_candidates( (string) ( $result['suggestion'] ?? '' ) );
$best_title = OpenAiApi::pick_best_title_candidate( $candidates, $context );
if ( $best_title !== '' )
{
$result['suggestion'] = $best_title;
$result['title_candidates'] = $candidates;
}
return self::call_api( self::$system_prompt, $prompt );
return $result;
}
static public function suggest_description( $context )
@@ -239,33 +272,26 @@ Zwróć TYLKO tytuł, bez cudzysłowów, bez wyjaśnień.';
: '- Napisz opis o długości ok. 1000 znaków (800-1200) — jeśli brak szczegółów, opisz ogólnie zastosowanie i grupę docelową
- Opisuj TYLKO to co wynika z oryginalnej nazwy — nie wymyślaj konkretnych parametrów';
$prompt = 'Napisz zoptymalizowany opis produktu dla Google Merchant Center.
$prompt_template = self::get_prompt_template( 'ai_prompt_description_template', OpenAiApi::get_default_description_prompt_template() );
WYMAGANIA:
' . $length_guide . '
- Najważniejsze info na początku (pierwsze 160 znaków widoczne w wynikach)
- Rzeczowo opisz: co to jest, z czego się składa, do czego służy, dla kogo
- Ton neutralny, informacyjny — jak w specyfikacji produktowej
- Każdy akapit musi zawierać INNĄ informację niż poprzedni — NIGDY nie powtarzaj tych samych elementów
if ( strpos( $prompt_template, '{{length_guide}}' ) === false )
{
$prompt_template .= "\n\n{{length_guide}}";
}
if ( strpos( $prompt_template, '{{context}}' ) === false )
{
$prompt_template .= "\n\n{{context}}";
}
if ( strpos( $prompt_template, '{{keyword_terms}}' ) === false )
{
$prompt_template .= "{{keyword_terms}}";
}
FORMATOWANIE — Google Merchant Center obsługuje podstawowe HTML:
- Używaj <br> do oddzielania akapitów/sekcji
- Używaj <b>pogrubienia</b> dla kluczowych cech (np. nazwy elementów zestawu, materiał)
- Używaj <ul><li>...</li></ul> gdy wymieniasz elementy zestawu lub listę cech
- NIE używaj innych tagów HTML (h1, p, div, span, img, a, table itp.)
- NIE używaj Markdown — tylko dozwolone tagi HTML
BEZWZGLĘDNY ZAKAZ (odrzucenie przez Google):
- Słowa promocyjne: bestseller, hit, okazja, najlepszy, polecamy, nowość, idealny
- Wykrzykniki (!), emotikony, WIELKIE LITERY
- Wezwania do działania: kup teraz, zamów, sprawdź, dodaj do koszyka
- Informacje o cenie, dostawie, promocjach, nazwie sklepu
- Opisy akcesoriów/produktów nie wchodzących w skład oferty
- Cechy wymyślone — opisuj TYLKO to co wynika z nazwy lub treści strony produktu
' . $context_text . $keyword_planner_text . '
Zwróć TYLKO opis w formacie HTML (używając dozwolonych tagów), bez cudzysłowów, bez wyjaśnień.';
$prompt = self::expand_prompt_template( $prompt_template, [
'length_guide' => $length_guide,
'context' => $context_text,
'keyword_terms' => $keyword_planner_text
] );
$tokens = $has_page ? 1500 : 1000;
return self::call_api( self::$system_prompt, $prompt, $tokens );

View File

@@ -0,0 +1,349 @@
<?php
namespace services;
class GeminiApi
{
static private $api_base = 'https://generativelanguage.googleapis.com/v1beta/models/';
static private $system_prompt = 'Jesteś ekspertem od optymalizacji feedów produktowych Google Merchant Center i reklam Google Ads (Performance Max, Shopping).
OFICJALNE ZASADY GOOGLE MERCHANT CENTER — BEZWZGLĘDNIE PRZESTRZEGAJ:
TYTUŁY (title) — max 150 znaków:
Zalecana struktura: [Marka] [Typ produktu] [Kluczowe atrybuty: kolor, rozmiar, materiał — TYLKO jeśli wynikają z nazwy]
- Najważniejsze informacje umieść w pierwszych 70 znakach (reszta może być obcięta na mobile)
- Każdy wariant produktu musi mieć unikalny tytuł (różne kolory/rozmiary = różne tytuły)
- Tytuł MUSI być spójny z nazwą produktu na stronie docelowej
- Używaj naturalnego języka — pisz dla ludzi, nie dla algorytmów
ZAKAZANE w tytułach:
- Tekst promocyjny: bestseller, hit, okazja, wyprzedaż, rabat, najlepszy, TOP, #1, idealny, polecamy, nowość
- Wykrzykniki (!) i nadmierna interpunkcja
- WIELKIE LITERY (wyjątek: ustalone skróty jak LED, USB, TV)
- Emotikony, emoji, symbole dekoracyjne (★, ♥, ✓)
- Informacje o cenie, dostawie, promocji
- Wezwania do działania (kup teraz, sprawdź, zamów)
- Upychanie słów kluczowych (keyword stuffing) — np. powtarzanie tych samych fraz
- Kody wewnętrzne, SKU, numery katalogowe
- Cechy produktu których NIE MA w oryginalnej nazwie (nie wymyślaj koloru, materiału, rozmiaru)
OPISY (description) — max 5000 znaków, ale najważniejsze info w pierwszych 160-500 znakach:
- Opisz cechy, specyfikacje techniczne, zastosowanie, grupę docelową
- Wymień atrybuty niewidoczne na zdjęciu (materiał, wzór, przeznaczenie wiekowe)
- Ton neutralny, informacyjny — jak w katalogu produktowym
ZAKAZANE w opisach:
- Tekst promocyjny i reklamowy (te same słowa co w tytułach)
- Nazwa firmy/sklepu (chyba że to marka produktu)
- Informacje o wysyłce, cenach, promocjach
- Wezwania do działania
- Opisy innych produktów, akcesoriów nie wchodzących w skład oferty
- Tekst zastępczy (lorem ipsum, placeholder)
- Powielanie tego samego opisu dla wielu produktów
- HTML, tagi formatowania
OGÓLNE:
- Pisz poprawną polszczyzną (ortografia, gramatyka, interpunkcja)
- Używaj pisowni zdaniowej lub tytułowej — NIE CAPS
- Dane w feedzie MUSZĄ zgadzać się z danymi na stronie produktowej
- Opisuj TYLKO cechy wynikające z nazwy produktu — nie wymyślaj
Twoje odpowiedzi muszą być:
- Ściśle zgodne z polityką Google Merchant Center
- Zgodne z prawdą (opisuj tylko cechy widoczne w nazwie produktu)
- Zoptymalizowane pod kątem trafności wyszukiwań w Google Ads
- W języku polskim';
static public function is_configured()
{
return (bool) GoogleAdsApi::get_setting( 'gemini_api_key' );
}
static private function call_api( $system_prompt, $user_prompt, $max_tokens = 500, $temperature = 0.7 )
{
$api_key = GoogleAdsApi::get_setting( 'gemini_api_key' );
$model = GoogleAdsApi::get_setting( 'gemini_model' ) ?: 'gemini-2.5-flash';
$is_thinking_model = ( strpos( $model, 'gemini-2.5' ) !== false );
$url = self::$api_base . $model . ':generateContent?key=' . urlencode( $api_key );
// Modele Gemini 2.5 z thinking potrzebują więcej tokenów (thinking liczy się do limitu)
$effective_max_tokens = $is_thinking_model ? max( $max_tokens * 6, 4096 ) : $max_tokens;
$payload = [
'systemInstruction' => [
'parts' => [ [ 'text' => $system_prompt ] ]
],
'contents' => [
[
'role' => 'user',
'parts' => [ [ 'text' => $user_prompt ] ]
]
],
'generationConfig' => [
'maxOutputTokens' => $effective_max_tokens,
'temperature' => $temperature
]
];
$ch = curl_init( $url );
curl_setopt_array( $ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json'
],
CURLOPT_POSTFIELDS => json_encode( $payload ),
CURLOPT_TIMEOUT => 60
] );
$response = curl_exec( $ch );
$http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
curl_close( $ch );
if ( $http_code !== 200 )
{
$error = json_decode( $response, true );
return [ 'status' => 'error', 'message' => $error['error']['message'] ?? 'Błąd API Gemini (HTTP ' . $http_code . ')' ];
}
$data = json_decode( $response, true );
$parts = $data['candidates'][0]['content']['parts'] ?? [];
$content = '';
foreach ( $parts as $part )
{
if ( !empty( $part['thought'] ) )
{
continue;
}
if ( isset( $part['text'] ) && trim( (string) $part['text'] ) !== '' )
{
$content = trim( (string) $part['text'] );
break;
}
}
if ( $content === '' )
{
$finish_reason = $data['candidates'][0]['finishReason'] ?? '';
$message = 'Gemini nie zwróciło treści.';
if ( $finish_reason !== '' && $finish_reason !== 'STOP' )
{
$message .= ' (finishReason: ' . $finish_reason . ')';
}
return [ 'status' => 'error', 'message' => $message ];
}
return [ 'status' => 'ok', 'suggestion' => $content ];
}
static private function build_context_text( $context )
{
$lines = [];
$lines[] = 'Nazwa produktu: ' . ( $context['original_name'] ?? '—' );
if ( !empty( $context['current_title'] ) )
$lines[] = 'Obecny tytuł (custom): ' . $context['current_title'];
if ( !empty( $context['current_description'] ) )
$lines[] = 'Obecny opis: ' . $context['current_description'];
if ( !empty( $context['current_category'] ) )
$lines[] = 'Obecna kategoria Google: ' . $context['current_category'];
if ( !empty( $context['offer_id'] ) )
$lines[] = 'ID oferty: ' . $context['offer_id'];
if ( !empty( $context['custom_label_4'] ) )
$lines[] = 'Status produktu: ' . $context['custom_label_4'];
$lines[] = '';
$lines[] = 'Metryki reklamowe (ostatnie 30 dni):';
$lines[] = '- Wyświetlenia: ' . ( $context['impressions_30'] ?? 0 );
$lines[] = '- Kliknięcia: ' . ( $context['clicks_30'] ?? 0 );
$lines[] = '- CTR: ' . ( $context['ctr'] ?? 0 ) . '%';
$lines[] = '- Koszt: ' . ( $context['cost'] ?? 0 ) . ' PLN';
$lines[] = '- Konwersje: ' . ( $context['conversions'] ?? 0 );
$lines[] = '- Wartość konwersji: ' . ( $context['conversions_value'] ?? 0 ) . ' PLN';
$lines[] = '- ROAS: ' . ( $context['roas'] ?? 0 ) . '%';
if ( !empty( $context['page_content'] ) )
{
$lines[] = '';
$lines[] = 'Treść ze strony produktu (użyj tych informacji do stworzenia dokładniejszego opisu):';
$lines[] = $context['page_content'];
}
return implode( "\n", $lines );
}
static private function get_prompt_template( $setting_key, $default_template )
{
$template = trim( (string) GoogleAdsApi::get_setting( $setting_key ) );
if ( $template === '' )
{
return $default_template;
}
return $template;
}
static private function expand_prompt_template( $template, $vars )
{
$result = (string) $template;
foreach ( (array) $vars as $key => $value )
{
$result = str_replace( '{{' . $key . '}}', (string) $value, $result );
}
return $result;
}
static public function suggest_title( $context )
{
$context_text = self::build_context_text( $context );
$keyword_planner_text = '';
if ( !empty( $context['keyword_planner_terms'] ) && is_array( $context['keyword_planner_terms'] ) )
{
$keyword_lines = [];
$keyword_lines[] = 'Najpopularniejsze frazy z Google Ads Keyword Planner (na bazie URL produktu, posortowane malejąco po średniej liczbie wyszukiwań):';
foreach ( array_slice( $context['keyword_planner_terms'], 0, 15 ) as $term )
{
$text = trim( (string) ( $term['keyword_text'] ?? '' ) );
if ( $text === '' )
{
continue;
}
$avg_monthly = (int) ( $term['avg_monthly_searches'] ?? 0 );
$keyword_lines[] = '- ' . $text . ' (avg miesięcznie: ' . $avg_monthly . ')';
}
if ( count( $keyword_lines ) > 1 )
{
$keyword_lines[] = 'Użyj tych fraz WYBIÓRCZO i naturalnie (bez upychania słów kluczowych), tylko jeśli pasują do produktu.';
$keyword_planner_text = "\n\n" . implode( "\n", $keyword_lines );
}
}
$prompt_template = self::get_prompt_template( 'ai_prompt_title_template', OpenAiApi::get_default_title_prompt_template() );
if ( strpos( $prompt_template, '{{context}}' ) === false )
{
$prompt_template .= "\n\n{{context}}";
}
if ( strpos( $prompt_template, '{{keyword_terms}}' ) === false )
{
$prompt_template .= "{{keyword_terms}}";
}
$prompt = self::expand_prompt_template( $prompt_template, [
'context' => $context_text,
'keyword_terms' => $keyword_planner_text
] );
$prompt .= "\n\nWygeneruj 3 różne warianty tytułu (A/B/C). Zwróć WYŁĄCZNIE poprawny JSON: {\"titles\":[\"wariant A\",\"wariant B\",\"wariant C\"]}.";
$result = self::call_api( self::$system_prompt, $prompt, 1200 );
if ( ( $result['status'] ?? '' ) !== 'ok' )
{
return $result;
}
$candidates = OpenAiApi::parse_title_candidates( (string) ( $result['suggestion'] ?? '' ) );
$best_title = OpenAiApi::pick_best_title_candidate( $candidates, $context );
if ( $best_title !== '' )
{
$result['suggestion'] = $best_title;
$result['title_candidates'] = $candidates;
}
return $result;
}
static public function suggest_description( $context )
{
$context_text = self::build_context_text( $context );
$has_page = !empty( $context['page_content'] );
$keyword_planner_text = '';
if ( !empty( $context['keyword_planner_terms'] ) && is_array( $context['keyword_planner_terms'] ) )
{
$keyword_lines = [];
$keyword_lines[] = 'Najpopularniejsze frazy z Google Ads Keyword Planner (na bazie URL produktu, posortowane malejąco po średniej liczbie wyszukiwań):';
foreach ( array_slice( $context['keyword_planner_terms'], 0, 15 ) as $term )
{
$text = trim( (string) ( $term['keyword_text'] ?? '' ) );
if ( $text === '' )
{
continue;
}
$avg_monthly = (int) ( $term['avg_monthly_searches'] ?? 0 );
$keyword_lines[] = '- ' . $text . ' (avg miesięcznie: ' . $avg_monthly . ')';
}
if ( count( $keyword_lines ) > 1 )
{
$keyword_lines[] = 'W opisie wykorzystuj te frazy naturalnie i wyłącznie gdy realnie pasują do produktu (bez keyword stuffing).';
$keyword_planner_text = "\n\n" . implode( "\n", $keyword_lines );
}
}
$length_guide = $has_page
? '- Napisz rozbudowany, szczegółowy opis: ok. 1000 znaków (800-1200)
- Wykorzystaj szczegóły ze strony produktu: skład zestawu, materiały, wymiary, kolory, przeznaczenie
- Każdy akapit/punkt powinien wnosić NOWĄ informację — NIE powtarzaj tych samych elementów'
: '- Napisz opis o długości ok. 1000 znaków (800-1200) — jeśli brak szczegółów, opisz ogólnie zastosowanie i grupę docelową
- Opisuj TYLKO to co wynika z oryginalnej nazwy — nie wymyślaj konkretnych parametrów';
$prompt_template = self::get_prompt_template( 'ai_prompt_description_template', OpenAiApi::get_default_description_prompt_template() );
if ( strpos( $prompt_template, '{{length_guide}}' ) === false )
{
$prompt_template .= "\n\n{{length_guide}}";
}
if ( strpos( $prompt_template, '{{context}}' ) === false )
{
$prompt_template .= "\n\n{{context}}";
}
if ( strpos( $prompt_template, '{{keyword_terms}}' ) === false )
{
$prompt_template .= "{{keyword_terms}}";
}
$prompt = self::expand_prompt_template( $prompt_template, [
'length_guide' => $length_guide,
'context' => $context_text,
'keyword_terms' => $keyword_planner_text
] );
$tokens = $has_page ? 1500 : 1000;
return self::call_api( self::$system_prompt, $prompt, $tokens );
}
static public function suggest_category( $context, $categories = [] )
{
$context_text = self::build_context_text( $context );
$cats_text = '';
if ( !empty( $categories ) )
{
$cats_list = array_slice( $categories, 0, 50 );
$cats_text = "\n\nDostępne kategorie Google Product Taxonomy (format: id - tekst):\n";
foreach ( $cats_list as $cat )
{
$cats_text .= $cat['id'] . ' - ' . $cat['text'] . "\n";
}
}
$prompt = 'Dopasuj produkt do najlepszej kategorii Google Product Taxonomy.
Wybierz najbardziej szczegółową (najgłębszą) kategorię pasującą do produktu.
' . $context_text . $cats_text . '
Zwróć TYLKO ID kategorii (liczbę), bez wyjaśnień.';
return self::call_api( self::$system_prompt, $prompt );
}
}

View File

@@ -89,15 +89,91 @@ Twoje odpowiedzi muszą być:
$text = strip_tags( $html );
$text = html_entity_decode( $text, ENT_QUOTES, 'UTF-8' );
$text = preg_replace( '/\s+/', ' ', $text );
$text = trim( $text );
$text = self::clean_page_content_text( trim( $text ) );
// Ogranicz do ~3000 znaków
if ( mb_strlen( $text ) > 3000 )
$text = mb_substr( $text, 0, 3000 ) . '...';
// Ogranicz do ~1800 znaków
if ( mb_strlen( $text ) > 1800 )
$text = mb_substr( $text, 0, 1800 ) . '...';
return $text;
}
static private function clean_page_content_text( $text )
{
$text = trim( (string) $text );
if ( $text === '' )
{
return '';
}
$noise_phrases = [
'cookies',
'polityce prywatności',
'zaakceptuj wszystkie',
'odrzuć wszystkie',
'dostosuj zgody',
'sklep jest w trybie podglądu',
'pokaż pełną wersję strony',
'wersje językowe',
'strona główna',
'social media',
'darmowa dostawa',
'profesjonalne doradztwo',
'bezpieczne płatności',
'szybka wysyłka',
'produkt miesiąca',
'niezbędne do działania strony',
'analityczne',
'marketingowe',
'funkcjonalne',
'shoper'
];
$parts = preg_split( '/(?<=[\.\!\?])\s+|\s{2,}/u', $text );
$clean_parts = [];
$seen = [];
foreach ( (array) $parts as $part )
{
$part = trim( (string) $part );
if ( mb_strlen( $part ) < 25 )
{
continue;
}
$part_l = mb_strtolower( $part );
$is_noise = false;
foreach ( $noise_phrases as $phrase )
{
if ( mb_strpos( $part_l, $phrase ) !== false )
{
$is_noise = true;
break;
}
}
if ( $is_noise )
{
continue;
}
if ( isset( $seen[ $part_l ] ) )
{
continue;
}
$seen[ $part_l ] = true;
$clean_parts[] = $part;
}
$result = trim( implode( '. ', $clean_parts ) );
if ( mb_strlen( $result ) < 80 )
{
return '';
}
return $result;
}
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' );
@@ -195,41 +271,320 @@ Twoje odpowiedzi muszą być:
return implode( "\n", $lines );
}
static public function suggest_title( $context )
static private function get_prompt_template( $setting_key, $default_template )
{
$context_text = self::build_context_text( $context );
$keyword_planner_text = '';
if ( !empty( $context['keyword_planner_terms'] ) && is_array( $context['keyword_planner_terms'] ) )
$template = trim( (string) GoogleAdsApi::get_setting( $setting_key ) );
if ( $template === '' )
{
$keyword_lines = [];
$keyword_lines[] = 'Najpopularniejsze frazy z Google Ads Keyword Planner (na bazie URL produktu, posortowane malejąco po średniej liczbie wyszukiwań):';
return $default_template;
}
foreach ( array_slice( $context['keyword_planner_terms'], 0, 15 ) as $term )
return $template;
}
static private function expand_prompt_template( $template, $vars )
{
$result = (string) $template;
foreach ( (array) $vars as $key => $value )
{
$result = str_replace( '{{' . $key . '}}', (string) $value, $result );
}
return $result;
}
static private function extract_meaningful_tokens( $text )
{
$text = mb_strtolower( (string) $text );
$text = preg_replace( '/[^\p{L}\p{N}]+/u', ' ', $text );
$raw_tokens = preg_split( '/\s+/u', trim( (string) $text ) );
$stopwords = [
'oraz', 'przez', 'jego', 'jej', 'this', 'that', 'with', 'from', 'jest',
'dla', 'the', 'and', 'lub', 'czy', 'ten', 'ta', 'to', 'tych', 'taki',
'takie', 'jako', 'się', 'sie', 'nad', 'pod', 'bez', 'www', 'http', 'https'
];
$stop_map = array_fill_keys( $stopwords, true );
$tokens = [];
foreach ( (array) $raw_tokens as $token )
{
$token = trim( (string) $token );
if ( mb_strlen( $token ) < 4 )
{
$text = trim( (string) ( $term['keyword_text'] ?? '' ) );
if ( $text === '' )
{
continue;
}
continue;
}
if ( isset( $stop_map[ $token ] ) )
{
continue;
}
$tokens[ $token ] = true;
}
$avg_monthly = (int) ( $term['avg_monthly_searches'] ?? 0 );
$keyword_lines[] = '- ' . $text . ' (avg miesięcznie: ' . $avg_monthly . ')';
return array_keys( $tokens );
}
static private function select_relevant_keyword_terms( $terms, $context, $limit = 10 )
{
$terms = is_array( $terms ) ? $terms : [];
if ( empty( $terms ) )
{
return [];
}
$context_source = trim( (string) ( $context['original_name'] ?? '' ) ) . ' ' . trim( (string) ( $context['page_content'] ?? '' ) );
$context_tokens = self::extract_meaningful_tokens( $context_source );
$context_map = array_fill_keys( $context_tokens, true );
$scored = [];
foreach ( $terms as $idx => $term )
{
$keyword_text = trim( (string) ( $term['keyword_text'] ?? '' ) );
if ( $keyword_text === '' )
{
continue;
}
if ( count( $keyword_lines ) > 1 )
$kw_tokens = self::extract_meaningful_tokens( $keyword_text );
$overlap = 0;
foreach ( $kw_tokens as $kw_token )
{
$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 );
if ( isset( $context_map[ $kw_token ] ) )
{
$overlap++;
}
}
$avg = (int) ( $term['avg_monthly_searches'] ?? 0 );
$score = ( $overlap * 1000000 ) + $avg - (int) $idx;
if ( $overlap <= 0 )
{
$score -= 5000000;
}
$term['_score'] = $score;
$term['_overlap'] = $overlap;
$scored[] = $term;
}
usort( $scored, function( $a, $b )
{
return (int) ( $b['_score'] ?? 0 ) <=> (int) ( $a['_score'] ?? 0 );
} );
$filtered = array_values( array_filter( $scored, function( $row )
{
return (int) ( $row['_overlap'] ?? 0 ) > 0;
} ) );
if ( empty( $filtered ) )
{
$filtered = $scored;
}
$filtered = array_slice( $filtered, 0, max( 1, (int) $limit ) );
foreach ( $filtered as &$row )
{
unset( $row['_score'], $row['_overlap'] );
}
unset( $row );
return $filtered;
}
static private function build_keyword_planner_text( $context, $usage_line, $limit = 10 )
{
$terms = self::select_relevant_keyword_terms( (array) ( $context['keyword_planner_terms'] ?? [] ), $context, $limit );
if ( empty( $terms ) )
{
return '';
}
$keyword_lines = [];
$keyword_lines[] = 'Najpopularniejsze frazy z Google Ads Keyword Planner (na bazie URL produktu, posortowane malejąco po średniej liczbie wyszukiwań):';
foreach ( $terms 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 )
{
return '';
}
$keyword_lines[] = $usage_line;
return "\n\n" . implode( "\n", $keyword_lines );
}
static public function parse_title_candidates( $raw )
{
$raw = trim( (string) $raw );
if ( $raw === '' )
{
return [];
}
$data = json_decode( $raw, true );
if ( is_array( $data ) && !empty( $data['titles'] ) && is_array( $data['titles'] ) )
{
return array_values( array_filter( array_map( 'trim', $data['titles'] ) ) );
}
if ( preg_match( '/\{[\s\S]*\}/u', $raw, $m ) )
{
$data = json_decode( (string) $m[0], true );
if ( is_array( $data ) && !empty( $data['titles'] ) && is_array( $data['titles'] ) )
{
return array_values( array_filter( array_map( 'trim', $data['titles'] ) ) );
}
}
$prompt = 'Zaproponuj zoptymalizowany tytuł produktu dla Google Merchant Center.
$lines = preg_split( '/\r?\n/u', $raw );
$titles = [];
foreach ( (array) $lines as $line )
{
$line = trim( (string) $line );
$line = preg_replace( '/^\d+[\)\.\-\s]+/u', '', $line );
$line = trim( (string) $line, " \t\n\r\0\x0B\"'" );
if ( $line !== '' )
{
$titles[] = $line;
}
}
return array_values( array_slice( array_unique( $titles ), 0, 5 ) );
}
static private function score_title_candidate( $title, $context )
{
$title = trim( (string) $title );
if ( $title === '' )
{
return -100000;
}
$score = 0;
$len = mb_strlen( $title );
// Kara za przekroczenie limitu Google Merchant Center
if ( $len > 150 )
{
$score -= 2000;
}
// Sweet spot: 60-120 znaków — tytuł wzbogacony, ale nie za długi
else if ( $len >= 60 && $len <= 120 )
{
$score += 180;
}
else if ( $len >= 40 && $len <= 140 )
{
$score += 100;
}
else if ( $len >= 25 )
{
$score += 30;
}
else
{
$score -= 200;
}
$bad_words = [ 'bestseller', 'hit', 'okazja', 'tanio', 'gratis', 'promocja', 'najlepszy', 'polecamy', 'nowość' ];
$title_l = mb_strtolower( $title );
foreach ( $bad_words as $bad_word )
{
if ( mb_strpos( $title_l, $bad_word ) !== false )
{
$score -= 300;
}
}
$source_tokens = self::extract_meaningful_tokens( (string) ( $context['original_name'] ?? '' ) );
$title_tokens = self::extract_meaningful_tokens( $title );
$source_map = array_fill_keys( $source_tokens, true );
// Overlap z oryginalną nazwą — zmniejszona waga, żeby nie preferować kopii oryginału
$overlap = 0;
foreach ( $title_tokens as $token )
{
if ( isset( $source_map[ $token ] ) )
{
$overlap++;
}
}
$score += ( $overlap * 15 );
// Bonus za wzbogacenie tytułu informacjami ze strony produktu
$page_content = trim( (string) ( $context['page_content'] ?? '' ) );
if ( $page_content !== '' )
{
$page_tokens = self::extract_meaningful_tokens( $page_content );
$page_map = array_fill_keys( $page_tokens, true );
$enrichment = 0;
foreach ( $title_tokens as $token )
{
// Token jest ze strony produktu, ale NIE z oryginalnej nazwy = wzbogacenie
if ( isset( $page_map[ $token ] ) && !isset( $source_map[ $token ] ) )
{
$enrichment++;
}
}
$score += min( $enrichment * 20, 100 );
}
if ( preg_match( '/\d+\s?(ml|l|g|kg|cm|mm)/iu', (string) ( $context['original_name'] ?? '' ), $m ) )
{
if ( preg_match( '/' . preg_quote( $m[0], '/' ) . '/iu', $title ) )
{
$score += 30;
}
}
return $score;
}
static public function pick_best_title_candidate( $candidates, $context )
{
$best_title = '';
$best_score = -1000000;
foreach ( (array) $candidates as $candidate )
{
$candidate = trim( (string) $candidate );
if ( $candidate === '' )
{
continue;
}
$score = self::score_title_candidate( $candidate, $context );
if ( $score > $best_score )
{
$best_score = $score;
$best_title = $candidate;
}
}
return $best_title;
}
static public function get_default_title_prompt_template()
{
return 'Zaproponuj zoptymalizowany tytuł produktu dla Google Merchant Center.
WYMAGANIA:
- Max 150 znaków, najważniejsze info w pierwszych 70 znakach
- Struktura: [Marka jeśli jest w nazwie] [Typ produktu] [Kluczowe cechy z nazwy] [Dla kogo/okazja jeśli wynika z nazwy]
- Pisownia zdaniowa lub tytułowa, naturalny język
- Pisownia zdaniowa (wielka litera tylko na początku i w nazwach własnych/markach) — NIE stosuj Title Case / Camel Case, np. "Preparat do laminacji rzęs 20ml" a NIE "Preparat Do Laminacji Rzęs 20ml"
- Tytuł musi brzmieć jak wpis w katalogu produktowym
BEZWZGLĘDNY ZAKAZ (odrzucenie przez Google):
@@ -240,54 +595,17 @@ BEZWZGLĘDNY ZAKAZ (odrzucenie przez Google):
- Cechy wymyślone — opisuj TYLKO to co wynika z oryginalnej nazwy lub treści strony produktu
- Jeśli podano treść ze strony produktu, wykorzystaj ją do wzbogacenia tytułu o rzeczywiste cechy (marka, materiał, kolor, rozmiar itp.)
' . $context_text . $keyword_planner_text . '
{{context}}{{keyword_terms}}
Zwróć TYLKO tytuł, bez cudzysłowów, bez wyjaśnień.';
return self::call_api( self::$system_prompt, $prompt );
}
static public function suggest_description( $context )
static public function get_default_description_prompt_template()
{
$context_text = self::build_context_text( $context );
$has_page = !empty( $context['page_content'] );
$keyword_planner_text = '';
if ( !empty( $context['keyword_planner_terms'] ) && is_array( $context['keyword_planner_terms'] ) )
{
$keyword_lines = [];
$keyword_lines[] = 'Najpopularniejsze frazy z Google Ads Keyword Planner (na bazie URL produktu, posortowane malejąco po średniej liczbie wyszukiwań):';
foreach ( array_slice( $context['keyword_planner_terms'], 0, 15 ) as $term )
{
$text = trim( (string) ( $term['keyword_text'] ?? '' ) );
if ( $text === '' )
{
continue;
}
$avg_monthly = (int) ( $term['avg_monthly_searches'] ?? 0 );
$keyword_lines[] = '- ' . $text . ' (avg miesięcznie: ' . $avg_monthly . ')';
}
if ( count( $keyword_lines ) > 1 )
{
$keyword_lines[] = 'W opisie wykorzystuj te frazy naturalnie i wyłącznie gdy realnie pasują do produktu (bez keyword stuffing).';
$keyword_planner_text = "\n\n" . implode( "\n", $keyword_lines );
}
}
$length_guide = $has_page
? '- Napisz rozbudowany, szczegółowy opis: ok. 1000 znaków (800-1200)
- Wykorzystaj szczegóły ze strony produktu: skład zestawu, materiały, wymiary, kolory, przeznaczenie
- Każdy akapit/punkt powinien wnosić NOWĄ informację — NIE powtarzaj tych samych elementów'
: '- Napisz opis o długości ok. 1000 znaków (800-1200) — jeśli brak szczegółów, opisz ogólnie zastosowanie i grupę docelową
- Opisuj TYLKO to co wynika z oryginalnej nazwy — nie wymyślaj konkretnych parametrów';
$prompt = 'Napisz zoptymalizowany opis produktu dla Google Merchant Center.
return 'Napisz zoptymalizowany opis produktu dla Google Merchant Center.
WYMAGANIA:
' . $length_guide . '
{{length_guide}}
- Najważniejsze info na początku (pierwsze 160 znaków widoczne w wynikach)
- Rzeczowo opisz: co to jest, z czego się składa, do czego służy, dla kogo
- Ton neutralny, informacyjny — jak w specyfikacji produktowej
@@ -308,9 +626,95 @@ BEZWZGLĘDNY ZAKAZ (odrzucenie przez Google):
- Opisy akcesoriów/produktów nie wchodzących w skład oferty
- Cechy wymyślone — opisuj TYLKO to co wynika z nazwy lub treści strony produktu
' . $context_text . $keyword_planner_text . '
{{context}}{{keyword_terms}}
Zwróć TYLKO opis w formacie HTML (używając dozwolonych tagów), bez cudzysłowów, bez wyjaśnień.';
}
static public function suggest_title( $context )
{
$context_text = self::build_context_text( $context );
$keyword_planner_text = self::build_keyword_planner_text(
$context,
'Użyj tych fraz WYBIÓRCZO i naturalnie (bez upychania słów kluczowych), tylko jeśli pasują do produktu.',
8
);
$prompt = self::get_default_title_prompt_template();
$prompt_template = self::get_prompt_template( 'ai_prompt_title_template', $prompt );
if ( strpos( $prompt_template, '{{context}}' ) === false )
{
$prompt_template .= "\n\n{{context}}";
}
if ( strpos( $prompt_template, '{{keyword_terms}}' ) === false )
{
$prompt_template .= "{{keyword_terms}}";
}
$prompt = self::expand_prompt_template( $prompt_template, [
'context' => $context_text,
'keyword_terms' => $keyword_planner_text
] );
$prompt .= "\n\nWygeneruj 3 różne warianty tytułu (A/B/C). Zwróć WYŁĄCZNIE poprawny JSON: {\"titles\":[\"wariant A\",\"wariant B\",\"wariant C\"]}.";
$result = self::call_api( self::$system_prompt, $prompt, 1200 );
if ( ( $result['status'] ?? '' ) !== 'ok' )
{
return $result;
}
$candidates = self::parse_title_candidates( (string) ( $result['suggestion'] ?? '' ) );
$best_title = self::pick_best_title_candidate( $candidates, $context );
if ( $best_title !== '' )
{
$result['suggestion'] = $best_title;
$result['title_candidates'] = $candidates;
}
return $result;
}
static public function suggest_description( $context )
{
$context_text = self::build_context_text( $context );
$has_page = !empty( $context['page_content'] );
$keyword_planner_text = self::build_keyword_planner_text(
$context,
'W opisie wykorzystuj te frazy naturalnie i wyłącznie gdy realnie pasują do produktu (bez keyword stuffing).',
12
);
$length_guide = $has_page
? '- Napisz rozbudowany, szczegółowy opis: ok. 1000 znaków (800-1200)
- Wykorzystaj szczegóły ze strony produktu: skład zestawu, materiały, wymiary, kolory, przeznaczenie
- Każdy akapit/punkt powinien wnosić NOWĄ informację — NIE powtarzaj tych samych elementów'
: '- Napisz opis o długości ok. 1000 znaków (800-1200) — jeśli brak szczegółów, opisz ogólnie zastosowanie i grupę docelową
- Opisuj TYLKO to co wynika z oryginalnej nazwy — nie wymyślaj konkretnych parametrów';
$prompt = self::get_default_description_prompt_template();
$prompt_template = self::get_prompt_template( 'ai_prompt_description_template', $prompt );
if ( strpos( $prompt_template, '{{length_guide}}' ) === false )
{
$prompt_template .= "\n\n{{length_guide}}";
}
if ( strpos( $prompt_template, '{{context}}' ) === false )
{
$prompt_template .= "\n\n{{context}}";
}
if ( strpos( $prompt_template, '{{keyword_terms}}' ) === false )
{
$prompt_template .= "{{keyword_terms}}";
}
$prompt = self::expand_prompt_template( $prompt_template, [
'length_guide' => $length_guide,
'context' => $context_text,
'keyword_terms' => $keyword_planner_text
] );
$tokens = $has_page ? 1500 : 1000;
return self::call_api( self::$system_prompt, $prompt, $tokens );

View File

@@ -47,6 +47,8 @@ $route_aliases = [
'settings/save_google_ads' => ['users', 'settings_save_google_ads'],
'settings/save_openai' => ['users', 'settings_save_openai'],
'settings/save_claude' => ['users', 'settings_save_claude'],
'settings/save_gemini' => ['users', 'settings_save_gemini'],
'settings/save_ai_prompts' => ['users', 'settings_save_ai_prompts'],
'products/ai_suggest' => ['products', 'ai_suggest'],
'clients/save' => ['clients', 'save'],
'logs' => ['logs', 'main_view'],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -799,6 +799,127 @@ table {
}
}
// --- Unified app tables (clients/campaigns/products/logs) ---
.clients-table-wrap,
.campaigns-table-wrap,
.products-table-wrap,
.logs-table-wrap {
background: $cWhite;
border-radius: 10px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
overflow-x: auto;
max-width: 100%;
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
.table {
margin: 0;
width: 100% !important;
thead th {
background: #F8FAFC;
border-bottom: 2px solid $cBorder;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #8899A6;
padding: 12px 16px;
white-space: nowrap;
}
tbody td {
padding: 10px 16px;
font-size: 13px;
color: $cTextDark;
vertical-align: middle;
border-bottom: 1px solid #F1F5F9;
}
tbody tr:hover td {
background: #F8FAFC;
}
}
.dt-layout-row {
padding: 14px 20px;
margin: 0 !important;
border-top: 1px solid #F1F5F9;
&:first-child {
display: none;
}
}
.dt-info {
font-size: 13px;
color: #8899A6;
}
.dt-paging {
.pagination {
margin: 0;
padding: 0;
list-style: none;
display: flex;
align-items: center;
gap: 6px;
.page-item {
.page-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
width: fit-content;
height: 36px;
padding: 0 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
border: 1px solid $cBorder;
background: $cWhite;
color: $cText;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
line-height: 1;
white-space: nowrap;
&:hover {
background: #EEF2FF;
color: $cPrimary;
border-color: $cPrimary;
}
}
&.active .page-link {
background: $cPrimary;
color: $cWhite;
border-color: $cPrimary;
font-weight: 600;
}
&.disabled .page-link {
opacity: 0.35;
cursor: default;
pointer-events: none;
}
}
}
}
.dt-processing {
background: rgba($cWhite, 0.9);
color: $cText;
font-size: 14px;
}
}
// --- Cards ---
.card {
background: $cWhite;
@@ -1094,35 +1215,7 @@ table {
}
.clients-table-wrap {
background: $cWhite;
border-radius: 10px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
overflow: hidden;
.table {
margin: 0;
thead th {
background: #F8FAFC;
border-bottom: 2px solid $cBorder;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #8899A6;
padding: 14px 20px;
}
tbody td {
padding: 14px 20px;
vertical-align: middle;
border-bottom: 1px solid #F1F5F9;
}
tbody tr:hover {
background: #F8FAFC;
}
.client-id {
color: #8899A6;
font-size: 13px;
@@ -1638,120 +1731,8 @@ table {
}
.campaigns-table-wrap {
background: $cWhite;
border-radius: 10px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
overflow-x: auto;
-ms-overflow-style: none;
scrollbar-width: none;
max-width: 100%;
&::-webkit-scrollbar {
display: none;
}
.table {
margin: 0;
width: 100% !important;
thead th {
background: #F8FAFC;
border-bottom: 2px solid $cBorder;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #8899A6;
padding: 12px 16px;
white-space: nowrap;
}
tbody td {
padding: 10px 16px;
vertical-align: middle;
border-bottom: 1px solid #F1F5F9;
font-size: 13px;
}
tbody tr:hover {
background: #F8FAFC;
}
}
// DataTables 2.x overrides
.dt-layout-row {
padding: 14px 20px;
margin: 0 !important;
border-top: 1px solid #F1F5F9;
// Ukryj wiersz z search/length jeśli pusty
&:first-child {
display: none;
}
}
.dt-info {
font-size: 13px;
color: #8899A6;
}
.dt-paging {
.pagination {
margin: 0;
padding: 0;
list-style: none;
display: flex;
align-items: center;
gap: 6px;
.page-item {
.page-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
width: fit-content;
height: 36px;
padding: 0 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
border: 1px solid $cBorder;
background: $cWhite;
color: $cText;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
line-height: 1;
white-space: nowrap;
&:hover {
background: #EEF2FF;
color: $cPrimary;
border-color: $cPrimary;
}
}
&.active .page-link {
background: $cPrimary;
color: $cWhite;
border-color: $cPrimary;
font-weight: 600;
}
&.disabled .page-link {
opacity: 0.35;
cursor: default;
pointer-events: none;
}
}
}
}
.dt-processing {
background: rgba($cWhite, 0.9);
color: $cText;
font-size: 14px;
width: 100%;
}
}
@@ -1990,37 +1971,8 @@ table {
}
.products-table-wrap {
background: $cWhite;
border-radius: 10px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
overflow: hidden;
.table {
margin: 0;
width: 100% !important;
thead th {
background: #F8FAFC;
border-bottom: 2px solid $cBorder;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.3px;
color: #8899A6;
padding: 10px 8px;
white-space: nowrap;
}
tbody td {
padding: 6px 8px;
vertical-align: middle;
border-bottom: 1px solid #F1F5F9;
font-size: 12px;
}
tbody tr:hover {
background: #F8FAFC;
}
width: 100%;
// Kompaktowe inputy w tabeli
input.min_roas,
@@ -2034,80 +1986,6 @@ table {
background: $cWhite;
}
}
// DataTables 2.x overrides (identyczne z campaigns)
.dt-layout-row {
padding: 14px 20px;
margin: 0 !important;
border-top: 1px solid #F1F5F9;
&:first-child {
display: none;
}
}
.dt-info {
font-size: 13px;
color: #8899A6;
}
.dt-paging {
.pagination {
margin: 0;
padding: 0;
list-style: none;
display: flex;
align-items: center;
gap: 6px;
.page-item {
.page-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
height: 36px;
padding: 0 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
border: 1px solid $cBorder;
background: $cWhite;
color: $cText;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
line-height: 1;
white-space: nowrap;
&:hover {
background: #EEF2FF;
color: $cPrimary;
border-color: $cPrimary;
}
}
&.active .page-link {
background: $cPrimary;
color: $cWhite;
border-color: $cPrimary;
font-weight: 600;
}
&.disabled .page-link {
opacity: 0.35;
cursor: default;
pointer-events: none;
}
}
}
}
.dt-processing {
background: rgba($cWhite, 0.9);
color: $cText;
font-size: 14px;
}
}
// Przycisk usuwania w wierszu
@@ -2274,6 +2152,18 @@ table {
border-color: #B45309;
}
}
&.btn-ai-gemini {
border-color: #4285F4;
background: linear-gradient(135deg, #E8F0FE, #D2E3FC);
color: #1A73E8;
&:hover {
background: linear-gradient(135deg, #4285F4, #1A73E8);
color: #FFF;
border-color: #1A73E8;
}
}
}
// --- Form container ---
@@ -3468,71 +3358,6 @@ table#products {
}
}
.products-page table#products>thead>tr>th {
position: sticky;
top: 0;
z-index: 2;
background-color: #111827 !important;
color: #E5E7EB !important;
border-bottom: 1px solid #0B1220 !important;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .4px;
padding: 10px 12px;
white-space: nowrap;
}
.products-page #products thead th .dt-column-order {
display: none !important;
}
.products-page #products thead th.dt-orderable-asc,
.products-page #products thead th.dt-orderable-desc {
cursor: pointer;
padding-right: 34px;
overflow: hidden;
}
.products-page #products thead th .dt-column-title {
display: block;
overflow: hidden;
text-overflow: ellipsis;
padding-right: 2px;
}
.products-page #products thead th.dt-orderable-asc::after,
.products-page #products thead th.dt-orderable-desc::after {
content: '\2195';
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
line-height: 16px;
text-align: center;
color: #E5E7EB;
background: #374151;
}
.products-page #products thead th.dt-ordering-asc::after,
.products-page #products thead th[aria-sort="ascending"]::after {
content: '\25B2';
color: #FFFFFF;
background: #2563EB;
}
.products-page #products thead th.dt-ordering-desc::after,
.products-page #products thead th[aria-sort="descending"]::after {
content: '\25BC';
color: #FFFFFF;
background: #2563EB;
}
// ===========================
// LOGS PAGE
// ===========================
@@ -3609,111 +3434,8 @@ table#products {
}
.logs-table-wrap {
background: $cWhite;
border-radius: 10px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
overflow: hidden;
.table {
margin: 0;
thead th {
background: #F0F4FA;
border-bottom: 2px solid $cBorder;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #8899A6;
padding: 12px 14px;
white-space: nowrap;
}
tbody td {
padding: 10px 14px;
font-size: 13px;
color: $cTextDark;
vertical-align: middle;
border-bottom: 1px solid #EEF2F7;
}
tbody tr:hover td {
background: #F8FAFD;
}
}
.dt-layout-row {
padding: 14px 20px;
margin: 0 !important;
border-top: 1px solid #F1F5F9;
&:first-child {
display: none;
}
}
.dt-info {
font-size: 13px;
color: #8899A6;
}
.dt-paging {
.pagination {
margin: 0;
padding: 0;
list-style: none;
display: flex;
align-items: center;
gap: 6px;
.page-item {
.page-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
width: fit-content;
height: 36px;
padding: 0 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
border: 1px solid $cBorder;
background: $cWhite;
color: $cText;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
line-height: 1;
white-space: nowrap;
&:hover {
background: #EEF2FF;
color: $cPrimary;
border-color: $cPrimary;
}
}
&.active .page-link {
background: $cPrimary;
color: $cWhite;
border-color: $cPrimary;
font-weight: 600;
}
&.disabled .page-link {
opacity: 0.35;
cursor: default;
pointer-events: none;
}
}
}
}
.dt-processing {
background: rgba($cWhite, 0.9);
color: $cText;
font-size: 14px;
width: 100%;
}
}
@@ -3741,4 +3463,9 @@ table#products {
background: #FEF3C7;
color: #92400E;
}
}
.js-title-alt-apply {
color: #000;
justify-content: flex-start;
}

View File

@@ -1370,6 +1370,7 @@ function build_ad_groups_table( rows )
terms_ad_groups_table = new DataTable( '#terms_ad_groups_table', {
data: rows || [],
stateSave: true,
processing: false,
serverSide: false,
autoWidth: false,
@@ -1421,6 +1422,7 @@ function build_search_terms_table( rows, negative_keywords )
terms_search_table = new DataTable( '#terms_search_table', {
data: rows || [],
stateSave: true,
processing: false,
serverSide: false,
autoWidth: false,
@@ -1512,6 +1514,7 @@ function build_negative_terms_table( rows )
terms_negative_table = new DataTable( '#terms_negative_table', {
data: rows || [],
stateSave: true,
processing: false,
serverSide: false,
autoWidth: false,
@@ -1586,6 +1589,7 @@ function build_keywords_table( rows )
terms_keywords_table = new DataTable( '#terms_keywords_table', {
data: rows || [],
stateSave: true,
processing: false,
serverSide: false,
autoWidth: false,

View File

@@ -559,6 +559,7 @@ $( function()
type: 'POST',
url: '/campaigns/get_campaign_history_data_table/campaign_id=' + campaign_id,
},
stateSave: true,
processing: true,
serverSide: true,
searching: false,

View File

@@ -250,6 +250,7 @@ function fb_reload_table()
];
new DataTable( '#fb_history_table', {
stateSave: true,
ajax: {
type: 'POST',
url: '/facebook_ads/get_history_data_table',

View File

@@ -79,6 +79,7 @@ function initLogsTable()
}
logsTable = new DataTable( '#logs-table', {
stateSave: true,
ajax: {
url: '/logs/get_data_table/',
data: function( d )

View File

@@ -104,10 +104,12 @@
<?php
$openai_enabled = \services\GoogleAdsApi::get_setting( 'openai_enabled' ) !== '0';
$claude_enabled = \services\GoogleAdsApi::get_setting( 'claude_enabled' ) !== '0';
$gemini_enabled = \services\GoogleAdsApi::get_setting( 'gemini_enabled' ) !== '0';
?>
<script type="text/javascript">
var AI_OPENAI_ENABLED = <?= $openai_enabled ? 'true' : 'false'; ?>;
var AI_CLAUDE_ENABLED = <?= $claude_enabled ? 'true' : 'false'; ?>;
var AI_GEMINI_ENABLED = <?= $gemini_enabled ? 'true' : 'false'; ?>;
var PRODUCTS_COLUMNS_STORAGE_KEY = 'products.columns.visibility';
var PRODUCTS_LOCKED_COLUMNS = [ 0, 20 ];
@@ -308,6 +310,7 @@ function products_render_columns_picker( table_instance )
$( function()
{
var products_table = new DataTable( '#products', {
stateSave: true,
ajax: {
type: 'POST',
url: '/products/get_products/',
@@ -372,7 +375,7 @@ $( function()
products_table.ajax.reload( null, false );
}
function submit_delete_campaign_ad_group( campaign_id, ad_group_id, delete_scope )
function submit_delete_campaign_ad_group( campaign_id, ad_group_id, delete_scope, on_success )
{
function parse_json_loose( raw )
{
@@ -412,6 +415,12 @@ $( function()
function handle_success( message )
{
show_toast( message || 'Grupa reklam zostala usunieta.', 'success' );
if ( typeof on_success === 'function' )
{
on_success();
}
localStorage.removeItem( 'products_ad_group_id' );
load_products_ad_groups( campaign_id, '' ).done( function() {
$.when( load_scope_alerts(), load_zero_impressions_products() ).always( function() {
@@ -1138,7 +1147,12 @@ $( function()
btnClass: 'btn-default',
action: function()
{
return submit_delete_campaign_ad_group( campaign_id, ad_group_id, 'local' );
var modal = this;
submit_delete_campaign_ad_group( campaign_id, ad_group_id, 'local', function()
{
modal.close();
} );
return false;
}
},
google: {
@@ -1146,7 +1160,12 @@ $( function()
btnClass: 'btn-red',
action: function()
{
return submit_delete_campaign_ad_group( campaign_id, ad_group_id, 'google' );
var modal = this;
submit_delete_campaign_ad_group( campaign_id, ad_group_id, 'google', function()
{
modal.close();
} );
return false;
}
},
cancel: {
@@ -1383,8 +1402,10 @@ $( function()
'<input type="text" value="" product_id="' + $( this ).attr( 'product_id' ) + '" placeholder="Tytuł produktu" class="name form-control" required />' +
( AI_OPENAI_ENABLED ? '<button type="button" class="btn btn-sm btn-ai-suggest" data-field="title" data-provider="openai" title="Zaproponuj tytuł przez ChatGPT"><i class="fa-solid fa-wand-magic-sparkles"></i> GPT</button>' : '' ) +
( AI_CLAUDE_ENABLED ? '<button type="button" class="btn btn-sm btn-ai-suggest btn-ai-claude" data-field="title" data-provider="claude" title="Zaproponuj tytuł przez Claude"><i class="fa-solid fa-brain"></i> Claude</button>' : '' ) +
( AI_GEMINI_ENABLED ? '<button type="button" class="btn btn-sm btn-ai-suggest btn-ai-gemini" data-field="title" data-provider="gemini" title="Zaproponuj tytuł przez Gemini"><i class="fa-solid fa-diamond"></i> Gemini</button>' : '' ) +
'</div>' +
'<small>0/150 znaków</small>' +
'<div class="title-ai-alternatives" style="margin-top:8px;display:none;"></div>' +
'</div>' +
'<div class="form-group">' +
'<label>URL strony produktu <small class="text-muted">(opcjonalnie, dla lepszego kontekstu AI)</small></label>' +
@@ -1405,6 +1426,7 @@ $( function()
'</div>' +
( AI_OPENAI_ENABLED ? '<button type="button" class="btn btn-sm btn-ai-suggest" data-field="description" data-provider="openai" title="Zaproponuj opis przez ChatGPT"><i class="fa-solid fa-wand-magic-sparkles"></i> GPT</button>' : '' ) +
( AI_CLAUDE_ENABLED ? '<button type="button" class="btn btn-sm btn-ai-suggest btn-ai-claude" data-field="description" data-provider="claude" title="Zaproponuj opis przez Claude"><i class="fa-solid fa-brain"></i> Claude</button>' : '' ) +
( AI_GEMINI_ENABLED ? '<button type="button" class="btn btn-sm btn-ai-suggest btn-ai-gemini" data-field="description" data-provider="gemini" title="Zaproponuj opis przez Gemini"><i class="fa-solid fa-diamond"></i> Gemini</button>' : '' ) +
'</div>' +
'</div>' +
'<div class="form-group">' +
@@ -1415,6 +1437,7 @@ $( function()
'</select>' +
( AI_OPENAI_ENABLED ? '<button type="button" class="btn btn-sm btn-ai-suggest" data-field="category" data-provider="openai" title="Zaproponuj kategorię przez ChatGPT"><i class="fa-solid fa-wand-magic-sparkles"></i> GPT</button>' : '' ) +
( AI_CLAUDE_ENABLED ? '<button type="button" class="btn btn-sm btn-ai-suggest btn-ai-claude" data-field="category" data-provider="claude" title="Zaproponuj kategorię przez Claude"><i class="fa-solid fa-brain"></i> Claude</button>' : '' ) +
( AI_GEMINI_ENABLED ? '<button type="button" class="btn btn-sm btn-ai-suggest btn-ai-gemini" data-field="category" data-provider="gemini" title="Zaproponuj kategorię przez Gemini"><i class="fa-solid fa-diamond"></i> Gemini</button>' : '' ) +
'</div>' +
'</div>' +
'</form>',
@@ -1478,8 +1501,56 @@ $( function()
var $description = this.$content.find( '.description' );
var $productUrl = this.$content.find( '.product-url' );
var $googleCategory = this.$content.find( '.google-category' );
var $titleAlternatives = this.$content.find( '.title-ai-alternatives' );
var product_id = $inputField.attr( 'product_id' );
function set_title_value( value ) {
value = String( value || '' );
$inputField.val( value );
var len = value.length;
$charCount.text( len + '/150 znaków' );
$inputField.toggleClass( 'is-invalid', len > 150 );
}
function render_title_alternatives( bestTitle, candidates ) {
var current = $.trim( String( bestTitle || '' ) );
var seen = {};
var list = [];
( candidates || [] ).forEach( function( item ) {
var title = $.trim( String( item || '' ) );
if ( !title ) {
return;
}
var key = title.toLowerCase();
if ( key === current.toLowerCase() || seen[ key ] ) {
return;
}
seen[ key ] = true;
list.push( title );
} );
if ( !list.length ) {
$titleAlternatives.hide().empty();
return;
}
var html = '<div class="js-title-alts-list" style="margin-top:8px;"><small class="text-muted">Alternatywy:</small>';
list.forEach( function( title, idx ) {
html += '<div style="margin-top:4px;">'
+ '<button type="button" class="btn btn-xs btn-default js-title-alt-apply" data-title-alt="' + escape_html( title ) + '" style="width:100%;text-align:left;">'
+ ( idx + 1 ) + '. ' + escape_html( title )
+ '</button>'
+ '</div>';
} );
html += '</div>';
$titleAlternatives.html( html ).show();
}
$.ajax({
url: '/products/get_product_data/',
type: 'POST',
@@ -1488,8 +1559,7 @@ $( function()
var data = JSON.parse( response );
if ( data.status == 'ok' ) {
if ( data.product_details.title ) {
$inputField.val( data.product_details.title );
$charCount.text( data.product_details.title.length + '/150 znaków' );
set_title_value( data.product_details.title );
}
if ( data.product_details.description ) {
$description.val( data.product_details.description );
@@ -1544,7 +1614,7 @@ $( function()
var field = $btn.data( 'field' );
var provider = $btn.data( 'provider' ) || 'openai';
var originalHtml = $btn.html();
var providerLabel = provider === 'claude' ? 'Claude' : 'ChatGPT';
var providerLabel = provider === 'claude' ? 'Claude' : ( provider === 'gemini' ? 'Gemini' : 'ChatGPT' );
$btn.prop( 'disabled', true ).html( '<i class="fa-solid fa-spinner fa-spin"></i>' );
@@ -1556,10 +1626,8 @@ $( function()
var data = JSON.parse( response );
if ( data.status == 'ok' ) {
if ( field == 'title' ) {
$inputField.val( data.suggestion );
var len = data.suggestion.length;
$charCount.text( len + '/150 znaków' );
$inputField.toggleClass( 'is-invalid', len > 150 );
set_title_value( data.suggestion );
render_title_alternatives( data.suggestion, data.title_candidates || [] );
} else if ( field == 'description' ) {
$description.val( data.suggestion );
} else if ( field == 'category' ) {
@@ -1598,6 +1666,22 @@ $( function()
});
});
this.$content.on( 'click', '.js-title-alts-toggle', function() {
var $list = jc.$content.find( '.js-title-alts-list' );
$list.toggle();
$( this ).html(
$list.is( ':visible' )
? '<i class="fa-solid fa-eye-slash"></i> Ukryj alternatywy'
: '<i class="fa-solid fa-list"></i> Pokaż alternatywy (' + $list.find( '.js-title-alt-apply' ).length + ')'
);
} );
this.$content.on( 'click', '.js-title-alt-apply', function() {
var selectedTitle = $( this ).attr( 'data-title-alt' ) || '';
set_title_value( selectedTitle );
} );
$form.on( 'submit', function( e ) {
e.preventDefault();
jc.$$formSubmit.trigger( 'click' );

View File

@@ -80,6 +80,7 @@
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,
},
stateSave: true,
autoWidth: false,
lengthChange: false,
pageLength: 30,

View File

@@ -292,6 +292,85 @@
</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-diamond"></i></div>
<div>
<h3>Gemini (Google)</h3>
<small>Klucz API i model Gemini do optymalizacji tytułów i opisów produktów przez AI</small>
</div>
</div>
<form method="POST" id="gemini-settings" action="/settings/save_gemini">
<div class="settings-field" style="margin-bottom: 16px;">
<label class="settings-toggle-label">
<?php $gemini_enabled = \services\GoogleAdsApi::get_setting( 'gemini_enabled' ) !== '0'; ?>
<input type="hidden" name="gemini_enabled" value="0" />
<input type="checkbox" name="gemini_enabled" value="1" class="settings-toggle-checkbox" <?= $gemini_enabled ? 'checked' : ''; ?> />
<span class="settings-toggle-switch"></span>
<span class="settings-toggle-text">Włącz Gemini (Google)</span>
</label>
</div>
<div class="settings-fields-grid">
<div class="settings-field">
<label for="gemini_api_key">API Key</label>
<div class="settings-input-wrap">
<input type="password" id="gemini_api_key" name="gemini_api_key" class="form-control" value="<?= htmlspecialchars( \services\GoogleAdsApi::get_setting( 'gemini_api_key' ) ); ?>" placeholder="AIza..." />
<button type="button" class="settings-toggle-pw" onclick="password_toggle( this, 'gemini_api_key' )">
<i class="fa-solid fa-eye"></i>
</button>
</div>
</div>
<div class="settings-field">
<label for="gemini_model">Model</label>
<?php $current_gemini_model = \services\GoogleAdsApi::get_setting( 'gemini_model' ) ?: 'gemini-2.5-flash'; ?>
<select id="gemini_model" name="gemini_model" class="form-control">
<option value="gemini-2.5-pro" <?= $current_gemini_model === 'gemini-2.5-pro' ? 'selected' : ''; ?>>Gemini 2.5 Pro (najlepszy, $1.25/$10 per 1M)</option>
<option value="gemini-2.5-flash" <?= $current_gemini_model === 'gemini-2.5-flash' ? 'selected' : ''; ?>>Gemini 2.5 Flash (szybki, $0.30/$2.50 per 1M)</option>
<option value="gemini-2.5-flash-lite" <?= $current_gemini_model === 'gemini-2.5-flash-lite' ? 'selected' : ''; ?>>Gemini 2.5 Flash Lite (najtańszy)</option>
</select>
</div>
</div>
<button type="button" class="btn btn-success" onclick="$( '#gemini-settings' ).submit();"><i class="fa-solid fa-check mr5"></i>Zapisz ustawienia Gemini</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-wand-magic-sparkles"></i></div>
<div>
<h3>Prompty AI (ogólne)</h3>
<small>Wspólne prompty używane przez wszystkie modele AI (ChatGPT, Claude, Gemini) do generowania tytułów i opisów produktów</small>
</div>
</div>
<form method="POST" id="ai-prompts-settings" action="/settings/save_ai_prompts">
<?php
$ai_title_prompt_saved = trim( (string) \services\GoogleAdsApi::get_setting( 'ai_prompt_title_template' ) );
$ai_desc_prompt_saved = trim( (string) \services\GoogleAdsApi::get_setting( 'ai_prompt_description_template' ) );
$ai_title_prompt_value = $ai_title_prompt_saved !== '' ? $ai_title_prompt_saved : \services\OpenAiApi::get_default_title_prompt_template();
$ai_desc_prompt_value = $ai_desc_prompt_saved !== '' ? $ai_desc_prompt_saved : \services\OpenAiApi::get_default_description_prompt_template();
?>
<div class="settings-field">
<label for="ai_prompt_title_template">Prompt: nazwa produktu</label>
<textarea id="ai_prompt_title_template" name="ai_prompt_title_template" class="form-control" rows="8" style="height:auto;min-height:170px;" placeholder="Domyslny prompt zostanie uzyty, jesli pole jest puste."><?= htmlspecialchars( $ai_title_prompt_value ); ?></textarea>
<small class="text-muted">Dostepne placeholdery: <code>{{context}}</code>, <code>{{keyword_terms}}</code>. Gdy pominiesz placeholder, system dopnie dane automatycznie.</small>
</div>
<div class="settings-field">
<label for="ai_prompt_description_template">Prompt: opis produktu</label>
<textarea id="ai_prompt_description_template" name="ai_prompt_description_template" class="form-control" rows="10" style="height:auto;min-height:210px;" placeholder="Domyslny prompt zostanie uzyty, jesli pole jest puste."><?= htmlspecialchars( $ai_desc_prompt_value ); ?></textarea>
<small class="text-muted">Dostepne placeholdery: <code>{{length_guide}}</code>, <code>{{context}}</code>, <code>{{keyword_terms}}</code>. Gdy pominiesz placeholder, system dopnie dane automatycznie.</small>
</div>
<button type="button" class="btn btn-success" onclick="$( '#ai-prompts-settings' ).submit();"><i class="fa-solid fa-check mr5"></i>Zapisz prompty AI</button>
</form>
</div>
</div>
</div>
<?php endif; ?>
<script type="text/javascript">