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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 )
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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 );
|
||||
|
||||
349
autoload/services/class.GeminiApi.php
Normal file
349
autoload/services/class.GeminiApi.php
Normal 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 );
|
||||
}
|
||||
}
|
||||
@@ -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 );
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -79,6 +79,7 @@ function initLogsTable()
|
||||
}
|
||||
|
||||
logsTable = new DataTable( '#logs-table', {
|
||||
stateSave: true,
|
||||
ajax: {
|
||||
url: '/logs/get_data_table/',
|
||||
data: function( d )
|
||||
|
||||
@@ -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' );
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user