diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 099e738..dfc3df7 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -8,7 +8,8 @@ "WebFetch(domain:help.kliken.com)", "WebFetch(domain:www.storegrowers.com)", "WebFetch(domain:platform.openai.com)", - "WebFetch(domain:openai.com)" + "WebFetch(domain:openai.com)", + "Bash(sass:*)" ] } } diff --git a/.vscode/ftp-kr.sync.cache.json b/.vscode/ftp-kr.sync.cache.json index 1eb8b0a..deb87ce 100644 --- a/.vscode/ftp-kr.sync.cache.json +++ b/.vscode/ftp-kr.sync.cache.json @@ -33,16 +33,22 @@ "lmtime": 1769729268048, "modified": false }, + "class.Clients.php": { + "type": "-", + "size": 1443, + "lmtime": 0, + "modified": false + }, "class.Cron.php": { "type": "-", - "size": 22459, + "size": 27380, "lmtime": 1764273350638, - "modified": false + "modified": true }, "class.Products.php": { "type": "-", - "size": 14277, - "lmtime": 1769726635358, + "size": 17445, + "lmtime": 1771171703249, "modified": false }, "class.Site.php": { @@ -53,8 +59,8 @@ }, "class.Users.php": { "type": "-", - "size": 3091, - "lmtime": 0, + "size": 4159, + "lmtime": 1771170208170, "modified": false } }, @@ -65,6 +71,12 @@ "lmtime": 1769729268049, "modified": false }, + "class.Clients.php": { + "type": "-", + "size": 732, + "lmtime": 0, + "modified": false + }, "class.Cron.php": { "type": "-", "size": 26120, @@ -73,8 +85,8 @@ }, "class.Products.php": { "type": "-", - "size": 6956, - "lmtime": 1769726808348, + "size": 7481, + "lmtime": 1771170224109, "modified": false }, "class.Users.php": { @@ -83,6 +95,28 @@ "lmtime": 0, "modified": false } + }, + "services": { + "class.GoogleAdsApi.php": { + "type": "-", + "size": 8751, + "lmtime": 0, + "modified": false + }, + "class.OpenAiApi.php": { + "type": "-", + "size": 12013, + "lmtime": 1771171891986, + "modified": false + } + } + }, + ".claude": { + "settings.local.json": { + "type": "-", + "size": 359, + "lmtime": 1771170998225, + "modified": false } }, "config.php": { @@ -97,16 +131,17 @@ "lmtime": 0, "modified": false }, + "docs": {}, ".htaccess": { "type": "-", - "size": 612, + "size": 601, "lmtime": 0, - "modified": false + "modified": true }, "index.php": { "type": "-", - "size": 2456, - "lmtime": 0, + "size": 3756, + "lmtime": 1771170199988, "modified": false }, "layout": { @@ -118,20 +153,32 @@ }, "style.css": { "type": "-", - "size": 19795, - "lmtime": 1763678459404, + "size": 33580, + "lmtime": 1771173952595, "modified": false }, "style.css.map": { "type": "-", - "size": 36969, - "lmtime": 1763678459404, + "size": 42515, + "lmtime": 1771169108538, + "modified": false + }, + "style-old.css": { + "type": "-", + "size": 19795, + "lmtime": 0, + "modified": false + }, + "style-old.scss": { + "type": "-", + "size": 25910, + "lmtime": 0, "modified": false }, "style.scss": { "type": "-", - "size": 25910, - "lmtime": 1763678459031, + "size": 36710, + "lmtime": 1771173944772, "modified": false } }, @@ -151,6 +198,26 @@ } } }, + "migrations": { + "001_google_ads_settings.sql": { + "type": "-", + "size": 889, + "lmtime": 0, + "modified": false + }, + "demo_data.sql": { + "type": "-", + "size": 18951, + "lmtime": 0, + "modified": false + }, + "002_products_data_url.sql": { + "type": "-", + "size": 86, + "lmtime": 1771171362268, + "modified": false + } + }, "robots.txt": { "type": "-", "size": 25, @@ -161,8 +228,8 @@ "products": { "main_view.php": { "type": "-", - "size": 19064, - "lmtime": 1770756800564, + "size": 21771, + "lmtime": 1771173883840, "modified": false }, "product_history.php": { @@ -199,6 +266,20 @@ "lmtime": 1769729268050, "modified": false } + }, + "users": { + "login-form.php": { + "type": "-", + "size": 3273, + "lmtime": 0, + "modified": false + }, + "settings.php": { + "type": "-", + "size": 8355, + "lmtime": 1771171025998, + "modified": false + } } }, "tmp": {}, diff --git a/autoload/controls/class.Products.php b/autoload/controls/class.Products.php index cc77640..820f8ee 100644 --- a/autoload/controls/class.Products.php +++ b/autoload/controls/class.Products.php @@ -85,11 +85,33 @@ class Products { $product_id = \S::get( 'product_id' ); $field = \S::get( 'field' ); + $provider = \S::get( 'provider' ) ?: 'openai'; - if ( !\services\OpenAiApi::is_configured() ) + if ( $provider === 'claude' ) { - echo json_encode( [ 'status' => 'error', 'message' => 'Klucz API OpenAI nie jest skonfigurowany. Przejdź do Ustawień.' ] ); - exit; + if ( \services\GoogleAdsApi::get_setting( 'claude_enabled' ) === '0' ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Claude jest wyłączony. Włącz go w Ustawieniach.' ] ); + exit; + } + if ( !\services\ClaudeApi::is_configured() ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Klucz API Claude nie jest skonfigurowany. Przejdź do Ustawień.' ] ); + exit; + } + } + else + { + if ( \services\GoogleAdsApi::get_setting( 'openai_enabled' ) === '0' ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'OpenAI jest wyłączony. Włącz go w Ustawieniach.' ] ); + exit; + } + if ( !\services\OpenAiApi::is_configured() ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Klucz API OpenAI nie jest skonfigurowany. Przejdź do Ustawień.' ] ); + exit; + } } $product = \factory\Products::get_product_full_context( $product_id ); @@ -124,21 +146,25 @@ class Products 'page_content' => $page_content, ]; + $api = $provider === 'claude' ? \services\ClaudeApi::class : \services\OpenAiApi::class; + switch ( $field ) { case 'title': - $result = \services\OpenAiApi::suggest_title( $context ); + $result = $api::suggest_title( $context ); break; case 'description': - $result = \services\OpenAiApi::suggest_description( $context ); + $result = $api::suggest_description( $context ); break; case 'category': - $result = \services\OpenAiApi::suggest_category( $context ); + $result = $api::suggest_category( $context ); break; default: $result = [ 'status' => 'error', 'message' => 'Nieznane pole: ' . $field ]; } + $result['provider'] = $provider; + if ( $product_url && !$page_content ) $result['warning'] = 'Nie udało się pobrać treści ze strony produktu (strona może blokować dostęp). Sugestia oparta tylko na nazwie produktu.'; elseif ( $page_content ) diff --git a/autoload/controls/class.Users.php b/autoload/controls/class.Users.php index dd2ee36..58eb883 100644 --- a/autoload/controls/class.Users.php +++ b/autoload/controls/class.Users.php @@ -101,6 +101,7 @@ class Users public static function settings_save_openai() { + \services\GoogleAdsApi::set_setting( 'openai_enabled', \S::get( 'openai_enabled' ) ? '1' : '0' ); \services\GoogleAdsApi::set_setting( 'openai_api_key', \S::get( 'openai_api_key' ) ); \services\GoogleAdsApi::set_setting( 'openai_model', \S::get( 'openai_model' ) ); @@ -109,6 +110,17 @@ class Users exit; } + public static function settings_save_claude() + { + \services\GoogleAdsApi::set_setting( 'claude_enabled', \S::get( 'claude_enabled' ) ? '1' : '0' ); + \services\GoogleAdsApi::set_setting( 'claude_api_key', \S::get( 'claude_api_key' ) ); + \services\GoogleAdsApi::set_setting( 'claude_model', \S::get( 'claude_model' ) ); + + \S::alert( 'Ustawienia Claude zostały zapisane.' ); + header( 'Location: /settings' ); + exit; + } + public static function login() { if ( $user = \factory\Users::login( diff --git a/autoload/services/class.ClaudeApi.php b/autoload/services/class.ClaudeApi.php new file mode 100644 index 0000000..ff2efd8 --- /dev/null +++ b/autoload/services/class.ClaudeApi.php @@ -0,0 +1,248 @@ + $model, + 'max_tokens' => $max_tokens, + 'system' => $system_prompt, + 'messages' => [ + [ 'role' => 'user', 'content' => $user_prompt ] + ] + ]; + + $ch = curl_init( self::$api_url ); + curl_setopt_array( $ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'x-api-key: ' . $api_key, + 'anthropic-version: 2023-06-01' + ], + 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 Claude (HTTP ' . $http_code . ')' ]; + } + + $data = json_decode( $response, true ); + $content = ''; + if ( !empty( $data['content'] ) ) + { + foreach ( $data['content'] as $block ) + { + if ( $block['type'] === 'text' ) + { + $content .= $block['text']; + } + } + } + $content = trim( $content ); + + 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 public function suggest_title( $context ) + { + $context_text = self::build_context_text( $context ); + + $prompt = '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 +- Tytuł musi brzmieć jak wpis w katalogu produktowym + +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.) + +' . $context_text . ' + +Zwróć TYLKO tytuł, bez cudzysłowów, bez wyjaśnień.'; + + return self::call_api( self::$system_prompt, $prompt ); + } + + static public function suggest_description( $context ) + { + $context_text = self::build_context_text( $context ); + $has_page = !empty( $context['page_content'] ); + + $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. + +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 + +FORMATOWANIE — Google Merchant Center obsługuje podstawowe HTML: +- Używaj
do oddzielania akapitów/sekcji +- Używaj pogrubienia dla kluczowych cech (np. nazwy elementów zestawu, materiał) +- Używaj 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 . ' + +Zwróć TYLKO opis w formacie HTML (używając dozwolonych tagów), bez cudzysłowów, bez wyjaśnień.'; + + $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 ); + } +} diff --git a/index.php b/index.php index 6286a49..002e61b 100644 --- a/index.php +++ b/index.php @@ -45,6 +45,7 @@ $route_aliases = [ 'settings/save' => ['users', 'settings_save'], 'settings/save_google_ads' => ['users', 'settings_save_google_ads'], 'settings/save_openai' => ['users', 'settings_save_openai'], + 'settings/save_claude' => ['users', 'settings_save_claude'], 'products/ai_suggest' => ['products', 'ai_suggest'], 'clients/save' => ['clients', 'save'], ]; diff --git a/layout/style.css b/layout/style.css index c0b2b5e..64838c2 100644 --- a/layout/style.css +++ b/layout/style.css @@ -718,6 +718,44 @@ table { .settings-card .settings-input-wrap .settings-toggle-pw:hover { color: #6690F4; } +.settings-card .settings-toggle-label { + display: inline-flex; + align-items: center; + gap: 10px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + user-select: none; +} +.settings-card .settings-toggle-checkbox { + display: none; +} +.settings-card .settings-toggle-checkbox + .settings-toggle-switch { + position: relative; + width: 44px; + height: 24px; + background: #ccc; + border-radius: 12px; + transition: background 0.2s; + flex-shrink: 0; +} +.settings-card .settings-toggle-checkbox + .settings-toggle-switch::after { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 18px; + height: 18px; + background: #fff; + border-radius: 50%; + transition: transform 0.2s; +} +.settings-card .settings-toggle-checkbox:checked + .settings-toggle-switch { + background: #22C55E; +} +.settings-card .settings-toggle-checkbox:checked + .settings-toggle-switch::after { + transform: translateX(20px); +} .settings-card .settings-fields-grid { display: grid; grid-template-columns: 1fr 1fr; @@ -1380,6 +1418,16 @@ table { opacity: 0.7; cursor: wait; } +.btn-ai-suggest.btn-ai-claude { + border-color: #D97706; + background: linear-gradient(135deg, #FEF3C7, #FDE68A); + color: #92400E; +} +.btn-ai-suggest.btn-ai-claude:hover { + background: linear-gradient(135deg, #D97706, #B45309); + color: #FFF; + border-color: #B45309; +} .form_container { background: #FFFFFF; diff --git a/layout/style.scss b/layout/style.scss index a535e95..daae7ff 100644 --- a/layout/style.scss +++ b/layout/style.scss @@ -882,6 +882,50 @@ table { } } + .settings-toggle-label { + display: inline-flex; + align-items: center; + gap: 10px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + user-select: none; + } + + .settings-toggle-checkbox { + display: none; + + & + .settings-toggle-switch { + position: relative; + width: 44px; + height: 24px; + background: #ccc; + border-radius: 12px; + transition: background 0.2s; + flex-shrink: 0; + + &::after { + content: ''; + position: absolute; + top: 3px; + left: 3px; + width: 18px; + height: 18px; + background: #fff; + border-radius: 50%; + transition: transform 0.2s; + } + } + + &:checked + .settings-toggle-switch { + background: #22C55E; + + &::after { + transform: translateX(20px); + } + } + } + .settings-fields-grid { display: grid; grid-template-columns: 1fr 1fr; @@ -1649,6 +1693,18 @@ table { opacity: 0.7; cursor: wait; } + + &.btn-ai-claude { + border-color: #D97706; + background: linear-gradient(135deg, #FEF3C7, #FDE68A); + color: #92400E; + + &:hover { + background: linear-gradient(135deg, #D97706, #B45309); + color: #FFF; + border-color: #B45309; + } + } } // --- Form container --- diff --git a/templates/products/main_view.php b/templates/products/main_view.php index cc04ae2..48d7ff0 100644 --- a/templates/products/main_view.php +++ b/templates/products/main_view.php @@ -58,7 +58,14 @@ +