Add migrations for Google Ads settings and demo data
- Create migration for global settings table and add google_ads_customer_id and google_ads_start_date columns to clients table. - Add migration to include product_url column in products_data table. - Insert demo data for campaigns, products, and their history for client 'pomysloweprezenty.pl'. - Implement client management interface with modals for adding and editing clients, including Google Ads Customer ID and data retrieval start date.
This commit is contained in:
14
.claude/settings.local.json
Normal file
14
.claude/settings.local.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npx sass:*)",
|
||||||
|
"Bash(npx md-to-pdf:*)",
|
||||||
|
"WebFetch(domain:support.google.com)",
|
||||||
|
"WebFetch(domain:feedops.com)",
|
||||||
|
"WebFetch(domain:help.kliken.com)",
|
||||||
|
"WebFetch(domain:www.storegrowers.com)",
|
||||||
|
"WebFetch(domain:platform.openai.com)",
|
||||||
|
"WebFetch(domain:openai.com)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
16
.htaccess
16
.htaccess
@@ -8,10 +8,14 @@ RewriteRule ^(.*)$ https://%1/$1 [R=301,L]
|
|||||||
RewriteCond %{SERVER_PORT} !=443
|
RewriteCond %{SERVER_PORT} !=443
|
||||||
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=permanent]
|
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=permanent]
|
||||||
|
|
||||||
RewriteCond %{REQUEST_URI} !^(.*)/libraries/(.*) [NC]
|
# Statyczne zasoby - pomijaj
|
||||||
RewriteCond %{REQUEST_URI} !^(.*)/temp/(.*) [NC]
|
RewriteCond %{REQUEST_URI} ^/(libraries|layout|upload|temp)/ [NC]
|
||||||
RewriteCond %{REQUEST_URI} !^(.*)/layout/(.*) [NC]
|
RewriteRule ^ - [L]
|
||||||
RewriteCond %{REQUEST_URI} !^(.*)/upload/(.*) [NC]
|
|
||||||
RewriteRule ^([^/]*)/([^/]*)/(.*)$ index.php?module=$1&action=$2&$3 [L]
|
|
||||||
|
|
||||||
RewriteRule ^logowanie$ index.php?module=users&action=login_form [L]
|
# Istniejące pliki/katalogi - pomijaj
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -d
|
||||||
|
RewriteRule ^ - [L]
|
||||||
|
|
||||||
|
# Wszystko inne → index.php
|
||||||
|
RewriteRule ^(.*)$ index.php [L,QSA]
|
||||||
|
|||||||
4
.vscode/ftp-kr.sync.cache.json
vendored
4
.vscode/ftp-kr.sync.cache.json
vendored
@@ -161,8 +161,8 @@
|
|||||||
"products": {
|
"products": {
|
||||||
"main_view.php": {
|
"main_view.php": {
|
||||||
"type": "-",
|
"type": "-",
|
||||||
"size": 19004,
|
"size": 19064,
|
||||||
"lmtime": 1769727759481,
|
"lmtime": 1770756800564,
|
||||||
"modified": false
|
"modified": false
|
||||||
},
|
},
|
||||||
"product_history.php": {
|
"product_history.php": {
|
||||||
|
|||||||
70
autoload/controls/class.Clients.php
Normal file
70
autoload/controls/class.Clients.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
namespace controls;
|
||||||
|
|
||||||
|
class Clients
|
||||||
|
{
|
||||||
|
static public function main_view()
|
||||||
|
{
|
||||||
|
return \view\Clients::main_view(
|
||||||
|
\factory\Clients::get_all()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static public function save()
|
||||||
|
{
|
||||||
|
$id = \S::get( 'id' );
|
||||||
|
$name = trim( \S::get( 'name' ) );
|
||||||
|
$google_ads_customer_id = trim( \S::get( 'google_ads_customer_id' ) );
|
||||||
|
|
||||||
|
if ( !$name )
|
||||||
|
{
|
||||||
|
\S::alert( 'Nazwa klienta jest wymagana.' );
|
||||||
|
header( 'Location: /clients' );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$google_ads_start_date = trim( \S::get( 'google_ads_start_date' ) );
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'name' => $name,
|
||||||
|
'google_ads_customer_id' => $google_ads_customer_id ?: null,
|
||||||
|
'google_ads_start_date' => $google_ads_start_date ?: null,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ( $id )
|
||||||
|
{
|
||||||
|
\factory\Clients::update( $id, $data );
|
||||||
|
\S::alert( 'Klient został zaktualizowany.' );
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
\factory\Clients::create( $data );
|
||||||
|
\S::alert( 'Klient został dodany.' );
|
||||||
|
}
|
||||||
|
|
||||||
|
header( 'Location: /clients' );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
static public function delete()
|
||||||
|
{
|
||||||
|
$id = \S::get( 'id' );
|
||||||
|
|
||||||
|
if ( $id )
|
||||||
|
{
|
||||||
|
\factory\Clients::delete( $id );
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode( [ 'success' => true ] );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
static public function get()
|
||||||
|
{
|
||||||
|
$id = \S::get( 'id' );
|
||||||
|
$client = \factory\Clients::get( $id );
|
||||||
|
|
||||||
|
echo json_encode( $client ?: [] );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -516,6 +516,160 @@ class Cron
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// KAMPANIE - Google Ads API
|
||||||
|
// ===========================
|
||||||
|
|
||||||
|
static public function cron_campaigns()
|
||||||
|
{
|
||||||
|
global $mdb;
|
||||||
|
|
||||||
|
$api = new \services\GoogleAdsApi();
|
||||||
|
|
||||||
|
if ( !$api -> is_configured() )
|
||||||
|
{
|
||||||
|
echo json_encode( [ 'result' => 'Google Ads API nie jest skonfigurowane. Uzupelnij dane w Ustawieniach.' ] );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pobierz klientów z ustawionym Google Ads Customer ID
|
||||||
|
$clients = $mdb -> select( 'clients', '*', [
|
||||||
|
'google_ads_customer_id[!]' => null
|
||||||
|
] );
|
||||||
|
|
||||||
|
if ( empty( $clients ) )
|
||||||
|
{
|
||||||
|
echo json_encode( [ 'result' => 'Brak klientow z ustawionym Google Ads Customer ID.' ] );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$today = date( 'Y-m-d' );
|
||||||
|
$processed = 0;
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
foreach ( $clients as $client )
|
||||||
|
{
|
||||||
|
$customer_id = $client['google_ads_customer_id'];
|
||||||
|
|
||||||
|
// Pobierz dane 30-dniowe
|
||||||
|
$campaigns_30 = $api -> get_campaigns_30_days( $customer_id );
|
||||||
|
if ( $campaigns_30 === false )
|
||||||
|
{
|
||||||
|
$last_err = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' );
|
||||||
|
$errors[] = 'Blad API dla klienta ' . $client['name'] . ' (ID: ' . $customer_id . '): ' . $last_err;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pobierz dane all-time
|
||||||
|
$campaigns_all_time = $api -> get_campaigns_all_time( $customer_id );
|
||||||
|
$all_time_map = [];
|
||||||
|
if ( is_array( $campaigns_all_time ) )
|
||||||
|
{
|
||||||
|
foreach ( $campaigns_all_time as $cat )
|
||||||
|
{
|
||||||
|
$all_time_map[ $cat['campaign_id'] ] = $cat['roas_all_time'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( $campaigns_30 as $campaign )
|
||||||
|
{
|
||||||
|
// Upsert kampanii
|
||||||
|
if ( !$mdb -> count( 'campaigns', [ 'AND' => [
|
||||||
|
'client_id' => $client['id'],
|
||||||
|
'campaign_id' => $campaign['campaign_id']
|
||||||
|
] ] ) )
|
||||||
|
{
|
||||||
|
$mdb -> insert( 'campaigns', [
|
||||||
|
'client_id' => $client['id'],
|
||||||
|
'campaign_id' => $campaign['campaign_id'],
|
||||||
|
'campaign_name' => $campaign['campaign_name']
|
||||||
|
] );
|
||||||
|
$db_campaign_id = $mdb -> id();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$db_campaign_id = $mdb -> get( 'campaigns', 'id', [ 'AND' => [
|
||||||
|
'client_id' => $client['id'],
|
||||||
|
'campaign_id' => $campaign['campaign_id']
|
||||||
|
] ] );
|
||||||
|
|
||||||
|
// Aktualizuj nazwe kampanii jesli sie zmienila
|
||||||
|
$mdb -> update( 'campaigns', [
|
||||||
|
'campaign_name' => $campaign['campaign_name']
|
||||||
|
], [ 'id' => $db_campaign_id ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Budowanie strategii biddingu
|
||||||
|
$bidding_strategy = self::format_bidding_strategy(
|
||||||
|
$campaign['bidding_strategy'],
|
||||||
|
$campaign['target_roas'] ?? 0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dane historii
|
||||||
|
$history_data = [
|
||||||
|
'roas_30_days' => $campaign['roas_30_days'],
|
||||||
|
'roas_all_time' => $all_time_map[ $campaign['campaign_id'] ] ?? 0,
|
||||||
|
'budget' => $campaign['budget'],
|
||||||
|
'money_spent' => $campaign['money_spent'],
|
||||||
|
'conversion_value' => $campaign['conversion_value'],
|
||||||
|
'bidding_strategy' => $bidding_strategy,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Upsert do campaigns_history
|
||||||
|
if ( $mdb -> count( 'campaigns_history', [ 'AND' => [
|
||||||
|
'campaign_id' => $db_campaign_id,
|
||||||
|
'date_add' => $today
|
||||||
|
] ] ) )
|
||||||
|
{
|
||||||
|
$mdb -> update( 'campaigns_history', $history_data, [ 'AND' => [
|
||||||
|
'campaign_id' => $db_campaign_id,
|
||||||
|
'date_add' => $today
|
||||||
|
] ] );
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$history_data['campaign_id'] = $db_campaign_id;
|
||||||
|
$history_data['date_add'] = $today;
|
||||||
|
$mdb -> insert( 'campaigns_history', $history_data );
|
||||||
|
}
|
||||||
|
|
||||||
|
$processed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode( [
|
||||||
|
'result' => 'Synchronizacja zakonczona. Przetworzono kampanii: ' . $processed . '.',
|
||||||
|
'errors' => $errors
|
||||||
|
] );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
static private function format_bidding_strategy( $strategy_type, $target_roas = 0 )
|
||||||
|
{
|
||||||
|
$map = [
|
||||||
|
'MAXIMIZE_CONVERSIONS' => 'Maksymalizacja liczby konwersji',
|
||||||
|
'MAXIMIZE_CONVERSION_VALUE' => 'Maksymalizacja wartosci konwersji',
|
||||||
|
'TARGET_ROAS' => 'Docelowy ROAS',
|
||||||
|
'TARGET_CPA' => 'Docelowy CPA',
|
||||||
|
'MANUAL_CPC' => 'Reczny CPC',
|
||||||
|
'MANUAL_CPM' => 'Reczny CPM',
|
||||||
|
'TARGET_IMPRESSION_SHARE' => 'Docelowy udzial w wyswietleniach',
|
||||||
|
];
|
||||||
|
|
||||||
|
$label = $map[ $strategy_type ] ?? $strategy_type ?? 'brak';
|
||||||
|
|
||||||
|
if ( $target_roas > 0 )
|
||||||
|
{
|
||||||
|
$label .= ' | docelowy ROAS: ' . round( $target_roas * 100 ) . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $label;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// FRAZY - history 30
|
||||||
|
// ===========================
|
||||||
|
|
||||||
static public function cron_phrase_history_30_save( $phrase_id, $date_from, $date_to )
|
static public function cron_phrase_history_30_save( $phrase_id, $date_from, $date_to )
|
||||||
{
|
{
|
||||||
global $mdb;
|
global $mdb;
|
||||||
|
|||||||
@@ -69,8 +69,82 @@ class Products
|
|||||||
|
|
||||||
$product_title = \factory\Products::get_product_data( $product_id, 'title' );
|
$product_title = \factory\Products::get_product_data( $product_id, 'title' );
|
||||||
$product_description = \factory\Products::get_product_data( $product_id, 'description' );
|
$product_description = \factory\Products::get_product_data( $product_id, 'description' );
|
||||||
|
$google_product_category = \factory\Products::get_product_data( $product_id, 'google_product_category' );
|
||||||
|
$product_url = \factory\Products::get_product_data( $product_id, 'product_url' );
|
||||||
|
|
||||||
echo json_encode( [ 'status' => 'ok', 'product_details' => [ 'title' => $product_title, 'description' => $product_description ] ] );
|
echo json_encode( [ 'status' => 'ok', 'product_details' => [
|
||||||
|
'title' => $product_title,
|
||||||
|
'description' => $product_description,
|
||||||
|
'google_product_category' => $google_product_category,
|
||||||
|
'product_url' => $product_url
|
||||||
|
] ] );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
static public function ai_suggest()
|
||||||
|
{
|
||||||
|
$product_id = \S::get( 'product_id' );
|
||||||
|
$field = \S::get( 'field' );
|
||||||
|
|
||||||
|
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 );
|
||||||
|
if ( !$product )
|
||||||
|
{
|
||||||
|
echo json_encode( [ 'status' => 'error', 'message' => 'Nie znaleziono produktu.' ] );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pobierz treść strony produktu jeśli podano URL
|
||||||
|
$product_url = \S::get( 'product_url' );
|
||||||
|
$page_content = '';
|
||||||
|
if ( $product_url && filter_var( $product_url, FILTER_VALIDATE_URL ) )
|
||||||
|
{
|
||||||
|
$page_content = \services\OpenAiApi::fetch_page_content( $product_url );
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = [
|
||||||
|
'original_name' => $product['name'],
|
||||||
|
'current_title' => \factory\Products::get_product_data( $product_id, 'title' ),
|
||||||
|
'current_description' => \factory\Products::get_product_data( $product_id, 'description' ),
|
||||||
|
'current_category' => \factory\Products::get_product_data( $product_id, 'google_product_category' ),
|
||||||
|
'offer_id' => $product['offer_id'],
|
||||||
|
'impressions_30' => $product['impressions_30'] ?? 0,
|
||||||
|
'clicks_30' => $product['clicks_30'] ?? 0,
|
||||||
|
'ctr' => $product['ctr'] ?? 0,
|
||||||
|
'cost' => $product['cost'] ?? 0,
|
||||||
|
'conversions' => $product['conversions'] ?? 0,
|
||||||
|
'conversions_value' => $product['conversions_value'] ?? 0,
|
||||||
|
'roas' => $product['roas'] ?? 0,
|
||||||
|
'custom_label_4' => \factory\Products::get_product_data( $product_id, 'custom_label_4' ),
|
||||||
|
'page_content' => $page_content,
|
||||||
|
];
|
||||||
|
|
||||||
|
switch ( $field )
|
||||||
|
{
|
||||||
|
case 'title':
|
||||||
|
$result = \services\OpenAiApi::suggest_title( $context );
|
||||||
|
break;
|
||||||
|
case 'description':
|
||||||
|
$result = \services\OpenAiApi::suggest_description( $context );
|
||||||
|
break;
|
||||||
|
case 'category':
|
||||||
|
$result = \services\OpenAiApi::suggest_category( $context );
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$result = [ 'status' => 'error', 'message' => 'Nieznane pole: ' . $field ];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 )
|
||||||
|
$result['page_fetched'] = true;
|
||||||
|
|
||||||
|
echo json_encode( $result );
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +265,7 @@ class Products
|
|||||||
'<input type="text" class="form-control min_roas" product_id="' . $row['product_id'] . '" value="' . $row['min_roas'] . '" style="width: 100px;">',
|
'<input type="text" class="form-control min_roas" product_id="' . $row['product_id'] . '" value="' . $row['min_roas'] . '" style="width: 100px;">',
|
||||||
'',
|
'',
|
||||||
'<input type="text" class="form-control custom_label_4" product_id="' . $row['product_id'] . '" value="' . $custom_label_4 . '" style="' . $custom_label_4_color . '">',
|
'<input type="text" class="form-control custom_label_4" product_id="' . $row['product_id'] . '" value="' . $custom_label_4 . '" style="' . $custom_label_4_color . '">',
|
||||||
'<a href="#" class="text-danger delete-product" product_id="' . $row['product_id'] . '">Usuń</a>'
|
'<button type="button" class="btn btn-danger btn-sm delete-product" product_id="' . $row['product_id'] . '"><i class="fa-solid fa-trash"></i></button>'
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,16 +460,22 @@ class Products
|
|||||||
$custom_title = \S::get( 'custom_title' );
|
$custom_title = \S::get( 'custom_title' );
|
||||||
$custom_description = \S::get( 'custom_description' );
|
$custom_description = \S::get( 'custom_description' );
|
||||||
$google_product_category = \S::get( 'google_product_category' );
|
$google_product_category = \S::get( 'google_product_category' );
|
||||||
|
$product_url = \S::get( 'product_url' );
|
||||||
|
|
||||||
if ( $product_id and $custom_title )
|
if ( $product_id )
|
||||||
|
{
|
||||||
|
if ( $custom_title )
|
||||||
\factory\Products::set_product_data( $product_id, 'title', $custom_title );
|
\factory\Products::set_product_data( $product_id, 'title', $custom_title );
|
||||||
|
|
||||||
if ( $product_id and $custom_description )
|
if ( $custom_description )
|
||||||
\factory\Products::set_product_data( $product_id, 'description', $custom_description );
|
\factory\Products::set_product_data( $product_id, 'description', $custom_description );
|
||||||
|
|
||||||
if ( $product_id and $google_product_category )
|
if ( $google_product_category )
|
||||||
\factory\Products::set_product_data( $product_id, 'google_product_category', $google_product_category );
|
\factory\Products::set_product_data( $product_id, 'google_product_category', $google_product_category );
|
||||||
|
|
||||||
|
\factory\Products::set_product_data( $product_id, 'product_url', $product_url ?: '' );
|
||||||
|
}
|
||||||
|
|
||||||
\factory\Products::add_product_comment( $product_id, 'Zmiana tytułu i opisu produktu.' );
|
\factory\Products::add_product_comment( $product_id, 'Zmiana tytułu i opisu produktu.' );
|
||||||
|
|
||||||
echo json_encode( [ 'status' => 'ok' ] );
|
echo json_encode( [ 'status' => 'ok' ] );
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class Users
|
|||||||
\S::set_session( 'user', $user );
|
\S::set_session( 'user', $user );
|
||||||
\S::alert( 'Ustawienia zostały zapisane.' );
|
\S::alert( 'Ustawienia zostały zapisane.' );
|
||||||
}
|
}
|
||||||
header( 'Location: /users/settings/' );
|
header( 'Location: /settings' );
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +66,8 @@ class Users
|
|||||||
|
|
||||||
if ( !$user )
|
if ( !$user )
|
||||||
{
|
{
|
||||||
return \Tpl::view( 'users/login-form' );
|
header( 'Location: /login' );
|
||||||
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
return \view\Users::settings(
|
return \view\Users::settings(
|
||||||
@@ -74,6 +75,40 @@ class Users
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function settings_save_google_ads()
|
||||||
|
{
|
||||||
|
$fields = [
|
||||||
|
'google_ads_developer_token',
|
||||||
|
'google_ads_client_id',
|
||||||
|
'google_ads_client_secret',
|
||||||
|
'google_ads_refresh_token',
|
||||||
|
'google_ads_manager_account_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ( $fields as $field )
|
||||||
|
{
|
||||||
|
\services\GoogleAdsApi::set_setting( $field, \S::get( $field ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
// wyczyść cached token przy zmianie credentials
|
||||||
|
\services\GoogleAdsApi::set_setting( 'google_ads_access_token', null );
|
||||||
|
\services\GoogleAdsApi::set_setting( 'google_ads_access_token_expires', null );
|
||||||
|
|
||||||
|
\S::alert( 'Ustawienia Google Ads zostały zapisane.' );
|
||||||
|
header( 'Location: /settings' );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function settings_save_openai()
|
||||||
|
{
|
||||||
|
\services\GoogleAdsApi::set_setting( 'openai_api_key', \S::get( 'openai_api_key' ) );
|
||||||
|
\services\GoogleAdsApi::set_setting( 'openai_model', \S::get( 'openai_model' ) );
|
||||||
|
|
||||||
|
\S::alert( 'Ustawienia OpenAI zostały zapisane.' );
|
||||||
|
header( 'Location: /settings' );
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
public static function login()
|
public static function login()
|
||||||
{
|
{
|
||||||
if ( $user = \factory\Users::login(
|
if ( $user = \factory\Users::login(
|
||||||
|
|||||||
36
autoload/factory/class.Clients.php
Normal file
36
autoload/factory/class.Clients.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
namespace factory;
|
||||||
|
|
||||||
|
class Clients
|
||||||
|
{
|
||||||
|
static public function get_all()
|
||||||
|
{
|
||||||
|
global $mdb;
|
||||||
|
return $mdb -> select( 'clients', '*', [ 'ORDER' => [ 'name' => 'ASC' ] ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
static public function get( $id )
|
||||||
|
{
|
||||||
|
global $mdb;
|
||||||
|
return $mdb -> get( 'clients', '*', [ 'id' => $id ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
static public function create( $data )
|
||||||
|
{
|
||||||
|
global $mdb;
|
||||||
|
$mdb -> insert( 'clients', $data );
|
||||||
|
return $mdb -> id();
|
||||||
|
}
|
||||||
|
|
||||||
|
static public function update( $id, $data )
|
||||||
|
{
|
||||||
|
global $mdb;
|
||||||
|
return $mdb -> update( 'clients', $data, [ 'id' => $id ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
static public function delete( $id )
|
||||||
|
{
|
||||||
|
global $mdb;
|
||||||
|
return $mdb -> delete( 'clients', [ 'id' => $id ] );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,6 +108,20 @@ class Products
|
|||||||
return $mdb -> query( 'SELECT COUNT(0) FROM products_temp AS pt INNER JOIN products AS p ON p.id = pt.product_id WHERE client_id = \'' . $client_id . '\'' ) -> fetchColumn();
|
return $mdb -> query( 'SELECT COUNT(0) FROM products_temp AS pt INNER JOIN products AS p ON p.id = pt.product_id WHERE client_id = \'' . $client_id . '\'' ) -> fetchColumn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static public function get_product_full_context( $product_id )
|
||||||
|
{
|
||||||
|
global $mdb;
|
||||||
|
return $mdb -> query(
|
||||||
|
'SELECT p.id, p.offer_id, p.name, p.min_roas,
|
||||||
|
pt.impressions, pt.impressions_30, pt.clicks, pt.clicks_30,
|
||||||
|
pt.ctr, pt.cost, pt.cpc, pt.conversions, pt.conversions_value, pt.roas
|
||||||
|
FROM products AS p
|
||||||
|
LEFT JOIN products_temp AS pt ON pt.product_id = p.id
|
||||||
|
WHERE p.id = :pid',
|
||||||
|
[ ':pid' => $product_id ]
|
||||||
|
) -> fetch( \PDO::FETCH_ASSOC );
|
||||||
|
}
|
||||||
|
|
||||||
static public function get_product_data( $product_id, $field )
|
static public function get_product_data( $product_id, $field )
|
||||||
{
|
{
|
||||||
global $mdb;
|
global $mdb;
|
||||||
|
|||||||
274
autoload/services/class.GoogleAdsApi.php
Normal file
274
autoload/services/class.GoogleAdsApi.php
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
<?php
|
||||||
|
namespace services;
|
||||||
|
|
||||||
|
class GoogleAdsApi
|
||||||
|
{
|
||||||
|
private $developer_token;
|
||||||
|
private $client_id;
|
||||||
|
private $client_secret;
|
||||||
|
private $refresh_token;
|
||||||
|
private $manager_account_id;
|
||||||
|
private $access_token;
|
||||||
|
|
||||||
|
private static $API_VERSION = 'v23';
|
||||||
|
private static $TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||||
|
private static $ADS_BASE_URL = 'https://googleads.googleapis.com';
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this -> developer_token = self::get_setting( 'google_ads_developer_token' );
|
||||||
|
$this -> client_id = self::get_setting( 'google_ads_client_id' );
|
||||||
|
$this -> client_secret = self::get_setting( 'google_ads_client_secret' );
|
||||||
|
$this -> refresh_token = self::get_setting( 'google_ads_refresh_token' );
|
||||||
|
$this -> manager_account_id = self::get_setting( 'google_ads_manager_account_id' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Settings CRUD ---
|
||||||
|
|
||||||
|
public static function get_setting( $key )
|
||||||
|
{
|
||||||
|
global $mdb;
|
||||||
|
return $mdb -> get( 'settings', 'setting_value', [ 'setting_key' => $key ] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function set_setting( $key, $value )
|
||||||
|
{
|
||||||
|
global $mdb;
|
||||||
|
|
||||||
|
if ( $mdb -> count( 'settings', [ 'setting_key' => $key ] ) )
|
||||||
|
{
|
||||||
|
$mdb -> update( 'settings', [ 'setting_value' => $value ], [ 'setting_key' => $key ] );
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$mdb -> insert( 'settings', [ 'setting_key' => $key, 'setting_value' => $value ] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Konfiguracja ---
|
||||||
|
|
||||||
|
public function is_configured()
|
||||||
|
{
|
||||||
|
return !empty( $this -> developer_token )
|
||||||
|
&& !empty( $this -> client_id )
|
||||||
|
&& !empty( $this -> client_secret )
|
||||||
|
&& !empty( $this -> refresh_token );
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OAuth2 ---
|
||||||
|
|
||||||
|
private function get_access_token()
|
||||||
|
{
|
||||||
|
$cached_token = self::get_setting( 'google_ads_access_token' );
|
||||||
|
$cached_expires = (int) self::get_setting( 'google_ads_access_token_expires' );
|
||||||
|
|
||||||
|
if ( $cached_token && $cached_expires > time() )
|
||||||
|
{
|
||||||
|
$this -> access_token = $cached_token;
|
||||||
|
return $this -> access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this -> refresh_access_token();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function refresh_access_token()
|
||||||
|
{
|
||||||
|
$ch = curl_init( self::$TOKEN_URL );
|
||||||
|
curl_setopt_array( $ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => http_build_query( [
|
||||||
|
'client_id' => $this -> client_id,
|
||||||
|
'client_secret' => $this -> client_secret,
|
||||||
|
'refresh_token' => $this -> refresh_token,
|
||||||
|
'grant_type' => 'refresh_token'
|
||||||
|
] ),
|
||||||
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
|
CURLOPT_TIMEOUT => 30,
|
||||||
|
] );
|
||||||
|
|
||||||
|
$response = curl_exec( $ch );
|
||||||
|
$http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
||||||
|
$error = curl_error( $ch );
|
||||||
|
curl_close( $ch );
|
||||||
|
|
||||||
|
if ( $http_code !== 200 || !$response )
|
||||||
|
{
|
||||||
|
self::set_setting( 'google_ads_last_error', 'Token refresh failed: HTTP ' . $http_code . ' | ' . $error . ' | ' . $response );
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode( $response, true );
|
||||||
|
|
||||||
|
if ( !isset( $data['access_token'] ) )
|
||||||
|
{
|
||||||
|
self::set_setting( 'google_ads_last_error', 'Token refresh: brak access_token w odpowiedzi' );
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this -> access_token = $data['access_token'];
|
||||||
|
$expires_at = time() + ( $data['expires_in'] ?? 3600 ) - 60;
|
||||||
|
|
||||||
|
self::set_setting( 'google_ads_access_token', $this -> access_token );
|
||||||
|
self::set_setting( 'google_ads_access_token_expires', $expires_at );
|
||||||
|
self::set_setting( 'google_ads_last_error', null );
|
||||||
|
|
||||||
|
return $this -> access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Google Ads API ---
|
||||||
|
|
||||||
|
public function search_stream( $customer_id, $gaql_query )
|
||||||
|
{
|
||||||
|
$access_token = $this -> get_access_token();
|
||||||
|
if ( !$access_token ) return false;
|
||||||
|
|
||||||
|
$customer_id = str_replace( '-', '', $customer_id );
|
||||||
|
|
||||||
|
$url = self::$ADS_BASE_URL . '/' . self::$API_VERSION
|
||||||
|
. '/customers/' . $customer_id . '/googleAds:searchStream';
|
||||||
|
|
||||||
|
$headers = [
|
||||||
|
'Authorization: Bearer ' . $access_token,
|
||||||
|
'developer-token: ' . $this -> developer_token,
|
||||||
|
'Content-Type: application/json',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ( !empty( $this -> manager_account_id ) )
|
||||||
|
{
|
||||||
|
$headers[] = 'login-customer-id: ' . str_replace( '-', '', $this -> manager_account_id );
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init( $url );
|
||||||
|
curl_setopt_array( $ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_POSTFIELDS => json_encode( [ 'query' => $gaql_query ] ),
|
||||||
|
CURLOPT_SSL_VERIFYPEER => true,
|
||||||
|
CURLOPT_TIMEOUT => 120,
|
||||||
|
] );
|
||||||
|
|
||||||
|
$response = curl_exec( $ch );
|
||||||
|
$http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
||||||
|
$error = curl_error( $ch );
|
||||||
|
curl_close( $ch );
|
||||||
|
|
||||||
|
if ( $http_code !== 200 || !$response )
|
||||||
|
{
|
||||||
|
self::set_setting( 'google_ads_last_error', 'searchStream failed: HTTP ' . $http_code . ' | ' . $error . ' | ' . substr( $response, 0, 500 ) );
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode( $response, true );
|
||||||
|
|
||||||
|
// searchStream zwraca tablicę batch'y, każdy z kluczem 'results'
|
||||||
|
$results = [];
|
||||||
|
if ( is_array( $data ) )
|
||||||
|
{
|
||||||
|
foreach ( $data as $batch )
|
||||||
|
{
|
||||||
|
if ( isset( $batch['results'] ) && is_array( $batch['results'] ) )
|
||||||
|
{
|
||||||
|
$results = array_merge( $results, $batch['results'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::set_setting( 'google_ads_last_error', null );
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Kampanie: dane 30-dniowe ---
|
||||||
|
|
||||||
|
public function get_campaigns_30_days( $customer_id )
|
||||||
|
{
|
||||||
|
$gaql = "SELECT "
|
||||||
|
. "campaign.id, "
|
||||||
|
. "campaign.name, "
|
||||||
|
. "campaign.bidding_strategy_type, "
|
||||||
|
. "campaign.target_roas.target_roas, "
|
||||||
|
. "campaign_budget.amount_micros, "
|
||||||
|
. "metrics.cost_micros, "
|
||||||
|
. "metrics.conversions_value "
|
||||||
|
. "FROM campaign "
|
||||||
|
. "WHERE campaign.status = 'ENABLED' "
|
||||||
|
. "AND segments.date DURING LAST_30_DAYS";
|
||||||
|
|
||||||
|
$results = $this -> search_stream( $customer_id, $gaql );
|
||||||
|
if ( $results === false ) return false;
|
||||||
|
|
||||||
|
// Agregacja po campaign.id (API zwraca wiersz per dzień per kampania)
|
||||||
|
$campaigns = [];
|
||||||
|
foreach ( $results as $row )
|
||||||
|
{
|
||||||
|
$cid = $row['campaign']['id'] ?? null;
|
||||||
|
if ( !$cid ) continue;
|
||||||
|
|
||||||
|
if ( !isset( $campaigns[ $cid ] ) )
|
||||||
|
{
|
||||||
|
$campaigns[ $cid ] = [
|
||||||
|
'campaign_id' => $cid,
|
||||||
|
'campaign_name' => $row['campaign']['name'] ?? '',
|
||||||
|
'bidding_strategy' => $row['campaign']['biddingStrategyType'] ?? 'UNKNOWN',
|
||||||
|
'target_roas' => isset( $row['campaign']['targetRoas']['targetRoas'] )
|
||||||
|
? (float) $row['campaign']['targetRoas']['targetRoas']
|
||||||
|
: 0,
|
||||||
|
'budget' => isset( $row['campaignBudget']['amountMicros'] )
|
||||||
|
? (float) $row['campaignBudget']['amountMicros'] / 1000000
|
||||||
|
: 0,
|
||||||
|
'cost_total' => 0,
|
||||||
|
'conversion_value' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$campaigns[ $cid ]['cost_total'] += (float) ( $row['metrics']['costMicros'] ?? 0 );
|
||||||
|
$campaigns[ $cid ]['conversion_value'] += (float) ( $row['metrics']['conversionsValue'] ?? 0 );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Przeliczenie micros i ROAS
|
||||||
|
foreach ( $campaigns as &$c )
|
||||||
|
{
|
||||||
|
$c['money_spent'] = $c['cost_total'] / 1000000;
|
||||||
|
$c['roas_30_days'] = ( $c['money_spent'] > 0 )
|
||||||
|
? round( ( $c['conversion_value'] / $c['money_spent'] ) * 100, 2 )
|
||||||
|
: 0;
|
||||||
|
unset( $c['cost_total'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values( $campaigns );
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Kampanie: dane all-time ---
|
||||||
|
|
||||||
|
public function get_campaigns_all_time( $customer_id )
|
||||||
|
{
|
||||||
|
$gaql = "SELECT "
|
||||||
|
. "campaign.id, "
|
||||||
|
. "metrics.cost_micros, "
|
||||||
|
. "metrics.conversions_value "
|
||||||
|
. "FROM campaign "
|
||||||
|
. "WHERE campaign.status = 'ENABLED'";
|
||||||
|
|
||||||
|
$results = $this -> search_stream( $customer_id, $gaql );
|
||||||
|
if ( $results === false ) return false;
|
||||||
|
|
||||||
|
$campaigns = [];
|
||||||
|
foreach ( $results as $row )
|
||||||
|
{
|
||||||
|
$cid = $row['campaign']['id'] ?? null;
|
||||||
|
if ( !$cid ) continue;
|
||||||
|
|
||||||
|
$cost = (float) ( $row['metrics']['costMicros'] ?? 0 ) / 1000000;
|
||||||
|
$value = (float) ( $row['metrics']['conversionsValue'] ?? 0 );
|
||||||
|
|
||||||
|
$campaigns[] = [
|
||||||
|
'campaign_id' => $cid,
|
||||||
|
'roas_all_time' => ( $cost > 0 ) ? round( ( $value / $cost ) * 100, 2 ) : 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $campaigns;
|
||||||
|
}
|
||||||
|
}
|
||||||
282
autoload/services/class.OpenAiApi.php
Normal file
282
autoload/services/class.OpenAiApi.php
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
<?php
|
||||||
|
namespace services;
|
||||||
|
class OpenAiApi
|
||||||
|
{
|
||||||
|
static private $api_url = 'https://api.openai.com/v1/chat/completions';
|
||||||
|
|
||||||
|
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( 'openai_api_key' );
|
||||||
|
}
|
||||||
|
|
||||||
|
static public function fetch_page_content( $url )
|
||||||
|
{
|
||||||
|
$ch = curl_init( $url );
|
||||||
|
curl_setopt_array( $ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
'Accept-Language: pl-PL,pl;q=0.9,en;q=0.8',
|
||||||
|
],
|
||||||
|
CURLOPT_SSL_VERIFYPEER => false
|
||||||
|
] );
|
||||||
|
|
||||||
|
$html = curl_exec( $ch );
|
||||||
|
$http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
||||||
|
curl_close( $ch );
|
||||||
|
|
||||||
|
if ( $http_code !== 200 || !$html ) return '';
|
||||||
|
|
||||||
|
// Usuń skrypty, style, komentarze
|
||||||
|
$html = preg_replace( '/<script[^>]*>.*?<\/script>/si', '', $html );
|
||||||
|
$html = preg_replace( '/<style[^>]*>.*?<\/style>/si', '', $html );
|
||||||
|
$html = preg_replace( '/<!--.*?-->/s', '', $html );
|
||||||
|
$html = preg_replace( '/<nav[^>]*>.*?<\/nav>/si', '', $html );
|
||||||
|
$html = preg_replace( '/<footer[^>]*>.*?<\/footer>/si', '', $html );
|
||||||
|
$html = preg_replace( '/<header[^>]*>.*?<\/header>/si', '', $html );
|
||||||
|
|
||||||
|
// Zamień tagi na spacje i wyciągnij tekst
|
||||||
|
$text = strip_tags( $html );
|
||||||
|
$text = html_entity_decode( $text, ENT_QUOTES, 'UTF-8' );
|
||||||
|
$text = preg_replace( '/\s+/', ' ', $text );
|
||||||
|
$text = trim( $text );
|
||||||
|
|
||||||
|
// Ogranicz do ~3000 znaków
|
||||||
|
if ( mb_strlen( $text ) > 3000 )
|
||||||
|
$text = mb_substr( $text, 0, 3000 ) . '...';
|
||||||
|
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
static private function call_api( $system_prompt, $user_prompt, $max_tokens = 500 )
|
||||||
|
{
|
||||||
|
$api_key = GoogleAdsApi::get_setting( 'openai_api_key' );
|
||||||
|
$model = GoogleAdsApi::get_setting( 'openai_model' ) ?: 'gpt-5-mini';
|
||||||
|
|
||||||
|
// GPT-5.x wymaga max_completion_tokens, starsze modele używają max_tokens
|
||||||
|
$tokens_key = ( strpos( $model, 'gpt-5' ) === 0 ) ? 'max_completion_tokens' : 'max_tokens';
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'model' => $model,
|
||||||
|
'messages' => [
|
||||||
|
[ 'role' => 'system', 'content' => $system_prompt ],
|
||||||
|
[ 'role' => 'user', 'content' => $user_prompt ]
|
||||||
|
],
|
||||||
|
'temperature' => 0.7,
|
||||||
|
$tokens_key => $max_tokens
|
||||||
|
];
|
||||||
|
|
||||||
|
$ch = curl_init( self::$api_url );
|
||||||
|
curl_setopt_array( $ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'Authorization: Bearer ' . $api_key
|
||||||
|
],
|
||||||
|
CURLOPT_POSTFIELDS => json_encode( $payload ),
|
||||||
|
CURLOPT_TIMEOUT => 30
|
||||||
|
] );
|
||||||
|
|
||||||
|
$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 OpenAI (HTTP ' . $http_code . ')' ];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode( $response, true );
|
||||||
|
$content = trim( $data['choices'][0]['message']['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 <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 . '
|
||||||
|
|
||||||
|
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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
12
autoload/view/class.Clients.php
Normal file
12
autoload/view/class.Clients.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
namespace view;
|
||||||
|
|
||||||
|
class Clients
|
||||||
|
{
|
||||||
|
static public function main_view( $clients )
|
||||||
|
{
|
||||||
|
return \Tpl::view( 'clients/main_view', [
|
||||||
|
'clients' => $clients,
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,15 +4,7 @@ class Site
|
|||||||
{
|
{
|
||||||
public static function show()
|
public static function show()
|
||||||
{
|
{
|
||||||
global $user;
|
global $user, $current_module;
|
||||||
|
|
||||||
$class = '\controls\\';
|
|
||||||
|
|
||||||
$results = explode( '_', \S::get( 'module' ) );
|
|
||||||
if ( is_array( $results ) ) foreach ( $results as $row )
|
|
||||||
$class .= ucfirst( $row );
|
|
||||||
|
|
||||||
$action = \S::get( 'action' );
|
|
||||||
|
|
||||||
$tpl = new \Tpl;
|
$tpl = new \Tpl;
|
||||||
$tpl -> content = \controls\Site::route();
|
$tpl -> content = \controls\Site::route();
|
||||||
@@ -22,6 +14,7 @@ class Site
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
$tpl -> user = $user;
|
$tpl -> user = $user;
|
||||||
|
$tpl -> current_module = $current_module;
|
||||||
if ( $alert = \S::get_session( 'alert' ) )
|
if ( $alert = \S::get_session( 'alert' ) )
|
||||||
{
|
{
|
||||||
$tpl -> alert = $alert;
|
$tpl -> alert = $alert;
|
||||||
|
|||||||
280
docs/PLAN.md
Normal file
280
docs/PLAN.md
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
# adsPRO - System Zarządzania Reklamami Google ADS & Facebook ADS
|
||||||
|
|
||||||
|
## Opis projektu
|
||||||
|
adsPRO to narzędzie webowe (PHP) do zarządzania i automatyzacji kampanii reklamowych Google ADS (priorytet) oraz Facebook ADS (planowane). System umożliwia monitorowanie kampanii, zarządzanie produktami, analizę wydajności (ROAS, CTR, CPC) oraz automatyczne etykietowanie produktów (bestsellery, zombie itp.).
|
||||||
|
|
||||||
|
**URL:** https://adspro.projectpro.pl
|
||||||
|
**Hosting:** Hostido (shared hosting)
|
||||||
|
|
||||||
|
## Stack technologiczny
|
||||||
|
- **PHP 8.x** - czyste PHP z własną strukturą MVC (bez frameworka)
|
||||||
|
- **MySQL/MariaDB** - baza danych (Medoo ORM)
|
||||||
|
- **Google ADS API** - pobieranie danych kampanii i produktów (CRON)
|
||||||
|
- **Facebook ADS API** - planowane w przyszłości
|
||||||
|
- **CRON** - automatyczna synchronizacja danych
|
||||||
|
- **SCSS** - stylowanie (kompilacja do CSS)
|
||||||
|
- **jQuery 3.6** - interaktywność frontend
|
||||||
|
- **DataTables 2.1.7** - tabele z sortowaniem, filtrowaniem, paginacją
|
||||||
|
- **Highcharts** - wykresy wydajności
|
||||||
|
- **Select2** - zaawansowane selecty
|
||||||
|
- **Font Awesome** - ikony
|
||||||
|
|
||||||
|
## Struktura katalogów (nowa)
|
||||||
|
|
||||||
|
```
|
||||||
|
public_html/
|
||||||
|
├── index.php # Front controller + nowy router
|
||||||
|
├── .htaccess # Rewrite rules
|
||||||
|
├── .env # Konfiguracja (przyszłość - migracja z config.php)
|
||||||
|
├── config.php # Konfiguracja DB (obecna)
|
||||||
|
├── ajax.php # Ajax handler
|
||||||
|
├── api.php # API handler (Google ADS webhook)
|
||||||
|
├── cron.php # CRON handler
|
||||||
|
├── robots.txt
|
||||||
|
├── layout/
|
||||||
|
│ ├── favicon.png
|
||||||
|
│ ├── style.scss # Główne style (SCSS)
|
||||||
|
│ └── style.css # Skompilowane style
|
||||||
|
├── libraries/ # Biblioteki zewnętrzne
|
||||||
|
│ ├── medoo/
|
||||||
|
│ ├── phpmailer/
|
||||||
|
│ ├── select2/
|
||||||
|
│ ├── jquery-confirm/
|
||||||
|
│ ├── functions.js # Globalne funkcje JS
|
||||||
|
│ └── framework/ # Framework UI (skin, pluginy)
|
||||||
|
├── autoload/ # Kod PHP (MVC)
|
||||||
|
│ ├── class.S.php # Helper: sesje, requesty, narzędzia
|
||||||
|
│ ├── class.Tpl.php # Template engine
|
||||||
|
│ ├── class.Cache.php # Cache
|
||||||
|
│ ├── class.DbModel.php # Bazowy model DB
|
||||||
|
│ ├── class.Html.php # HTML helper
|
||||||
|
│ ├── controls/ # Kontrolery
|
||||||
|
│ │ ├── class.Site.php # Router (do przebudowy)
|
||||||
|
│ │ ├── class.Users.php # Logowanie, ustawienia
|
||||||
|
│ │ ├── class.Dashboard.php # NOWY - Dashboard główny
|
||||||
|
│ │ ├── class.Campaigns.php # Kampanie Google ADS
|
||||||
|
│ │ ├── class.Products.php # Produkty
|
||||||
|
│ │ ├── class.Allegro.php # Import Allegro
|
||||||
|
│ │ ├── class.Reports.php # NOWY - Raporty i analityka
|
||||||
|
│ │ ├── class.Api.php # Endpointy API
|
||||||
|
│ │ └── class.Cron.php # CRON joby
|
||||||
|
│ ├── factory/ # Modele danych (DB queries)
|
||||||
|
│ │ ├── class.Users.php
|
||||||
|
│ │ ├── class.Campaigns.php
|
||||||
|
│ │ ├── class.Products.php
|
||||||
|
│ │ └── class.Cron.php
|
||||||
|
│ └── view/ # View helpers
|
||||||
|
│ ├── class.Site.php # Renderer layoutu
|
||||||
|
│ ├── class.Users.php
|
||||||
|
│ └── class.Cron.php
|
||||||
|
├── templates/ # Szablony PHP
|
||||||
|
│ ├── site/
|
||||||
|
│ │ ├── layout-logged.php # Layout z sidebar (PRZEBUDOWA)
|
||||||
|
│ │ └── layout-unlogged.php # Layout logowania (PRZEBUDOWA)
|
||||||
|
│ ├── auth/
|
||||||
|
│ │ └── login.php # NOWY ekran logowania
|
||||||
|
│ ├── dashboard/
|
||||||
|
│ │ └── index.php # NOWY dashboard
|
||||||
|
│ ├── campaigns/
|
||||||
|
│ │ └── main_view.php # Widok kampanii
|
||||||
|
│ ├── products/
|
||||||
|
│ │ ├── main_view.php # Lista produktów
|
||||||
|
│ │ └── product_history.php # Historia produktu
|
||||||
|
│ ├── allegro/
|
||||||
|
│ │ └── main_view.php # Import Allegro
|
||||||
|
│ ├── reports/ # NOWE
|
||||||
|
│ │ └── index.php # Raporty
|
||||||
|
│ ├── users/
|
||||||
|
│ │ ├── login-form.php # Stary login (do usunięcia)
|
||||||
|
│ │ └── settings.php # Ustawienia użytkownika
|
||||||
|
│ └── html/ # Komponenty HTML
|
||||||
|
│ ├── button.php
|
||||||
|
│ ├── input.php
|
||||||
|
│ ├── select.php
|
||||||
|
│ └── ...
|
||||||
|
├── tools/
|
||||||
|
│ └── google-taxonomy.php
|
||||||
|
├── tmp/
|
||||||
|
└── docs/
|
||||||
|
└── PLAN.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nowy system routingu
|
||||||
|
|
||||||
|
### Zasada działania
|
||||||
|
Zamiast obecnego `?module=X&action=Y` → czyste URLe obsługiwane przez `.htaccess` + nowy router w `class.Site.php`.
|
||||||
|
|
||||||
|
### Mapa URL
|
||||||
|
|
||||||
|
| URL | Kontroler | Metoda | Opis |
|
||||||
|
|-----|-----------|--------|------|
|
||||||
|
| `/login` | Users | login_form | Ekran logowania |
|
||||||
|
| `/logout` | Users | logout | Wylogowanie |
|
||||||
|
| `/` | Dashboard | index | Dashboard główny |
|
||||||
|
| `/campaigns` | Campaigns | main_view | Lista kampanii |
|
||||||
|
| `/campaigns/history/{id}` | Campaigns | history | Historia kampanii |
|
||||||
|
| `/products` | Products | main_view | Lista produktów |
|
||||||
|
| `/products/history/{id}` | Products | product_history | Historia produktu |
|
||||||
|
| `/allegro` | Allegro | main_view | Import Allegro |
|
||||||
|
| `/reports` | Reports | index | Raporty |
|
||||||
|
| `/settings` | Users | settings | Ustawienia konta |
|
||||||
|
| `/api/*` | Api | * | Endpointy API |
|
||||||
|
| `/cron/*` | Cron | * | CRON joby |
|
||||||
|
|
||||||
|
### Nowy .htaccess
|
||||||
|
```apache
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteBase /
|
||||||
|
|
||||||
|
# Statyczne zasoby - pomijaj
|
||||||
|
RewriteCond %{REQUEST_URI} ^/(libraries|layout|upload|temp)/ [NC]
|
||||||
|
RewriteRule ^ - [L]
|
||||||
|
|
||||||
|
# Wszystko inne → index.php
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
RewriteRule ^(.*)$ index.php [L,QSA]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nowy router (class.Site.php)
|
||||||
|
```php
|
||||||
|
// Parsowanie URL z $_SERVER['REQUEST_URI']
|
||||||
|
// Mapowanie: /segment1/segment2/segment3 → kontroler/akcja/parametry
|
||||||
|
// Fallback na dashboard dla zalogowanych, login dla niezalogowanych
|
||||||
|
```
|
||||||
|
|
||||||
|
## Główne funkcje
|
||||||
|
|
||||||
|
### 1. Nowy ekran logowania
|
||||||
|
- Nowoczesny design: podzielony ekran (lewa strona - branding/grafika, prawa - formularz)
|
||||||
|
- Logo "adsPRO" z subtitlem
|
||||||
|
- Pola: email + hasło
|
||||||
|
- Checkbox "Zapamiętaj mnie"
|
||||||
|
- Walidacja AJAX
|
||||||
|
- Animacje przejścia
|
||||||
|
- Responsywność (mobile: tylko formularz)
|
||||||
|
|
||||||
|
### 2. Nowy layout z menu bocznym (sidebar)
|
||||||
|
- **Sidebar (lewa strona, 260px):**
|
||||||
|
- Logo "adsPRO" na górze
|
||||||
|
- Menu nawigacyjne z ikonami Font Awesome:
|
||||||
|
- 📊 Dashboard (`/`)
|
||||||
|
- 📢 Kampanie (`/campaigns`)
|
||||||
|
- 📦 Produkty (`/products`)
|
||||||
|
- 📥 Allegro import (`/allegro`)
|
||||||
|
- 📈 Raporty (`/reports`)
|
||||||
|
- ⚙️ Ustawienia (`/settings`)
|
||||||
|
- Aktywny element podświetlony
|
||||||
|
- Możliwość zwijania sidebar (collapsed → same ikony, 60px)
|
||||||
|
- Na dole: info o zalogowanym użytkowniku + przycisk wylogowania
|
||||||
|
- **Top bar (nad contentem):**
|
||||||
|
- Przycisk hamburger (toggle sidebar)
|
||||||
|
- Breadcrumbs (ścieżka nawigacji)
|
||||||
|
- Szybkie akcje / notyfikacje (przyszłość)
|
||||||
|
- **Content area:**
|
||||||
|
- Pełna szerokość minus sidebar
|
||||||
|
- Padding 25px
|
||||||
|
- Tło #F4F6F9 (jaśniejsze od obecnego)
|
||||||
|
|
||||||
|
### 3. Dashboard (NOWY)
|
||||||
|
- Kafelki podsumowujące (karty):
|
||||||
|
- Łączna liczba kampanii
|
||||||
|
- Łączna liczba produktów
|
||||||
|
- Średni ROAS (30 dni)
|
||||||
|
- Łączne wydatki (30 dni)
|
||||||
|
- Wykres trendu ROAS (ostatnie 30 dni)
|
||||||
|
- Lista ostatnio zmodyfikowanych kampanii
|
||||||
|
- Produkty wymagające uwagi (niski ROAS, zombie)
|
||||||
|
|
||||||
|
### 4. Zarządzanie kampaniami Google ADS
|
||||||
|
- Wybór klienta (select)
|
||||||
|
- Lista kampanii z metrykami (DataTables)
|
||||||
|
- Historia kampanii z wykresem Highcharts
|
||||||
|
- Metryki: ROAS, budżet, wydatki, wartość konwersji, strategia bidding
|
||||||
|
- Usuwanie kampanii i wpisów historii
|
||||||
|
- Komentarze do kampanii
|
||||||
|
|
||||||
|
### 5. Zarządzanie produktami
|
||||||
|
- Wybór klienta
|
||||||
|
- Konfiguracja min. ROAS dla bestsellerów
|
||||||
|
- Tabela produktów z metrykami:
|
||||||
|
- Wyświetlenia, kliknięcia, CTR, koszt, CPC
|
||||||
|
- Konwersje, wartość konwersji, ROAS
|
||||||
|
- Custom labels (bestseller/zombie/deleted/pla/paused)
|
||||||
|
- Edycja inline (min_roas, custom_label)
|
||||||
|
- Edycja produktu w modalu (tytuł, opis, kategoria Google)
|
||||||
|
- Historia produktu z wykresem
|
||||||
|
- Bulk delete zaznaczonych produktów
|
||||||
|
|
||||||
|
### 6. Import Allegro
|
||||||
|
- Upload pliku CSV
|
||||||
|
- Automatyczne mapowanie ofert
|
||||||
|
- Raport importu (dodane, zaktualizowane)
|
||||||
|
|
||||||
|
### 7. Raporty (NOWY - przyszłość)
|
||||||
|
- Raport wydajności kampanii
|
||||||
|
- Raport produktów (bestsellery vs zombie)
|
||||||
|
- Eksport do Excel
|
||||||
|
- Porównanie okresów
|
||||||
|
|
||||||
|
### 8. Ustawienia
|
||||||
|
- Dane konta (email)
|
||||||
|
- Zmiana hasła
|
||||||
|
- Konfiguracja Pushover (powiadomienia)
|
||||||
|
- Klucze API (przyszłość: Google ADS, Facebook ADS)
|
||||||
|
|
||||||
|
### 9. CRON - synchronizacja danych
|
||||||
|
- `cron_products` - synchronizacja produktów z Google ADS
|
||||||
|
- `cron_products_history_30` - historia 30-dniowa produktów
|
||||||
|
- `cron_xml` - generowanie XML
|
||||||
|
- `cron_phrases` - synchronizacja fraz
|
||||||
|
- `cron_phrases_history_30` - historia 30-dniowa fraz
|
||||||
|
|
||||||
|
## Plan implementacji
|
||||||
|
|
||||||
|
| Etap | Zakres | Priorytet | Pliki |
|
||||||
|
|------|--------|-----------|-------|
|
||||||
|
| **1. Nowy routing** | Przebudowa routera, nowy .htaccess, parsowanie czystych URL | 🔴 Wysoki | `.htaccess`, `index.php`, `controls/class.Site.php` |
|
||||||
|
| **2. Nowy layout (sidebar)** | Layout z bocznym menu, top bar, responsywność | 🔴 Wysoki | `templates/site/layout-logged.php`, `layout/style.scss` |
|
||||||
|
| **3. Nowy ekran logowania** | Nowoczesny split-screen login, nowy layout-unlogged | 🔴 Wysoki | `templates/site/layout-unlogged.php`, `templates/auth/login.php`, `layout/style.scss` |
|
||||||
|
| **4. Dashboard** | Nowa strona startowa z podsumowaniem | 🟡 Średni | `controls/class.Dashboard.php`, `templates/dashboard/index.php` |
|
||||||
|
| **5. Migracja kampanii** | Dostosowanie widoku kampanii do nowego routingu | 🟡 Średni | `controls/class.Campaigns.php`, `templates/campaigns/*` |
|
||||||
|
| **6. Migracja produktów** | Dostosowanie widoku produktów do nowego routingu | 🟡 Średni | `controls/class.Products.php`, `templates/products/*` |
|
||||||
|
| **7. Migracja Allegro** | Dostosowanie importu Allegro | 🟢 Niski | `controls/class.Allegro.php`, `templates/allegro/*` |
|
||||||
|
| **8. Moduł raportów** | Nowy moduł analityczny | 🟢 Niski | `controls/class.Reports.php`, `templates/reports/*` |
|
||||||
|
| **9. Facebook ADS** | Integracja z Facebook ADS API | 🔵 Przyszłość | Nowe kontrolery, factory, szablony |
|
||||||
|
|
||||||
|
## Kolorystyka i design
|
||||||
|
|
||||||
|
### Paleta kolorów
|
||||||
|
- **Primary (akcent):** `#6690F4` (niebieski - obecny)
|
||||||
|
- **Sidebar tło:** `#1E2A3A` (ciemny granat)
|
||||||
|
- **Sidebar tekst:** `#A8B7C7` (jasny szary)
|
||||||
|
- **Sidebar active:** `#6690F4` (primary)
|
||||||
|
- **Content tło:** `#F4F6F9` (jasnoszary)
|
||||||
|
- **Karty:** `#FFFFFF`
|
||||||
|
- **Tekst:** `#4E5E6A` (obecny)
|
||||||
|
- **Success:** `#57B951`
|
||||||
|
- **Danger:** `#CC0000`
|
||||||
|
- **Warning:** `#FF8C00`
|
||||||
|
|
||||||
|
### Typografia
|
||||||
|
- Font: Open Sans (obecny - zachowany)
|
||||||
|
- Rozmiar bazowy: 14px (sidebar), 15px (content)
|
||||||
|
|
||||||
|
## Bezpieczeństwo
|
||||||
|
- Hasła hashowane MD5 (obecne) → **TODO: migracja na bcrypt**
|
||||||
|
- Sesje PHP + cookie "zapamiętaj mnie"
|
||||||
|
- Prepared statements (Medoo ORM)
|
||||||
|
- htmlspecialchars() w szablonach
|
||||||
|
- **TODO: CSRF tokeny w formularzach**
|
||||||
|
- **TODO: migracja config.php → .env (z .htaccess deny)**
|
||||||
|
|
||||||
|
## Przyszłe rozszerzenia
|
||||||
|
- Facebook ADS API - zarządzanie kampaniami FB
|
||||||
|
- System powiadomień (Pushover + in-app)
|
||||||
|
- Wielojęzyczność (PL/EN)
|
||||||
|
- Role użytkowników (admin, manager, viewer)
|
||||||
|
- Automatyczne reguły (np. "jeśli ROAS < X → zmień label na zombie")
|
||||||
|
- Integracja z Google Merchant Center
|
||||||
|
- API REST do integracji z innymi systemami
|
||||||
153
docs/google_ads_api_design_doc.doc
Normal file
153
docs/google_ads_api_design_doc.doc
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body { font-family: Calibri, sans-serif; font-size: 11pt; color: #333; line-height: 1.6; margin: 40px; }
|
||||||
|
h1 { font-size: 20pt; color: #1a73e8; border-bottom: 2px solid #1a73e8; padding-bottom: 8px; }
|
||||||
|
h2 { font-size: 14pt; color: #1a73e8; margin-top: 24px; }
|
||||||
|
h3 { font-size: 12pt; color: #444; margin-top: 16px; }
|
||||||
|
p { margin: 6px 0; }
|
||||||
|
ul { margin: 6px 0 6px 20px; }
|
||||||
|
li { margin: 4px 0; }
|
||||||
|
strong { color: #222; }
|
||||||
|
code { font-family: Consolas, monospace; font-size: 10pt; background: #f5f5f5; padding: 2px 4px; }
|
||||||
|
pre { font-family: Consolas, monospace; font-size: 9.5pt; background: #f8f8f8; border: 1px solid #ddd; padding: 12px; margin: 10px 0; white-space: pre-wrap; }
|
||||||
|
hr { border: none; border-top: 1px solid #ccc; margin: 16px 0; }
|
||||||
|
.meta { color: #666; font-size: 10pt; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h1>adsPRO — Google Ads API Tool Design Documentation</h1>
|
||||||
|
|
||||||
|
<p class="meta"><strong>Company:</strong> Project-Pro<br>
|
||||||
|
<strong>Tool Name:</strong> adsPRO<br>
|
||||||
|
<strong>Date:</strong> February 2026<br>
|
||||||
|
<strong>Version:</strong> 1.0</p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2>1. Tool Overview</h2>
|
||||||
|
|
||||||
|
<p>adsPRO is an internal advertising management platform built for our agency to centralize and automate the management of Google Ads campaigns across multiple client accounts. The tool provides a unified dashboard for monitoring campaign performance, analyzing key metrics (ROAS, cost, conversion value), and managing campaign settings — eliminating the need to switch between multiple Google Ads accounts manually.</p>
|
||||||
|
|
||||||
|
<h2>2. Purpose and Business Use Case</h2>
|
||||||
|
|
||||||
|
<p>Our agency manages Google Ads campaigns for multiple clients. Currently, campaign data is collected manually or via Google Apps Script, which is fragile and hard to maintain. adsPRO replaces this workflow with a direct, reliable integration with the Google Ads API.</p>
|
||||||
|
|
||||||
|
<p><strong>Key goals:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Automatically retrieve campaign performance data for all managed client accounts</li>
|
||||||
|
<li>Display historical trends (ROAS, budget, spend, conversions) in a centralized dashboard</li>
|
||||||
|
<li>Enable campaign management operations (budget adjustments, bidding strategy changes, campaign status updates) directly from the platform</li>
|
||||||
|
<li>Reduce manual work and improve response time for campaign optimization</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>3. Google Ads API Usage</h2>
|
||||||
|
|
||||||
|
<h3>3.1 Data Retrieval (Read Operations)</h3>
|
||||||
|
|
||||||
|
<p>The tool uses the <strong>GoogleAdsService.SearchStream</strong> endpoint to fetch campaign data using GAQL (Google Ads Query Language).</p>
|
||||||
|
|
||||||
|
<p><strong>Data retrieved:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Campaign name, ID, and status</li>
|
||||||
|
<li>Bidding strategy type and target ROAS</li>
|
||||||
|
<li>Campaign budget (daily)</li>
|
||||||
|
<li>Cost (spend) over the last 30 days</li>
|
||||||
|
<li>Conversion value over the last 30 days</li>
|
||||||
|
<li>All-time ROAS calculation</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p><strong>GAQL queries used:</strong></p>
|
||||||
|
|
||||||
|
<pre>SELECT campaign.id, campaign.name, campaign.bidding_strategy_type,
|
||||||
|
campaign.target_roas.target_roas, campaign_budget.amount_micros,
|
||||||
|
metrics.cost_micros, metrics.conversions_value
|
||||||
|
FROM campaign
|
||||||
|
WHERE campaign.status = 'ENABLED'
|
||||||
|
AND segments.date DURING LAST_30_DAYS</pre>
|
||||||
|
|
||||||
|
<pre>SELECT campaign.id, metrics.cost_micros, metrics.conversions_value
|
||||||
|
FROM campaign
|
||||||
|
WHERE campaign.status = 'ENABLED'</pre>
|
||||||
|
|
||||||
|
<h3>3.2 Campaign Management (Write Operations)</h3>
|
||||||
|
|
||||||
|
<p>The tool will also support campaign management operations through the Google Ads API:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>Budget adjustments</strong> — updating <code>campaign_budget.amount_micros</code> via <code>CampaignBudgetService.MutateCampaignBudgets</code></li>
|
||||||
|
<li><strong>Bidding strategy changes</strong> — modifying bidding strategy type and target ROAS via <code>CampaignService.MutateCampaigns</code></li>
|
||||||
|
<li><strong>Campaign status updates</strong> — enabling/pausing campaigns via <code>CampaignService.MutateCampaigns</code></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>All write operations are initiated manually by authorized agency staff through the adsPRO interface. No automated modifications are made without human approval.</p>
|
||||||
|
|
||||||
|
<h2>4. API Request Frequency</h2>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><strong>Automated data retrieval:</strong> Once per day via server-side CRON job (scheduled at 06:00 CET)</li>
|
||||||
|
<li><strong>Campaign management operations:</strong> On-demand, initiated manually by agency staff (estimated 10–50 requests per day)</li>
|
||||||
|
<li><strong>Number of client accounts:</strong> Currently under 20, expected to grow to ~50</li>
|
||||||
|
<li><strong>Estimated total daily API calls:</strong> Under 200 requests per day</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>5. Authentication and Authorization</h2>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>OAuth 2.0 authentication with offline access (refresh token flow)</li>
|
||||||
|
<li>Credentials stored securely in a server-side database (not exposed to end users)</li>
|
||||||
|
<li>Access token is refreshed automatically when expired</li>
|
||||||
|
<li>Optional Manager Account (MCC) support for centralized access to client accounts</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>6. Architecture</h2>
|
||||||
|
|
||||||
|
<pre>[CRON - daily at 06:00]
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[adsPRO Server (PHP)]
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[Google Ads REST API v18]
|
||||||
|
- SearchStream (read campaign data)
|
||||||
|
- MutateCampaigns (manage campaigns)
|
||||||
|
- MutateCampaignBudgets (adjust budgets)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[MySQL Database]
|
||||||
|
- campaigns table (current state)
|
||||||
|
- campaigns_history table (daily snapshots)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[adsPRO Dashboard]
|
||||||
|
- Campaign performance charts
|
||||||
|
- ROAS tracking over time
|
||||||
|
- Campaign management interface</pre>
|
||||||
|
|
||||||
|
<h2>7. Data Handling and Privacy</h2>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>All data is stored on our own private server (shared hosting with SSL)</li>
|
||||||
|
<li>Data is accessible only to authenticated agency staff (login required)</li>
|
||||||
|
<li>No campaign data is shared with third parties</li>
|
||||||
|
<li>No personally identifiable information (PII) is collected from end users</li>
|
||||||
|
<li>API credentials are stored server-side and never exposed in client-facing code</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>8. Users</h2>
|
||||||
|
|
||||||
|
<p>This is an <strong>internal agency tool</strong>. It is not a publicly available application. Access is restricted to authorized staff members of our agency (currently 3 users). There is no self-registration — accounts are created by the administrator.</p>
|
||||||
|
|
||||||
|
<h2>9. Compliance</h2>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>The tool complies with the Google Ads API Terms of Service</li>
|
||||||
|
<li>The tool complies with the Google API Services User Data Policy</li>
|
||||||
|
<li>No data is sold or shared with third parties</li>
|
||||||
|
<li>The tool does not perform automated campaign modifications without human oversight</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
122
docs/google_ads_api_design_doc.md
Normal file
122
docs/google_ads_api_design_doc.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# adsPRO — Google Ads API Tool Design Documentation
|
||||||
|
|
||||||
|
**Company:** Project-Pro
|
||||||
|
**Tool Name:** adsPRO
|
||||||
|
**Date:** February 2026
|
||||||
|
**Version:** 1.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Tool Overview
|
||||||
|
|
||||||
|
adsPRO is an internal advertising management platform built for our agency to centralize and automate the management of Google Ads campaigns across multiple client accounts. The tool provides a unified dashboard for monitoring campaign performance, analyzing key metrics (ROAS, cost, conversion value), and managing campaign settings — eliminating the need to switch between multiple Google Ads accounts manually.
|
||||||
|
|
||||||
|
## 2. Purpose and Business Use Case
|
||||||
|
|
||||||
|
Our agency manages Google Ads campaigns for multiple clients. Currently, campaign data is collected manually or via Google Apps Script, which is fragile and hard to maintain. adsPRO replaces this workflow with a direct, reliable integration with the Google Ads API.
|
||||||
|
|
||||||
|
**Key goals:**
|
||||||
|
- Automatically retrieve campaign performance data for all managed client accounts
|
||||||
|
- Display historical trends (ROAS, budget, spend, conversions) in a centralized dashboard
|
||||||
|
- Enable campaign management operations (budget adjustments, bidding strategy changes, campaign status updates) directly from the platform
|
||||||
|
- Reduce manual work and improve response time for campaign optimization
|
||||||
|
|
||||||
|
## 3. Google Ads API Usage
|
||||||
|
|
||||||
|
### 3.1 Data Retrieval (Read Operations)
|
||||||
|
|
||||||
|
The tool uses the **GoogleAdsService.SearchStream** endpoint to fetch campaign data using GAQL (Google Ads Query Language).
|
||||||
|
|
||||||
|
**Data retrieved:**
|
||||||
|
- Campaign name, ID, and status
|
||||||
|
- Bidding strategy type and target ROAS
|
||||||
|
- Campaign budget (daily)
|
||||||
|
- Cost (spend) over the last 30 days
|
||||||
|
- Conversion value over the last 30 days
|
||||||
|
- All-time ROAS calculation
|
||||||
|
|
||||||
|
**GAQL queries used:**
|
||||||
|
|
||||||
|
```
|
||||||
|
SELECT campaign.id, campaign.name, campaign.bidding_strategy_type,
|
||||||
|
campaign.target_roas.target_roas, campaign_budget.amount_micros,
|
||||||
|
metrics.cost_micros, metrics.conversions_value
|
||||||
|
FROM campaign
|
||||||
|
WHERE campaign.status = 'ENABLED'
|
||||||
|
AND segments.date DURING LAST_30_DAYS
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
SELECT campaign.id, metrics.cost_micros, metrics.conversions_value
|
||||||
|
FROM campaign
|
||||||
|
WHERE campaign.status = 'ENABLED'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Campaign Management (Write Operations)
|
||||||
|
|
||||||
|
The tool will also support campaign management operations through the Google Ads API:
|
||||||
|
|
||||||
|
- **Budget adjustments** — updating `campaign_budget.amount_micros` via `CampaignBudgetService.MutateCampaignBudgets`
|
||||||
|
- **Bidding strategy changes** — modifying bidding strategy type and target ROAS via `CampaignService.MutateCampaigns`
|
||||||
|
- **Campaign status updates** — enabling/pausing campaigns via `CampaignService.MutateCampaigns`
|
||||||
|
|
||||||
|
All write operations are initiated manually by authorized agency staff through the adsPRO interface. No automated modifications are made without human approval.
|
||||||
|
|
||||||
|
## 4. API Request Frequency
|
||||||
|
|
||||||
|
- **Automated data retrieval:** Once per day via server-side CRON job (scheduled at 06:00 CET)
|
||||||
|
- **Campaign management operations:** On-demand, initiated manually by agency staff (estimated 10–50 requests per day)
|
||||||
|
- **Number of client accounts:** Currently under 20, expected to grow to ~50
|
||||||
|
- **Estimated total daily API calls:** Under 200 requests per day
|
||||||
|
|
||||||
|
## 5. Authentication and Authorization
|
||||||
|
|
||||||
|
- OAuth 2.0 authentication with offline access (refresh token flow)
|
||||||
|
- Credentials stored securely in a server-side database (not exposed to end users)
|
||||||
|
- Access token is refreshed automatically when expired
|
||||||
|
- Optional Manager Account (MCC) support for centralized access to client accounts
|
||||||
|
|
||||||
|
## 6. Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
[CRON - daily at 06:00]
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[adsPRO Server (PHP)]
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[Google Ads REST API v18]
|
||||||
|
- SearchStream (read campaign data)
|
||||||
|
- MutateCampaigns (manage campaigns)
|
||||||
|
- MutateCampaignBudgets (adjust budgets)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[MySQL Database]
|
||||||
|
- campaigns table (current state)
|
||||||
|
- campaigns_history table (daily snapshots)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[adsPRO Dashboard]
|
||||||
|
- Campaign performance charts
|
||||||
|
- ROAS tracking over time
|
||||||
|
- Campaign management interface
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Data Handling and Privacy
|
||||||
|
|
||||||
|
- All data is stored on our own private server (shared hosting with SSL)
|
||||||
|
- Data is accessible only to authenticated agency staff (login required)
|
||||||
|
- No campaign data is shared with third parties
|
||||||
|
- No personally identifiable information (PII) is collected from end users
|
||||||
|
- API credentials are stored server-side and never exposed in client-facing code
|
||||||
|
|
||||||
|
## 8. Users
|
||||||
|
|
||||||
|
This is an **internal agency tool**. It is not a publicly available application. Access is restricted to authorized staff members of our agency (currently 3 users). There is no self-registration — accounts are created by the administrator.
|
||||||
|
|
||||||
|
## 9. Compliance
|
||||||
|
|
||||||
|
- The tool complies with the Google Ads API Terms of Service
|
||||||
|
- The tool complies with the Google API Services User Data Policy
|
||||||
|
- No data is sold or shared with third parties
|
||||||
|
- The tool does not perform automated campaign modifications without human oversight
|
||||||
88
index.php
88
index.php
@@ -30,6 +30,53 @@ $mdb = new medoo([
|
|||||||
'charset' => 'utf8'
|
'charset' => 'utf8'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// --- Nowy router ---
|
||||||
|
$request_uri = $_SERVER['REQUEST_URI'];
|
||||||
|
$uri = parse_url($request_uri, PHP_URL_PATH);
|
||||||
|
$uri = trim($uri, '/');
|
||||||
|
$segments = $uri ? explode('/', $uri, 3) : [];
|
||||||
|
|
||||||
|
// Aliasy czystych URL na moduł/akcję
|
||||||
|
$route_aliases = [
|
||||||
|
'login' => ['users', 'login_form'],
|
||||||
|
'logowanie' => ['users', 'login_form'],
|
||||||
|
'logout' => ['users', 'logout'],
|
||||||
|
'settings' => ['users', 'settings'],
|
||||||
|
'settings/save' => ['users', 'settings_save'],
|
||||||
|
'settings/save_google_ads' => ['users', 'settings_save_google_ads'],
|
||||||
|
'settings/save_openai' => ['users', 'settings_save_openai'],
|
||||||
|
'products/ai_suggest' => ['products', 'ai_suggest'],
|
||||||
|
'clients/save' => ['clients', 'save'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$path = implode('/', $segments);
|
||||||
|
$path_first = $segments[0] ?? '';
|
||||||
|
|
||||||
|
if (isset($route_aliases[$path])) {
|
||||||
|
$_GET['module'] = $route_aliases[$path][0];
|
||||||
|
$_GET['action'] = $route_aliases[$path][1];
|
||||||
|
} elseif (isset($route_aliases[$path_first])) {
|
||||||
|
$_GET['module'] = $route_aliases[$path_first][0];
|
||||||
|
$_GET['action'] = $route_aliases[$path_first][1];
|
||||||
|
} elseif (count($segments) >= 2) {
|
||||||
|
$_GET['module'] = $segments[0];
|
||||||
|
$_GET['action'] = $segments[1];
|
||||||
|
if (isset($segments[2])) {
|
||||||
|
parse_str($segments[2], $extra);
|
||||||
|
$_GET = array_merge($_GET, $extra);
|
||||||
|
}
|
||||||
|
} elseif (count($segments) === 1 && $segments[0] !== '') {
|
||||||
|
$_GET['module'] = $segments[0];
|
||||||
|
$_GET['action'] = 'main_view';
|
||||||
|
} else {
|
||||||
|
$_GET['module'] = 'campaigns';
|
||||||
|
$_GET['action'] = 'main_view';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aktualny moduł do podświetlenia w sidebar
|
||||||
|
$current_module = $_GET['module'] ?? '';
|
||||||
|
|
||||||
|
// --- Autoryzacja ---
|
||||||
$domain = preg_replace( '#^(http(s)?://)?w{3}\.#', '$1', $_SERVER['SERVER_NAME'] );
|
$domain = preg_replace( '#^(http(s)?://)?w{3}\.#', '$1', $_SERVER['SERVER_NAME'] );
|
||||||
$cookie_name = str_replace( '.', '-', $domain );
|
$cookie_name = str_replace( '.', '-', $domain );
|
||||||
|
|
||||||
@@ -46,31 +93,24 @@ if ( isset( $_COOKIE[$cookie_name] ) && !isset( $_SESSION['user'] ) )
|
|||||||
}
|
}
|
||||||
|
|
||||||
$user = \S::get_session('user');
|
$user = \S::get_session('user');
|
||||||
if (
|
|
||||||
!$user
|
// Whitelist - strony dostępne bez logowania
|
||||||
and
|
$public_paths = ['login', 'logowanie', 'users/login', 'users/login_form'];
|
||||||
!(
|
$public_prefixes = ['api/', 'cron/'];
|
||||||
in_array( $_SERVER['REQUEST_URI'], [ '/logowanie', '/users/login/' ] )
|
|
||||||
or
|
$is_public = in_array($path, $public_paths)
|
||||||
strpos( $_SERVER['REQUEST_URI'], '/api/campaigns_data_save/' ) !== false
|
|| in_array($path_first . '/' . ($segments[1] ?? ''), $public_paths);
|
||||||
or
|
|
||||||
strpos( $_SERVER['REQUEST_URI'], '/api/phrases_data_save/' ) !== false
|
foreach ($public_prefixes as $prefix) {
|
||||||
or
|
if (strpos($path, $prefix) === 0) {
|
||||||
strpos( $_SERVER['REQUEST_URI'], '/api/products_data_save/' ) !== false
|
$is_public = true;
|
||||||
or
|
break;
|
||||||
strpos( $_SERVER['REQUEST_URI'], '/cron/cron_products/' ) !== false
|
}
|
||||||
or
|
}
|
||||||
strpos( $_SERVER['REQUEST_URI'], '/cron/cron_products_history_30/' ) !== false
|
|
||||||
or
|
if (!$user && !$is_public)
|
||||||
strpos( $_SERVER['REQUEST_URI'], '/cron/cron_xml/' ) !== false
|
|
||||||
or
|
|
||||||
strpos( $_SERVER['REQUEST_URI'], '/cron/cron_phrases/' ) !== false
|
|
||||||
or
|
|
||||||
strpos( $_SERVER['REQUEST_URI'], '/cron/cron_phrases_history_30/' ) !== false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
header( 'Location: /logowanie' );
|
header( 'Location: /login' );
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
layout/style-old.css
Normal file
1
layout/style-old.css
Normal file
File diff suppressed because one or more lines are too long
1409
layout/style-old.scss
Normal file
1409
layout/style-old.scss
Normal file
File diff suppressed because it is too large
Load Diff
1619
layout/style.css
1619
layout/style.css
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2835
layout/style.scss
2835
layout/style.scss
File diff suppressed because it is too large
Load Diff
18
migrations/001_google_ads_settings.sql
Normal file
18
migrations/001_google_ads_settings.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- Migracja: Tabela settings + kolumna google_ads_customer_id
|
||||||
|
-- Data: 2026-02-15
|
||||||
|
-- Opis: Dodaje globalną tabelę ustawień key-value oraz kolumnę Google Ads Customer ID do tabeli clients
|
||||||
|
|
||||||
|
-- 1. Tabela settings (globalne ustawienia aplikacji)
|
||||||
|
CREATE TABLE IF NOT EXISTS `settings` (
|
||||||
|
`id` INT(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`setting_key` VARCHAR(100) NOT NULL,
|
||||||
|
`setting_value` TEXT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_setting_key` (`setting_key`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||||
|
|
||||||
|
-- 2. Kolumna google_ads_customer_id w tabeli clients
|
||||||
|
ALTER TABLE `clients` ADD COLUMN `google_ads_customer_id` VARCHAR(20) NULL DEFAULT NULL AFTER `name`;
|
||||||
|
|
||||||
|
-- 3. Kolumna google_ads_start_date w tabeli clients (data od kiedy pobierać dane z Google Ads API)
|
||||||
|
ALTER TABLE `clients` ADD COLUMN `google_ads_start_date` DATE NULL DEFAULT NULL AFTER `google_ads_customer_id`;
|
||||||
1
migrations/002_products_data_url.sql
Normal file
1
migrations/002_products_data_url.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `products_data` ADD COLUMN `product_url` VARCHAR(500) NULL DEFAULT NULL;
|
||||||
361
migrations/demo_data.sql
Normal file
361
migrations/demo_data.sql
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- DANE DEMO dla adsPRO
|
||||||
|
-- Klient: pomysloweprezenty.pl (client_id = 2)
|
||||||
|
-- Data generacji: 2026-02-15
|
||||||
|
-- ============================================================
|
||||||
|
-- UWAGA: Uruchom ten skrypt TYLKO na bazie testowej/deweloperskiej!
|
||||||
|
-- Zakłada że klient o id=2 istnieje z google_ads_customer_id = '941-605-1782'
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 1. KAMPANIE
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
INSERT INTO `campaigns` (`client_id`, `campaign_id`, `campaign_name`) VALUES
|
||||||
|
(2, 20845671001, 'PMAX | Prezenty personalizowane'),
|
||||||
|
(2, 20845671002, 'PMAX | Bestsellery'),
|
||||||
|
(2, 20845671003, 'PMAX | Walentynki 2026'),
|
||||||
|
(2, 20845671004, 'Shopping | Wszystkie produkty'),
|
||||||
|
(2, 20845671005, 'Shopping | Prezenty do 50 zł'),
|
||||||
|
(2, 20845671006, 'Shopping | Prezenty premium'),
|
||||||
|
(2, 20845671007, 'PMAX | Nowości'),
|
||||||
|
(2, 20845671008, 'Shopping | Bestsellery high ROAS');
|
||||||
|
|
||||||
|
-- Zapamiętaj ID kampanii (zakładam auto_increment od 1)
|
||||||
|
SET @camp1 = (SELECT id FROM campaigns WHERE campaign_id = 20845671001 AND client_id = 2);
|
||||||
|
SET @camp2 = (SELECT id FROM campaigns WHERE campaign_id = 20845671002 AND client_id = 2);
|
||||||
|
SET @camp3 = (SELECT id FROM campaigns WHERE campaign_id = 20845671003 AND client_id = 2);
|
||||||
|
SET @camp4 = (SELECT id FROM campaigns WHERE campaign_id = 20845671004 AND client_id = 2);
|
||||||
|
SET @camp5 = (SELECT id FROM campaigns WHERE campaign_id = 20845671005 AND client_id = 2);
|
||||||
|
SET @camp6 = (SELECT id FROM campaigns WHERE campaign_id = 20845671006 AND client_id = 2);
|
||||||
|
SET @camp7 = (SELECT id FROM campaigns WHERE campaign_id = 20845671007 AND client_id = 2);
|
||||||
|
SET @camp8 = (SELECT id FROM campaigns WHERE campaign_id = 20845671008 AND client_id = 2);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 2. HISTORIA KAMPANII (30 dni: 2026-01-16 do 2026-02-14)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Generujemy dane dzienne za pomocą procedury
|
||||||
|
DELIMITER //
|
||||||
|
DROP PROCEDURE IF EXISTS generate_campaign_history//
|
||||||
|
CREATE PROCEDURE generate_campaign_history()
|
||||||
|
BEGIN
|
||||||
|
DECLARE i INT DEFAULT 0;
|
||||||
|
DECLARE cur_date DATE;
|
||||||
|
DECLARE day_factor FLOAT;
|
||||||
|
|
||||||
|
WHILE i < 30 DO
|
||||||
|
SET cur_date = DATE_SUB('2026-02-14', INTERVAL i DAY);
|
||||||
|
-- Weekendy mają wyższe wydatki (factor 1.2-1.4), dni powszednie normalne
|
||||||
|
SET day_factor = CASE DAYOFWEEK(cur_date)
|
||||||
|
WHEN 1 THEN 1.3 -- niedziela
|
||||||
|
WHEN 7 THEN 1.4 -- sobota
|
||||||
|
WHEN 6 THEN 1.1 -- piątek
|
||||||
|
ELSE 1.0
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Walentynki boost (7-14 lutego)
|
||||||
|
IF cur_date BETWEEN '2026-02-07' AND '2026-02-14' THEN
|
||||||
|
SET day_factor = day_factor * 1.5;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- PMAX | Prezenty personalizowane (wysoki ROAS, duży budżet)
|
||||||
|
INSERT INTO campaigns_history (campaign_id, date_add, roas_30_days, roas_all_time, budget, money_spent, conversion_value, bidding_strategy) VALUES
|
||||||
|
(@camp1, cur_date,
|
||||||
|
ROUND(450 + RAND() * 150, 2),
|
||||||
|
ROUND(520 + RAND() * 80, 2),
|
||||||
|
150.00,
|
||||||
|
ROUND((130 + RAND() * 40) * day_factor, 2),
|
||||||
|
ROUND((600 + RAND() * 300) * day_factor, 2),
|
||||||
|
'Docelowy ROAS | docelowy ROAS: 400%');
|
||||||
|
|
||||||
|
-- PMAX | Bestsellery (bardzo wysoki ROAS)
|
||||||
|
INSERT INTO campaigns_history (campaign_id, date_add, roas_30_days, roas_all_time, budget, money_spent, conversion_value, bidding_strategy) VALUES
|
||||||
|
(@camp2, cur_date,
|
||||||
|
ROUND(650 + RAND() * 200, 2),
|
||||||
|
ROUND(700 + RAND() * 100, 2),
|
||||||
|
200.00,
|
||||||
|
ROUND((170 + RAND() * 60) * day_factor, 2),
|
||||||
|
ROUND((1200 + RAND() * 500) * day_factor, 2),
|
||||||
|
'Docelowy ROAS | docelowy ROAS: 600%');
|
||||||
|
|
||||||
|
-- PMAX | Walentynki 2026 (sezonowa, wysoki wydatek w lutym)
|
||||||
|
INSERT INTO campaigns_history (campaign_id, date_add, roas_30_days, roas_all_time, budget, money_spent, conversion_value, bidding_strategy) VALUES
|
||||||
|
(@camp3, cur_date,
|
||||||
|
ROUND(350 + RAND() * 200 + IF(cur_date >= '2026-02-01', 100, 0), 2),
|
||||||
|
ROUND(380 + RAND() * 120, 2),
|
||||||
|
ROUND(IF(cur_date >= '2026-02-01', 250, 80), 2),
|
||||||
|
ROUND((IF(cur_date >= '2026-02-01', 200, 60) + RAND() * 50) * day_factor, 2),
|
||||||
|
ROUND((IF(cur_date >= '2026-02-01', 800, 200) + RAND() * 400) * day_factor, 2),
|
||||||
|
'Maksymalizacja wartosci konwersji');
|
||||||
|
|
||||||
|
-- Shopping | Wszystkie produkty (umiarkowany ROAS, duży budżet)
|
||||||
|
INSERT INTO campaigns_history (campaign_id, date_add, roas_30_days, roas_all_time, budget, money_spent, conversion_value, bidding_strategy) VALUES
|
||||||
|
(@camp4, cur_date,
|
||||||
|
ROUND(300 + RAND() * 100, 2),
|
||||||
|
ROUND(350 + RAND() * 60, 2),
|
||||||
|
100.00,
|
||||||
|
ROUND((85 + RAND() * 30) * day_factor, 2),
|
||||||
|
ROUND((280 + RAND() * 120) * day_factor, 2),
|
||||||
|
'Docelowy ROAS | docelowy ROAS: 300%');
|
||||||
|
|
||||||
|
-- Shopping | Prezenty do 50 zł (niski CPC, dobry ROAS)
|
||||||
|
INSERT INTO campaigns_history (campaign_id, date_add, roas_30_days, roas_all_time, budget, money_spent, conversion_value, bidding_strategy) VALUES
|
||||||
|
(@camp5, cur_date,
|
||||||
|
ROUND(380 + RAND() * 120, 2),
|
||||||
|
ROUND(400 + RAND() * 80, 2),
|
||||||
|
50.00,
|
||||||
|
ROUND((40 + RAND() * 15) * day_factor, 2),
|
||||||
|
ROUND((170 + RAND() * 80) * day_factor, 2),
|
||||||
|
'Docelowy ROAS | docelowy ROAS: 350%');
|
||||||
|
|
||||||
|
-- Shopping | Prezenty premium (niski ROAS, wysoki AOV)
|
||||||
|
INSERT INTO campaigns_history (campaign_id, date_add, roas_30_days, roas_all_time, budget, money_spent, conversion_value, bidding_strategy) VALUES
|
||||||
|
(@camp6, cur_date,
|
||||||
|
ROUND(200 + RAND() * 100, 2),
|
||||||
|
ROUND(250 + RAND() * 80, 2),
|
||||||
|
80.00,
|
||||||
|
ROUND((65 + RAND() * 25) * day_factor, 2),
|
||||||
|
ROUND((150 + RAND() * 100) * day_factor, 2),
|
||||||
|
'Maksymalizacja wartosci konwersji');
|
||||||
|
|
||||||
|
-- PMAX | Nowości (nowa kampania, niski ROAS na start)
|
||||||
|
INSERT INTO campaigns_history (campaign_id, date_add, roas_30_days, roas_all_time, budget, money_spent, conversion_value, bidding_strategy) VALUES
|
||||||
|
(@camp7, cur_date,
|
||||||
|
ROUND(180 + RAND() * 100 + i * 3, 2),
|
||||||
|
ROUND(200 + RAND() * 80, 2),
|
||||||
|
60.00,
|
||||||
|
ROUND((50 + RAND() * 20) * day_factor, 2),
|
||||||
|
ROUND((100 + RAND() * 80 + i * 5) * day_factor, 2),
|
||||||
|
'Maksymalizacja liczby konwersji');
|
||||||
|
|
||||||
|
-- Shopping | Bestsellery high ROAS (najlepsza kampania)
|
||||||
|
INSERT INTO campaigns_history (campaign_id, date_add, roas_30_days, roas_all_time, budget, money_spent, conversion_value, bidding_strategy) VALUES
|
||||||
|
(@camp8, cur_date,
|
||||||
|
ROUND(800 + RAND() * 300, 2),
|
||||||
|
ROUND(850 + RAND() * 150, 2),
|
||||||
|
120.00,
|
||||||
|
ROUND((100 + RAND() * 35) * day_factor, 2),
|
||||||
|
ROUND((900 + RAND() * 400) * day_factor, 2),
|
||||||
|
'Docelowy ROAS | docelowy ROAS: 700%');
|
||||||
|
|
||||||
|
SET i = i + 1;
|
||||||
|
END WHILE;
|
||||||
|
END//
|
||||||
|
DELIMITER ;
|
||||||
|
|
||||||
|
CALL generate_campaign_history();
|
||||||
|
DROP PROCEDURE IF EXISTS generate_campaign_history;
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 3. PRODUKTY (25 produktów - realistyczne nazwy sklepu z prezentami)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
INSERT INTO `products` (`client_id`, `offer_id`, `name`) VALUES
|
||||||
|
(2, 'shopify_PL_8901001', 'Kubek personalizowany ze zdjęciem - Biały 330ml'),
|
||||||
|
(2, 'shopify_PL_8901002', 'Poduszka z własnym nadrukiem 40x40cm'),
|
||||||
|
(2, 'shopify_PL_8901003', 'Brelok LED z grawerem - Serce'),
|
||||||
|
(2, 'shopify_PL_8901004', 'Ramka na zdjęcie z dedykacją - Drewniana A4'),
|
||||||
|
(2, 'shopify_PL_8901005', 'Puzzle ze zdjęciem 500 elementów'),
|
||||||
|
(2, 'shopify_PL_8901006', 'Koszulka z nadrukiem - Dla Najlepszego Taty'),
|
||||||
|
(2, 'shopify_PL_8901007', 'Skarpetki personalizowane - Zestaw 3 par'),
|
||||||
|
(2, 'shopify_PL_8901008', 'Kubek magiczny zmieniający kolor ze zdjęciem'),
|
||||||
|
(2, 'shopify_PL_8901009', 'Plakat personalizowany - Mapa Gwiazd A3'),
|
||||||
|
(2, 'shopify_PL_8901010', 'Biżuteria - Naszyjnik z grawerem Serce'),
|
||||||
|
(2, 'shopify_PL_8901011', 'Etui na telefon z własnym zdjęciem'),
|
||||||
|
(2, 'shopify_PL_8901012', 'Torba bawełniana z nadrukiem'),
|
||||||
|
(2, 'shopify_PL_8901013', 'Kalendarz ze zdjęciami 2026 - Ścienny A3'),
|
||||||
|
(2, 'shopify_PL_8901014', 'Podkładka pod mysz z własnym zdjęciem'),
|
||||||
|
(2, 'shopify_PL_8901015', 'Zestaw upominkowy - Kubek + Podkładka + Brelok'),
|
||||||
|
(2, 'shopify_PL_8901016', 'Obraz na płótnie Canvas 60x40cm ze zdjęciem'),
|
||||||
|
(2, 'shopify_PL_8901017', 'Otwieracz do butelek z grawerem'),
|
||||||
|
(2, 'shopify_PL_8901018', 'Fotokolaż na płótnie 50x70cm'),
|
||||||
|
(2, 'shopify_PL_8901019', 'Koc z nadrukiem polarowy 150x200cm'),
|
||||||
|
(2, 'shopify_PL_8901020', 'Piersiówka z grawerem 200ml'),
|
||||||
|
(2, 'shopify_PL_8901021', 'Zegar ścienny ze zdjęciem - Okrągły 30cm'),
|
||||||
|
(2, 'shopify_PL_8901022', 'Ręcznik z haftem imienia 70x140cm'),
|
||||||
|
(2, 'shopify_PL_8901023', 'Walentynkowy zestaw - Kubek + Czekolada + Kartka'),
|
||||||
|
(2, 'shopify_PL_8901024', 'Magnes na lodówkę z własnym zdjęciem'),
|
||||||
|
(2, 'shopify_PL_8901025', 'Szkatułka drewniana z grawerem');
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 4. HISTORIA PRODUKTÓW (30 dni dziennych danych)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
DELIMITER //
|
||||||
|
DROP PROCEDURE IF EXISTS generate_product_history//
|
||||||
|
CREATE PROCEDURE generate_product_history()
|
||||||
|
BEGIN
|
||||||
|
DECLARE i INT DEFAULT 0;
|
||||||
|
DECLARE j INT DEFAULT 1;
|
||||||
|
DECLARE cur_date DATE;
|
||||||
|
DECLARE prod_id INT;
|
||||||
|
DECLARE base_imp INT;
|
||||||
|
DECLARE base_clicks INT;
|
||||||
|
DECLARE base_cost DECIMAL(10,2);
|
||||||
|
DECLARE base_conv INT;
|
||||||
|
DECLARE base_conv_val DECIMAL(10,2);
|
||||||
|
DECLARE day_imp INT;
|
||||||
|
DECLARE day_clicks INT;
|
||||||
|
DECLARE day_cost DECIMAL(10,2);
|
||||||
|
DECLARE day_conv INT;
|
||||||
|
DECLARE day_conv_val DECIMAL(10,2);
|
||||||
|
DECLARE day_ctr DECIMAL(6,4);
|
||||||
|
DECLARE day_factor FLOAT;
|
||||||
|
DECLARE product_count INT;
|
||||||
|
|
||||||
|
SET product_count = 25;
|
||||||
|
|
||||||
|
-- Dla każdego produktu
|
||||||
|
SET j = 1;
|
||||||
|
WHILE j <= product_count DO
|
||||||
|
-- Pobierz ID produktu
|
||||||
|
SET prod_id = (SELECT id FROM products WHERE client_id = 2 AND offer_id = CONCAT('shopify_PL_890100', j) LIMIT 1);
|
||||||
|
IF prod_id IS NULL AND j >= 10 THEN
|
||||||
|
SET prod_id = (SELECT id FROM products WHERE client_id = 2 AND offer_id = CONCAT('shopify_PL_89010', j) LIMIT 1);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Bazowe metryki różne per produkt (symulacja różnej popularności)
|
||||||
|
CASE j
|
||||||
|
WHEN 1 THEN SET base_imp = 850, base_clicks = 45, base_cost = 12.50, base_conv = 3, base_conv_val = 89.70; -- Kubek - bestseller
|
||||||
|
WHEN 2 THEN SET base_imp = 620, base_clicks = 32, base_cost = 9.80, base_conv = 2, base_conv_val = 79.80; -- Poduszka
|
||||||
|
WHEN 3 THEN SET base_imp = 1200, base_clicks = 68, base_cost = 15.20, base_conv = 5, base_conv_val = 74.75; -- Brelok LED - top impressions
|
||||||
|
WHEN 4 THEN SET base_imp = 380, base_clicks = 18, base_cost = 7.20, base_conv = 1, base_conv_val = 59.90; -- Ramka
|
||||||
|
WHEN 5 THEN SET base_imp = 520, base_clicks = 28, base_cost = 11.00, base_conv = 2, base_conv_val = 99.80; -- Puzzle
|
||||||
|
WHEN 6 THEN SET base_imp = 780, base_clicks = 42, base_cost = 13.50, base_conv = 3, base_conv_val = 89.70; -- Koszulka
|
||||||
|
WHEN 7 THEN SET base_imp = 450, base_clicks = 22, base_cost = 6.80, base_conv = 2, base_conv_val = 49.90; -- Skarpetki
|
||||||
|
WHEN 8 THEN SET base_imp = 680, base_clicks = 38, base_cost = 10.50, base_conv = 2, base_conv_val = 69.90; -- Kubek magiczny
|
||||||
|
WHEN 9 THEN SET base_imp = 920, base_clicks = 55, base_cost = 18.00, base_conv = 4, base_conv_val = 159.60; -- Plakat mapa gwiazd - wysoki AOV
|
||||||
|
WHEN 10 THEN SET base_imp = 1100, base_clicks = 72, base_cost = 22.00, base_conv = 5, base_conv_val = 349.50; -- Naszyjnik - top conversion value
|
||||||
|
WHEN 11 THEN SET base_imp = 550, base_clicks = 30, base_cost = 8.50, base_conv = 1, base_conv_val = 39.90; -- Etui
|
||||||
|
WHEN 12 THEN SET base_imp = 320, base_clicks = 14, base_cost = 4.20, base_conv = 1, base_conv_val = 29.90; -- Torba
|
||||||
|
WHEN 13 THEN SET base_imp = 280, base_clicks = 12, base_cost = 5.00, base_conv = 0, base_conv_val = 0.00; -- Kalendarz - słaby (po sezonie)
|
||||||
|
WHEN 14 THEN SET base_imp = 180, base_clicks = 8, base_cost = 2.50, base_conv = 0, base_conv_val = 0.00; -- Podkładka - zombie
|
||||||
|
WHEN 15 THEN SET base_imp = 740, base_clicks = 40, base_cost = 14.00, base_conv = 3, base_conv_val = 179.70; -- Zestaw upominkowy
|
||||||
|
WHEN 16 THEN SET base_imp = 480, base_clicks = 25, base_cost = 16.00, base_conv = 1, base_conv_val = 149.00; -- Canvas
|
||||||
|
WHEN 17 THEN SET base_imp = 350, base_clicks = 18, base_cost = 5.50, base_conv = 1, base_conv_val = 34.90; -- Otwieracz
|
||||||
|
WHEN 18 THEN SET base_imp = 410, base_clicks = 22, base_cost = 14.00, base_conv = 1, base_conv_val = 189.00; -- Fotokolaż
|
||||||
|
WHEN 19 THEN SET base_imp = 290, base_clicks = 15, base_cost = 9.00, base_conv = 1, base_conv_val = 119.00; -- Koc
|
||||||
|
WHEN 20 THEN SET base_imp = 420, base_clicks = 24, base_cost = 7.00, base_conv = 1, base_conv_val = 69.90; -- Piersiówka
|
||||||
|
WHEN 21 THEN SET base_imp = 260, base_clicks = 11, base_cost = 4.50, base_conv = 0, base_conv_val = 0.00; -- Zegar - słaby
|
||||||
|
WHEN 22 THEN SET base_imp = 340, base_clicks = 16, base_cost = 6.00, base_conv = 1, base_conv_val = 59.90; -- Ręcznik
|
||||||
|
WHEN 23 THEN SET base_imp = 1500, base_clicks = 95, base_cost = 28.00, base_conv = 8, base_conv_val = 319.20; -- Walentynkowy zestaw - HIT
|
||||||
|
WHEN 24 THEN SET base_imp = 150, base_clicks = 6, base_cost = 1.80, base_conv = 0, base_conv_val = 0.00; -- Magnes - zombie
|
||||||
|
WHEN 25 THEN SET base_imp = 380, base_clicks = 20, base_cost = 8.00, base_conv = 1, base_conv_val = 89.90; -- Szkatułka
|
||||||
|
ELSE SET base_imp = 300, base_clicks = 15, base_cost = 5.00, base_conv = 1, base_conv_val = 50.00;
|
||||||
|
END CASE;
|
||||||
|
|
||||||
|
-- 30 dni historii
|
||||||
|
SET i = 0;
|
||||||
|
WHILE i < 30 DO
|
||||||
|
SET cur_date = DATE_SUB('2026-02-14', INTERVAL i DAY);
|
||||||
|
|
||||||
|
SET day_factor = CASE DAYOFWEEK(cur_date)
|
||||||
|
WHEN 1 THEN 1.25
|
||||||
|
WHEN 7 THEN 1.35
|
||||||
|
WHEN 6 THEN 1.1
|
||||||
|
ELSE 1.0
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Walentynki boost (szczególnie produkty walentynkowe: j=23, j=10, j=9)
|
||||||
|
IF cur_date BETWEEN '2026-02-07' AND '2026-02-14' THEN
|
||||||
|
IF j IN (23, 10, 9, 3, 1) THEN
|
||||||
|
SET day_factor = day_factor * 2.0;
|
||||||
|
ELSE
|
||||||
|
SET day_factor = day_factor * 1.3;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SET day_imp = ROUND(base_imp * day_factor * (0.8 + RAND() * 0.4));
|
||||||
|
SET day_clicks = ROUND(base_clicks * day_factor * (0.7 + RAND() * 0.6));
|
||||||
|
SET day_cost = ROUND(base_cost * day_factor * (0.8 + RAND() * 0.4), 2);
|
||||||
|
-- Konwersje: losowe z prawdopodobieństwem bazowym
|
||||||
|
SET day_conv = FLOOR(base_conv * day_factor * (0.3 + RAND() * 1.4));
|
||||||
|
SET day_conv_val = ROUND(IF(day_conv > 0, day_conv * (base_conv_val / GREATEST(base_conv, 1)) * (0.9 + RAND() * 0.2), 0), 2);
|
||||||
|
SET day_ctr = IF(day_imp > 0, ROUND(day_clicks / day_imp, 4) * 100, 0);
|
||||||
|
|
||||||
|
IF prod_id IS NOT NULL THEN
|
||||||
|
INSERT INTO products_history (product_id, date_add, impressions, clicks, ctr, cost, conversions, conversions_value, updated) VALUES
|
||||||
|
(prod_id, cur_date, day_imp, day_clicks, day_ctr, day_cost, day_conv, day_conv_val, 0);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SET i = i + 1;
|
||||||
|
END WHILE;
|
||||||
|
|
||||||
|
SET j = j + 1;
|
||||||
|
END WHILE;
|
||||||
|
END//
|
||||||
|
DELIMITER ;
|
||||||
|
|
||||||
|
CALL generate_product_history();
|
||||||
|
DROP PROCEDURE IF EXISTS generate_product_history;
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 5. PRODUCTS_TEMP (zagregowane dane - jak po cron_products)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
INSERT INTO products_temp (product_id, name, impressions, impressions_30, clicks, clicks_30, ctr, cost, conversions, conversions_value, cpc, roas)
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.name,
|
||||||
|
COALESCE(SUM(ph.impressions), 0),
|
||||||
|
COALESCE(SUM(ph.impressions), 0),
|
||||||
|
COALESCE(SUM(ph.clicks), 0),
|
||||||
|
COALESCE(SUM(ph.clicks), 0),
|
||||||
|
CASE WHEN SUM(ph.impressions) > 0 THEN ROUND(SUM(ph.clicks) / SUM(ph.impressions) * 100, 2) ELSE 0 END,
|
||||||
|
COALESCE(SUM(ph.cost), 0),
|
||||||
|
COALESCE(SUM(ph.conversions), 0),
|
||||||
|
COALESCE(SUM(ph.conversions_value), 0),
|
||||||
|
CASE WHEN SUM(ph.clicks) > 0 THEN ROUND(SUM(ph.cost) / SUM(ph.clicks), 6) ELSE 0 END,
|
||||||
|
CASE WHEN SUM(ph.conversions) > 0 AND SUM(ph.cost) > 0 THEN ROUND(SUM(ph.conversions_value) / SUM(ph.cost) * 100, 2) ELSE 0 END
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN products_history ph ON p.id = ph.product_id
|
||||||
|
WHERE p.client_id = 2
|
||||||
|
GROUP BY p.id, p.name;
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 6. PRODUCTS_DATA (custom labels)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Bestsellery (wysoki ROAS + dużo konwersji)
|
||||||
|
INSERT INTO products_data (product_id, custom_label_4) VALUES
|
||||||
|
((SELECT id FROM products WHERE offer_id = 'shopify_PL_8901001' AND client_id = 2), 'bestseller'),
|
||||||
|
((SELECT id FROM products WHERE offer_id = 'shopify_PL_8901003' AND client_id = 2), 'bestseller'),
|
||||||
|
((SELECT id FROM products WHERE offer_id = 'shopify_PL_8901010' AND client_id = 2), 'bestseller'),
|
||||||
|
((SELECT id FROM products WHERE offer_id = 'shopify_PL_8901023' AND client_id = 2), 'bestseller');
|
||||||
|
|
||||||
|
-- Produkty PLA (w kampaniach Shopping)
|
||||||
|
INSERT INTO products_data (product_id, custom_label_4) VALUES
|
||||||
|
((SELECT id FROM products WHERE offer_id = 'shopify_PL_8901005' AND client_id = 2), 'pla'),
|
||||||
|
((SELECT id FROM products WHERE offer_id = 'shopify_PL_8901006' AND client_id = 2), 'pla'),
|
||||||
|
((SELECT id FROM products WHERE offer_id = 'shopify_PL_8901015' AND client_id = 2), 'pla');
|
||||||
|
|
||||||
|
-- Zombie (bardzo niskie wyświetlenia)
|
||||||
|
INSERT INTO products_data (product_id, custom_label_4) VALUES
|
||||||
|
((SELECT id FROM products WHERE offer_id = 'shopify_PL_8901014' AND client_id = 2), 'zombie'),
|
||||||
|
((SELECT id FROM products WHERE offer_id = 'shopify_PL_8901024' AND client_id = 2), 'zombie');
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 7. MIN ROAS per klient (bestseller threshold)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Ustaw min ROAS dla wybranych produktów
|
||||||
|
UPDATE products SET min_roas = 500 WHERE offer_id = 'shopify_PL_8901001' AND client_id = 2;
|
||||||
|
UPDATE products SET min_roas = 400 WHERE offer_id = 'shopify_PL_8901003' AND client_id = 2;
|
||||||
|
UPDATE products SET min_roas = 600 WHERE offer_id = 'shopify_PL_8901010' AND client_id = 2;
|
||||||
|
UPDATE products SET min_roas = 350 WHERE offer_id = 'shopify_PL_8901023' AND client_id = 2;
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- GOTOWE!
|
||||||
|
-- Po wykonaniu tego skryptu:
|
||||||
|
-- 1. Wejdź na /campaigns → wybierz klienta pomysloweprezenty.pl
|
||||||
|
-- → zobaczysz 8 kampanii z 30-dniową historią
|
||||||
|
-- 2. Wejdź na /products → wybierz klienta
|
||||||
|
-- → zobaczysz 25 produktów z metrykami
|
||||||
|
-- 3. Kliknij dowolny produkt → zobaczysz historię i wykres
|
||||||
|
-- ============================================================
|
||||||
@@ -1,219 +1,180 @@
|
|||||||
<div class="admin-form theme-primary">
|
<div class="campaigns-page">
|
||||||
<div class="panel heading-border panel-primary">
|
<div class="campaigns-header">
|
||||||
<div class="panel-body">
|
<h2><i class="fa-solid fa-chart-line"></i> Kampanie</h2>
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="field select">
|
|
||||||
<select id="client_id" name="client_id">
|
|
||||||
<option value="">--- wybierz klienta ---</option>
|
|
||||||
<? foreach ( $this -> clients as $client ):?>
|
|
||||||
<option value="<?= $client['id'];?>"><?= $client['name'];?></option>
|
|
||||||
<? endforeach;?>
|
|
||||||
</select>
|
|
||||||
<i class="arrow double"></i>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="row">
|
<!-- Filtry -->
|
||||||
<div class="col-md-10">
|
<div class="campaigns-filters">
|
||||||
<label class="field select">
|
<div class="filter-group">
|
||||||
<select id="campaign_id" name="campaign_id">
|
<label for="client_id"><i class="fa-solid fa-building"></i> Klient</label>
|
||||||
<option value="">--- wybierz kampanię ---</option>
|
<select id="client_id" name="client_id" class="form-control">
|
||||||
|
<option value="">— wybierz klienta —</option>
|
||||||
|
<?php foreach ( $this -> clients as $client ): ?>
|
||||||
|
<option value="<?= $client['id']; ?>"><?= htmlspecialchars( $client['name'] ); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
<i class="arrow double"></i>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="filter-group">
|
||||||
<button type="button" id="delete_campaign" class="btn btn-danger btn-block" title="Usuń kampanię">
|
<label for="campaign_id"><i class="fa-solid fa-bullhorn"></i> Kampania</label>
|
||||||
<i class="fa fa-trash"></i>
|
<div class="filter-with-action">
|
||||||
|
<select id="campaign_id" name="campaign_id" class="form-control">
|
||||||
|
<option value="">— wybierz kampanię —</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" id="delete_campaign" class="btn-icon btn-icon-delete" title="Usuń kampanię">
|
||||||
|
<i class="fa-solid fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-form theme-primary">
|
<!-- Wykres -->
|
||||||
<div class="panel heading-border panel-primary">
|
<div class="campaigns-chart-wrap">
|
||||||
<div class="panel-body">
|
|
||||||
<figure class="highcharts-figure">
|
|
||||||
<div id="container"></div>
|
<div id="container"></div>
|
||||||
</figure>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-form theme-primary">
|
<!-- Tabela historii -->
|
||||||
<div class="panel heading-border panel-primary">
|
<div class="campaigns-table-wrap">
|
||||||
<div class="panel-body">
|
|
||||||
<table class="table" id="products">
|
<table class="table" id="products">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Data</th>
|
<th>Data</th>
|
||||||
<th scope="col">ROAS (30 dni)</th>
|
<th>ROAS (30 dni)</th>
|
||||||
<th scope="col">ROAS (all time)</th>
|
<th>ROAS (all time)</th>
|
||||||
<th scope="col">Wartość konwersji (30 dni)</th>
|
<th>Wartość konwersji (30 dni)</th>
|
||||||
<th scope="col">Wydatki (30 dni)</th>
|
<th>Wydatki (30 dni)</th>
|
||||||
<th scope="col">Komentarz</th>
|
<th>Komentarz</th>
|
||||||
<th scope="col">Strategia ustalania stawek</th>
|
<th>Strategia ustalania stawek</th>
|
||||||
<th scope="col">Budżet</th>
|
<th>Budżet</th>
|
||||||
<th scope="col">Akcje</th>
|
<th style="width: 60px; text-align: center;">Akcje</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<script type="text/javascript">
|
|
||||||
var client_id = '';
|
|
||||||
|
|
||||||
function reloadChart()
|
<script type="text/javascript">
|
||||||
{
|
var client_id = '';
|
||||||
|
|
||||||
|
function reloadChart()
|
||||||
|
{
|
||||||
var campaign_id = $( '#campaign_id' ).val();
|
var campaign_id = $( '#campaign_id' ).val();
|
||||||
if ( !campaign_id ) return;
|
if ( !campaign_id ) return;
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: '/campaigns/get_campaign_history_data_table_chart/',
|
url: '/campaigns/get_campaign_history_data_table_chart/',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: {
|
data: { campaign_id: campaign_id },
|
||||||
campaign_id: campaign_id
|
success: function( response )
|
||||||
},
|
{
|
||||||
success: function(response) {
|
const parsedData = JSON.parse( response );
|
||||||
const parsedData = JSON.parse(response);
|
|
||||||
|
|
||||||
let plotLines = [];
|
let plotLines = [];
|
||||||
|
|
||||||
parsedData.comments.forEach(function(comment) {
|
parsedData.comments.forEach( function( comment ) {
|
||||||
plotLines.push({
|
plotLines.push({
|
||||||
color: '#333333',
|
color: '#333333',
|
||||||
width: 1,
|
width: 1,
|
||||||
value: parsedData.dates.indexOf(comment.date_add.split(' ')[0]),
|
value: parsedData.dates.indexOf( comment.date_add.split(' ')[0] ),
|
||||||
dashStyle: 'Solid',
|
dashStyle: 'Solid',
|
||||||
label: {
|
label: {
|
||||||
text: comment.comment,
|
text: comment.comment,
|
||||||
align: 'left',
|
align: 'left',
|
||||||
style: {
|
style: { color: '#333333', fontSize: '13px' }
|
||||||
color: '#333333',
|
|
||||||
fontSize: '14px'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
zIndex: 5
|
zIndex: 5
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Highcharts.chart('container', {
|
Highcharts.chart( 'container', {
|
||||||
title: {
|
chart: {
|
||||||
text: ``,
|
style: { fontFamily: '"Open Sans", sans-serif' },
|
||||||
},
|
backgroundColor: 'transparent'
|
||||||
subtitle: {
|
|
||||||
text: ``,
|
|
||||||
},
|
},
|
||||||
|
title: { text: '' },
|
||||||
|
subtitle: { text: '' },
|
||||||
yAxis: {
|
yAxis: {
|
||||||
title: {
|
title: { text: '' },
|
||||||
text: ''
|
gridLineColor: '#E2E8F0'
|
||||||
},
|
|
||||||
},
|
},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
categories: parsedData.dates,
|
categories: parsedData.dates,
|
||||||
labels: {
|
labels: {
|
||||||
style: {
|
style: { fontSize: '12px', color: '#8899A6' },
|
||||||
fontSize: '14px'
|
|
||||||
},
|
|
||||||
formatter: function() {
|
formatter: function() {
|
||||||
var date = new Date(Date.parse(this.value));
|
var date = new Date( Date.parse( this.value ) );
|
||||||
var day = date.getDate();
|
var day = date.getDate();
|
||||||
var month = date.getMonth() + 1;
|
var month = date.getMonth() + 1;
|
||||||
var year = date.getFullYear();
|
var year = date.getFullYear();
|
||||||
|
if ( day === 1 || this.isLast ) {
|
||||||
if (day === 1 || this.isLast) {
|
return year + '-' + ( month < 10 ? '0' + month : month ) + '-' + ( day < 10 ? '0' + day : day );
|
||||||
return `${year}-${month < 10 ? '0' + month : month}-${day < 10 ? '0' + day : day}`;
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plotLines: plotLines
|
plotLines: plotLines
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
layout: 'vertical',
|
layout: 'horizontal',
|
||||||
align: 'right',
|
align: 'center',
|
||||||
verticalAlign: 'middle',
|
verticalAlign: 'bottom',
|
||||||
itemStyle: {
|
itemStyle: { fontSize: '13px', color: '#4E5E6A' }
|
||||||
fontSize: '14px'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
plotOptions: {
|
plotOptions: {
|
||||||
series: {
|
series: {
|
||||||
label: {
|
label: { connectorAllowed: false },
|
||||||
connectorAllowed: false
|
pointStart: 0
|
||||||
},
|
}
|
||||||
pointStart: 0,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
colors: ['#6690F4', '#57B951', '#FF8C00', '#CC0000', '#8B5CF6'],
|
||||||
series: parsedData.chart_data,
|
series: parsedData.chart_data,
|
||||||
tooltip: {
|
tooltip: { style: { fontSize: '13px' } },
|
||||||
style: {
|
credits: { enabled: false }
|
||||||
fontSize: '14px'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
error: function (jqXHR, textStatus, errorThrown) {
|
error: function( jqXHR, textStatus, errorThrown ) {
|
||||||
console.error('Error AJAX:', textStatus, errorThrown);
|
console.error( 'Error AJAX:', textStatus, errorThrown );
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$( function()
|
$( function()
|
||||||
{
|
{
|
||||||
// load campaigns from server when client is selected
|
// Załaduj kampanie po wyborze klienta
|
||||||
$( 'body' ).on( 'change', '#client_id', function()
|
$( 'body' ).on( 'change', '#client_id', function()
|
||||||
{
|
{
|
||||||
client_id = $( this ).val();
|
client_id = $( this ).val();
|
||||||
var campaigns_select = $( '#campaign_id' );
|
var campaigns_select = $( '#campaign_id' );
|
||||||
|
|
||||||
$.ajax(
|
$.ajax({
|
||||||
{
|
|
||||||
url: '/campaigns/get_campaigns_list/client_id=' + client_id,
|
url: '/campaigns/get_campaigns_list/client_id=' + client_id,
|
||||||
type: 'GET',
|
type: 'GET',
|
||||||
success: function( response )
|
success: function( response )
|
||||||
{
|
{
|
||||||
var data = JSON.parse(response);
|
var data = JSON.parse( response );
|
||||||
|
|
||||||
campaigns_select.empty();
|
campaigns_select.empty();
|
||||||
campaigns_select.append('<option value="">- wybierz kampanię -</option>');
|
campaigns_select.append( '<option value="">— wybierz kampanię —</option>' );
|
||||||
|
|
||||||
var campaigns = Object.entries(data.campaigns);
|
var campaigns = Object.entries( data.campaigns );
|
||||||
|
|
||||||
// Sortowanie kampanii: "--- konto ---" zawsze na początku, reszta alfabetycznie
|
campaigns.sort( function( a, b ) {
|
||||||
campaigns.sort(function(a, b) {
|
if ( a[1] === "--- konto ---" ) return -1;
|
||||||
if (a[1] === "--- konto ---") {
|
if ( b[1] === "--- konto ---" ) return 1;
|
||||||
return -1; // "a" idzie na początek
|
return a[1] > b[1] ? 1 : ( a[1] < b[1] ? -1 : 0 );
|
||||||
} else if (b[1] === "--- konto ---") {
|
|
||||||
return 1; // "b" idzie na początek
|
|
||||||
} else {
|
|
||||||
// Porównanie ciągów znaków bez użycia localeCompare
|
|
||||||
return a[1] > b[1] ? 1 : (a[1] < b[1] ? -1 : 0);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dodawanie opcji do selecta
|
campaigns.forEach( function( [key, value] ) {
|
||||||
campaigns.forEach(function([key, value]) {
|
campaigns_select.append( '<option value="' + value.id + '">' + value.campaign_name + '</option>' );
|
||||||
campaigns_select.append('<option value="' + value.id + '">' + value.campaign_name + '</option>');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
<? if ( $campaign_id ): ?>
|
<?php if ( $campaign_id ): ?>
|
||||||
campaigns_select.val('<?= $campaign_id; ?>').trigger('change');
|
campaigns_select.val( '<?= $campaign_id; ?>' ).trigger( 'change' );
|
||||||
<? endif; ?>
|
<?php endif; ?>
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Usuwanie kampanii
|
||||||
$( 'body' ).on( 'click', '#delete_campaign', function()
|
$( 'body' ).on( 'click', '#delete_campaign', function()
|
||||||
{
|
{
|
||||||
var campaign_id = $( '#campaign_id' ).val();
|
var campaign_id = $( '#campaign_id' ).val();
|
||||||
@@ -268,13 +229,12 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
cancel: {
|
cancel: { text: 'Anuluj' }
|
||||||
text: 'Anuluj'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Usuwanie wpisu historii
|
||||||
$( 'body' ).on( 'click', '.delete-history-entry', function()
|
$( 'body' ).on( 'click', '.delete-history-entry', function()
|
||||||
{
|
{
|
||||||
var btn = $( this );
|
var btn = $( this );
|
||||||
@@ -321,13 +281,12 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
cancel: {
|
cancel: { text: 'Anuluj' }
|
||||||
text: 'Anuluj'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Załaduj dane po wyborze kampanii
|
||||||
$( 'body' ).on( 'change', '#campaign_id', function()
|
$( 'body' ).on( 'change', '#campaign_id', function()
|
||||||
{
|
{
|
||||||
var campaign_id = $( this ).val();
|
var campaign_id = $( this ).val();
|
||||||
@@ -342,20 +301,36 @@
|
|||||||
},
|
},
|
||||||
processing: true,
|
processing: true,
|
||||||
serverSide: true,
|
serverSide: true,
|
||||||
|
searching: false,
|
||||||
|
lengthChange: false,
|
||||||
|
pageLength: 15,
|
||||||
columns: [
|
columns: [
|
||||||
{ width: '100px', name: 'date', orderable: false },
|
{ width: '130px', name: 'date', orderable: false, className: "nowrap" },
|
||||||
{ width: '200px', name: 'roas30', orderable: false, className: "dt-type-numeric" },
|
{ width: '120px', name: 'roas30', orderable: false, className: "dt-type-numeric" },
|
||||||
{ width: '200px', name: 'roas_all_time', orderable: false, className: "dt-type-numeric" },
|
{ width: '120px', name: 'roas_all_time', orderable: false, className: "dt-type-numeric" },
|
||||||
{ width: '250px', name: 'conversion_value', orderable: false, className: "dt-type-numeric" },
|
{ width: '180px', name: 'conversion_value', orderable: false, className: "dt-type-numeric" },
|
||||||
{ width: '200px', name: 'spend30', orderable: false, className: "dt-type-numeric" },
|
{ width: '140px', name: 'spend30', orderable: false, className: "dt-type-numeric" },
|
||||||
{ width: 'auto', name: 'bidding_strategy', orderable: false },
|
|
||||||
{ width: 'auto', name: 'comment', orderable: false },
|
{ width: 'auto', name: 'comment', orderable: false },
|
||||||
{ width: '150px', name: 'budget', orderable: false, className: "dt-type-numeric" },
|
{ width: 'auto', name: 'bidding_strategy', orderable: false },
|
||||||
{ width: '80px', name: 'actions', orderable: false, className: "dt-center" }
|
{ width: '100px', name: 'budget', orderable: false, className: "dt-type-numeric" },
|
||||||
|
{ width: '60px', name: 'actions', orderable: false, className: "dt-center" }
|
||||||
],
|
],
|
||||||
|
language: {
|
||||||
|
processing: 'Ładowanie...',
|
||||||
|
emptyTable: 'Brak danych do wyświetlenia',
|
||||||
|
info: 'Wpisy _START_ - _END_ z _TOTAL_',
|
||||||
|
infoEmpty: '',
|
||||||
|
lengthMenu: 'Pokaż _MENU_ wpisów',
|
||||||
|
paginate: {
|
||||||
|
first: 'Pierwsza',
|
||||||
|
last: 'Ostatnia',
|
||||||
|
next: 'Dalej',
|
||||||
|
previous: 'Wstecz'
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
reloadChart();
|
reloadChart();
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
158
templates/clients/main_view.php
Normal file
158
templates/clients/main_view.php
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<div class="clients-page">
|
||||||
|
<div class="clients-header">
|
||||||
|
<h2><i class="fa-solid fa-building"></i> Klienci</h2>
|
||||||
|
<button type="button" class="btn btn-success" onclick="openClientForm()">
|
||||||
|
<i class="fa-solid fa-plus mr5"></i>Dodaj klienta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="clients-table-wrap">
|
||||||
|
<table class="table" id="clients-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 60px;">#ID</th>
|
||||||
|
<th>Nazwa klienta</th>
|
||||||
|
<th>Google Ads Customer ID</th>
|
||||||
|
<th>Dane od</th>
|
||||||
|
<th style="width: 120px; text-align: center;">Akcje</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php if ( $this -> clients ): ?>
|
||||||
|
<?php foreach ( $this -> clients as $client ): ?>
|
||||||
|
<tr data-id="<?= $client['id']; ?>">
|
||||||
|
<td class="client-id"><?= $client['id']; ?></td>
|
||||||
|
<td class="client-name"><?= htmlspecialchars( $client['name'] ); ?></td>
|
||||||
|
<td>
|
||||||
|
<?php if ( $client['google_ads_customer_id'] ): ?>
|
||||||
|
<span class="badge-id"><?= htmlspecialchars( $client['google_ads_customer_id'] ); ?></span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="text-muted">— brak —</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php if ( $client['google_ads_start_date'] ): ?>
|
||||||
|
<?= $client['google_ads_start_date']; ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="text-muted">— brak —</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<button type="button" class="btn-icon btn-icon-edit" onclick="editClient(<?= $client['id']; ?>)" title="Edytuj">
|
||||||
|
<i class="fa-solid fa-pen"></i>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-icon btn-icon-delete" onclick="deleteClient(<?= $client['id']; ?>, '<?= htmlspecialchars( addslashes( $client['name'] ) ); ?>')" title="Usuń">
|
||||||
|
<i class="fa-solid fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="empty-state">
|
||||||
|
<i class="fa-solid fa-building"></i>
|
||||||
|
<p>Brak klientów. Dodaj pierwszego klienta.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal: Dodaj / Edytuj klienta -->
|
||||||
|
<div class="default_popup" id="client-modal">
|
||||||
|
<div class="popup_content" style="max-width: 520px;">
|
||||||
|
<div class="popup_header">
|
||||||
|
<div class="title" id="client-modal-title">Dodaj klienta</div>
|
||||||
|
<div class="close"><i class="fa-solid fa-xmark"></i></div>
|
||||||
|
</div>
|
||||||
|
<div class="popup_body">
|
||||||
|
<form method="POST" id="client-form" action="/clients/save">
|
||||||
|
<input type="hidden" name="id" id="client-id" value="" />
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="client-name">Nazwa klienta</label>
|
||||||
|
<input type="text" id="client-name" name="name" class="form-control" required placeholder="np. Firma XYZ" />
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="client-gads-id">Google Ads Customer ID</label>
|
||||||
|
<input type="text" id="client-gads-id" name="google_ads_customer_id" class="form-control" placeholder="np. 123-456-7890 (opcjonalnie)" />
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="client-gads-start">Pobieraj dane od</label>
|
||||||
|
<input type="date" id="client-gads-start" name="google_ads_start_date" class="form-control" />
|
||||||
|
<small class="text-muted">Data od której CRON zacznie pobierać dane z Google Ads API</small>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 20px; display: flex; gap: 10px;">
|
||||||
|
<button type="submit" class="btn btn-success"><i class="fa-solid fa-check mr5"></i>Zapisz</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="closeClientForm()">Anuluj</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
function openClientForm()
|
||||||
|
{
|
||||||
|
$( '#client-modal-title' ).text( 'Dodaj klienta' );
|
||||||
|
$( '#client-id' ).val( '' );
|
||||||
|
$( '#client-name' ).val( '' );
|
||||||
|
$( '#client-gads-id' ).val( '' );
|
||||||
|
$( '#client-gads-start' ).val( '' );
|
||||||
|
$( '#client-modal' ).fadeIn();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeClientForm()
|
||||||
|
{
|
||||||
|
$( '#client-modal' ).fadeOut();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editClient( id )
|
||||||
|
{
|
||||||
|
$.getJSON( '/clients/get/id=' + id, function( data ) {
|
||||||
|
$( '#client-modal-title' ).text( 'Edytuj klienta' );
|
||||||
|
$( '#client-id' ).val( data.id );
|
||||||
|
$( '#client-name' ).val( data.name );
|
||||||
|
$( '#client-gads-id' ).val( data.google_ads_customer_id || '' );
|
||||||
|
$( '#client-gads-start' ).val( data.google_ads_start_date || '' );
|
||||||
|
$( '#client-modal' ).fadeIn();
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteClient( id, name )
|
||||||
|
{
|
||||||
|
$.confirm( {
|
||||||
|
title: 'Potwierdzenie',
|
||||||
|
content: 'Czy na pewno chcesz usunąć klienta <strong>' + name + '</strong>?',
|
||||||
|
type: 'red',
|
||||||
|
buttons: {
|
||||||
|
confirm: {
|
||||||
|
text: 'Usuń',
|
||||||
|
btnClass: 'btn-red',
|
||||||
|
action: function() {
|
||||||
|
$.post( '/clients/delete', { id: id }, function( response ) {
|
||||||
|
var data = JSON.parse( response );
|
||||||
|
if ( data.success ) {
|
||||||
|
$( 'tr[data-id="' + id + '"]' ).fadeOut( 300, function() { $( this ).remove(); } );
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancel: {
|
||||||
|
text: 'Anuluj'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zamknij modal klawiszem Escape
|
||||||
|
$( document ).on( 'keydown', function( e ) {
|
||||||
|
if ( e.key === 'Escape' ) closeClientForm();
|
||||||
|
} );
|
||||||
|
|
||||||
|
// Zamknij modal kliknięciem w tło
|
||||||
|
$( '#client-modal' ).on( 'click', function( e ) {
|
||||||
|
if ( e.target === this ) closeClientForm();
|
||||||
|
} );
|
||||||
|
</script>
|
||||||
@@ -1,129 +1,126 @@
|
|||||||
<div class="admin-form theme-primary">
|
<div class="products-page">
|
||||||
<div class="panel heading-border panel-primary">
|
<div class="products-header">
|
||||||
<div class="panel-body">
|
<h2><i class="fa-solid fa-box-open"></i> Produkty</h2>
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label class="field select">
|
|
||||||
<select id="client_id" name="client_id">
|
|
||||||
<option value="">--- wybierz klienta ---</option>
|
|
||||||
<? foreach ( $this -> clients as $client ):?>
|
|
||||||
<option value="<?= $client['id'];?>"><?= $client['name'];?></option>
|
|
||||||
<? endforeach;?>
|
|
||||||
</select>
|
|
||||||
<i class="arrow double"></i>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-1 text-right">
|
|
||||||
<label>Bestseller min ROAS</label>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<input type="text" id="bestseller_min_roas" name="bestseller_min_roas" class="form-control" placeholder="Minimalny ROAS bestsellerów" value="" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-form theme-primary">
|
<!-- Filtry -->
|
||||||
<div class="panel heading-border panel-primary">
|
<div class="products-filters">
|
||||||
<div class="panel-body">
|
<div class="filter-group filter-group-client">
|
||||||
<div style="margin-bottom: 20px;">
|
<label for="client_id"><i class="fa-solid fa-building"></i> Klient</label>
|
||||||
<button type="button" class="btn btn-danger" id="delete-selected-products" disabled style="padding: 6px 12px; font-size: 13px;">
|
<select id="client_id" name="client_id" class="form-control">
|
||||||
<i class="fa fa-trash"></i> Usuń zaznaczone (<span id="selected-count">0</span>)
|
<option value="">— wybierz klienta —</option>
|
||||||
|
<?php foreach ( $this -> clients as $client ): ?>
|
||||||
|
<option value="<?= $client['id']; ?>"><?= htmlspecialchars( $client['name'] ); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-group filter-group-roas">
|
||||||
|
<label for="bestseller_min_roas"><i class="fa-solid fa-star"></i> Bestseller min ROAS</label>
|
||||||
|
<input type="text" id="bestseller_min_roas" name="bestseller_min_roas" class="form-control" placeholder="np. 500" value="" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Akcje bulk -->
|
||||||
|
<div class="products-actions">
|
||||||
|
<button type="button" class="btn btn-danger btn-sm" id="delete-selected-products" disabled>
|
||||||
|
<i class="fa-solid fa-trash"></i> Usuń zaznaczone (<span id="selected-count">0</span>)
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<table class="table table-sm table-hover" id="products">
|
|
||||||
|
<!-- Tabela produktów -->
|
||||||
|
<div class="products-table-wrap">
|
||||||
|
<table class="table table-sm" id="products">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col"><input type="checkbox" id="select-all-products" title="Zaznacz wszystkie" /></th>
|
<th><input type="checkbox" id="select-all-products" title="Zaznacz wszystkie" /></th>
|
||||||
<th scope="col">Id</th>
|
<th>Id</th>
|
||||||
<th scope="col">Id oferty</th>
|
<th>Id oferty</th>
|
||||||
<th scope="col">Nazwa produktu</th>
|
<th>Nazwa produktu</th>
|
||||||
<th scope="col">Wyśw.</th>
|
<th>Wyśw.</th>
|
||||||
<th scope="col">Wyśw. (30 dni)</th>
|
<th>Wyśw. (30d)</th>
|
||||||
<th scope="col">Klik.</th>
|
<th>Klik.</th>
|
||||||
<th scope="col">Klik. (30 dni)</th>
|
<th>Klik. (30d)</th>
|
||||||
<th scope="col">CTR</th>
|
<th>CTR</th>
|
||||||
<th scope="col">Koszt</th>
|
<th>Koszt</th>
|
||||||
<th scope="col">CPC</th>
|
<th>CPC</th>
|
||||||
<th scope="col">Konw.</th>
|
<th>Konw.</th>
|
||||||
<th scope="col">Wartość konw.</th>
|
<th>Wart. konw.</th>
|
||||||
<th scope="col">ROAS</th>
|
<th>ROAS</th>
|
||||||
<th scope="col">Min. ROAS</th>
|
<th>Min. ROAS</th>
|
||||||
<th scope="col">CL3</th>
|
<th>CL3</th>
|
||||||
<th scope="col">CL4</th>
|
<th>CL4</th>
|
||||||
<th scope="col">Akcje</th>
|
<th>Akcje</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<script type="text/javascript">
|
|
||||||
var GOOGLE_TAXONOMY_ENDPOINT = '/tools/google-taxonomy.php'; // dostosuj ścieżkę
|
|
||||||
var googleCategories = [];
|
|
||||||
|
|
||||||
// główna funkcja – używaj jej w modalu
|
<script type="text/javascript">
|
||||||
function loadGoogleCategories(callback) {
|
function show_toast( message, type )
|
||||||
if (googleCategories.length) {
|
{
|
||||||
// już załadowane wcześniej
|
var bg = type === 'error' ? '#dc3545' : '#28a745';
|
||||||
callback(googleCategories);
|
var icon = type === 'error' ? 'fa-circle-xmark' : 'fa-circle-check';
|
||||||
|
var $toast = $( '<div class="app-toast"><i class="fa-solid ' + icon + '"></i> ' + message + '</div>' );
|
||||||
|
$toast.css({
|
||||||
|
position: 'fixed', bottom: '30px', right: '30px', zIndex: 99999,
|
||||||
|
background: bg, color: '#fff', padding: '12px 24px', borderRadius: '8px',
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,.25)', fontSize: '14px', fontWeight: 500,
|
||||||
|
opacity: 0, transform: 'translateY(20px)', transition: 'all .3s ease'
|
||||||
|
});
|
||||||
|
$( 'body' ).append( $toast );
|
||||||
|
setTimeout( function() { $toast.css({ opacity: 1, transform: 'translateY(0)' }); }, 10 );
|
||||||
|
setTimeout( function() {
|
||||||
|
$toast.css({ opacity: 0, transform: 'translateY(20px)' });
|
||||||
|
setTimeout( function() { $toast.remove(); }, 300 );
|
||||||
|
}, 3000 );
|
||||||
|
}
|
||||||
|
|
||||||
|
var GOOGLE_TAXONOMY_ENDPOINT = '/tools/google-taxonomy.php';
|
||||||
|
var googleCategories = [];
|
||||||
|
|
||||||
|
function loadGoogleCategories( callback )
|
||||||
|
{
|
||||||
|
if ( googleCategories.length ) {
|
||||||
|
callback( googleCategories );
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: GOOGLE_TAXONOMY_ENDPOINT,
|
url: GOOGLE_TAXONOMY_ENDPOINT,
|
||||||
dataType: 'json',
|
dataType: 'json',
|
||||||
success: function (res) {
|
success: function( res ) {
|
||||||
if (res.status === 'ok') {
|
if ( res.status === 'ok' ) {
|
||||||
googleCategories = res.categories || [];
|
googleCategories = res.categories || [];
|
||||||
callback(googleCategories);
|
callback( googleCategories );
|
||||||
} else {
|
} else {
|
||||||
console.error('Błąd pobierania taksonomii:', res);
|
callback( [] );
|
||||||
callback([]);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: function (xhr, status, error) {
|
error: function() { callback( [] ); }
|
||||||
console.error('AJAX error przy pobieraniu taksonomii:', status, error);
|
|
||||||
callback([]);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$( function()
|
||||||
|
{
|
||||||
$( function()
|
// Załaduj produkty po wyborze klienta
|
||||||
{
|
|
||||||
$( 'body' ).on( 'change', '#client_id', function()
|
$( 'body' ).on( 'change', '#client_id', function()
|
||||||
{
|
{
|
||||||
var client_id = $( this ).val();
|
var client_id = $( this ).val();
|
||||||
|
localStorage.setItem( 'products_client_id', client_id );
|
||||||
|
|
||||||
table = $( '#products' ).DataTable();
|
table = $( '#products' ).DataTable();
|
||||||
table.destroy();
|
table.destroy();
|
||||||
|
|
||||||
// get min client roas
|
// Pobierz min ROAS bestsellera
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: '/products/get_client_bestseller_min_roas/',
|
url: '/products/get_client_bestseller_min_roas/',
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
data: {
|
data: { client_id: client_id },
|
||||||
client_id: client_id
|
|
||||||
},
|
|
||||||
success: function( response ) {
|
success: function( response ) {
|
||||||
data = JSON.parse(response);
|
data = JSON.parse( response );
|
||||||
if ( data.status == 'ok' )
|
$( '#bestseller_min_roas' ).val( data.status == 'ok' ? data.min_roas : '' );
|
||||||
{
|
|
||||||
$( '#bestseller_min_roas' ).val( data.min_roas );
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
$( '#bestseller_min_roas' ).val( '' );
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: function() {
|
|
||||||
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,8 +132,11 @@
|
|||||||
processing: true,
|
processing: true,
|
||||||
serverSide: true,
|
serverSide: true,
|
||||||
autoWidth: false,
|
autoWidth: false,
|
||||||
|
searching: false,
|
||||||
|
lengthChange: false,
|
||||||
|
pageLength: 25,
|
||||||
columns: [
|
columns: [
|
||||||
{ width: '30px', orderable: false, className: 'select-checkbox', render: function(data, type, row) {
|
{ width: '30px', orderable: false, className: 'select-checkbox', render: function( data, type, row ) {
|
||||||
return '<input type="checkbox" class="product-checkbox" value="' + row[1] + '" />';
|
return '<input type="checkbox" class="product-checkbox" value="' + row[1] + '" />';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -144,168 +144,180 @@
|
|||||||
{ width: '80px', name: 'offer_id' },
|
{ width: '80px', name: 'offer_id' },
|
||||||
{ name: 'name' },
|
{ name: 'name' },
|
||||||
{ width: '50px', name: 'impressions' },
|
{ width: '50px', name: 'impressions' },
|
||||||
{ width: '150px', name: 'impressions_30' },
|
{ width: '80px', name: 'impressions_30' },
|
||||||
{ width: '50px', name: 'clicks' },
|
{ width: '50px', name: 'clicks' },
|
||||||
{ width: '150px', name: 'clicks_30' },
|
{ width: '80px', name: 'clicks_30' },
|
||||||
{ width: '50px', name: 'ctr' },
|
{ width: '50px', name: 'ctr' },
|
||||||
{ width: '100px', name: 'cost', className: "dt-type-numeric" },
|
{ width: '80px', name: 'cost', className: "dt-type-numeric" },
|
||||||
{ width: '50px', name: 'cpc', className: "dt-type-numeric" },
|
{ width: '50px', name: 'cpc', className: "dt-type-numeric" },
|
||||||
{ width: '50px', name: 'conversions' },
|
{ width: '50px', name: 'conversions' },
|
||||||
{ width: '125px', name: 'conversions_value', className: "dt-type-numeric" },
|
{ width: '90px', name: 'conversions_value', className: "dt-type-numeric" },
|
||||||
{ width: '70px', name: 'roas' },
|
{ width: '60px', name: 'roas' },
|
||||||
{ width: '70px', name: 'min_roas' },
|
{ width: '70px', name: 'min_roas' },
|
||||||
{ width: '50px', name: 'cl3', orderable: false },
|
{ width: '50px', name: 'cl3', orderable: false },
|
||||||
{ width: '100px', orderable: false },
|
{ width: '120px', orderable: false },
|
||||||
{ width: '50px', orderable: false }],
|
{ width: '50px', orderable: false, className: 'dt-center' }
|
||||||
order: [ [ 6, 'desc' ] ]
|
],
|
||||||
|
order: [ [ 6, 'desc' ] ],
|
||||||
|
language: {
|
||||||
|
processing: 'Ładowanie...',
|
||||||
|
emptyTable: 'Brak produktów do wyświetlenia',
|
||||||
|
info: 'Produkty _START_ - _END_ z _TOTAL_',
|
||||||
|
infoEmpty: '',
|
||||||
|
paginate: {
|
||||||
|
first: 'Pierwsza',
|
||||||
|
last: 'Ostatnia',
|
||||||
|
next: 'Dalej',
|
||||||
|
previous: 'Wstecz'
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$( 'body' ).on( 'click', '.delete-product', function(e)
|
// Przywróć ostatnio wybranego klienta
|
||||||
|
var savedClient = localStorage.getItem( 'products_client_id' );
|
||||||
|
if ( savedClient && $( '#client_id option[value="' + savedClient + '"]' ).length ) {
|
||||||
|
$( '#client_id' ).val( savedClient ).trigger( 'change' );
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usuwanie produktu
|
||||||
|
$( 'body' ).on( 'click', '.delete-product', function( e )
|
||||||
{
|
{
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var product_id = $( this ).attr( 'product_id' );
|
var product_id = $( this ).attr( 'product_id' );
|
||||||
var row = $( this ).closest('tr');
|
var row = $( this ).closest( 'tr' );
|
||||||
|
|
||||||
$.confirm({
|
$.confirm({
|
||||||
title: 'Potwierdzenie',
|
title: 'Potwierdzenie',
|
||||||
content: 'Czy na pewno chcesz usunąć ten produkt?',
|
content: 'Czy na pewno chcesz usunąć ten produkt?',
|
||||||
|
type: 'red',
|
||||||
buttons: {
|
buttons: {
|
||||||
confirm: {
|
confirm: {
|
||||||
text: 'Usuń',
|
text: 'Usuń',
|
||||||
|
btnClass: 'btn-red',
|
||||||
keys: ['enter'],
|
keys: ['enter'],
|
||||||
action: function () {
|
action: function() {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: '/products/delete_product/',
|
url: '/products/delete_product/',
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
data: {
|
data: { product_id: product_id },
|
||||||
product_id: product_id
|
|
||||||
},
|
|
||||||
success: function( response ) {
|
success: function( response ) {
|
||||||
data = JSON.parse(response);
|
data = JSON.parse( response );
|
||||||
if ( data.status == 'ok' )
|
if ( data.status == 'ok' ) {
|
||||||
{
|
|
||||||
// change to alert with auto close after 2 seconds
|
|
||||||
$.alert({
|
$.alert({
|
||||||
title: 'Sukces',
|
title: 'Sukces',
|
||||||
content: 'Produkt został usunięty.',
|
content: 'Produkt został usunięty.',
|
||||||
|
type: 'green',
|
||||||
autoClose: 'ok|2000',
|
autoClose: 'ok|2000',
|
||||||
buttons: {
|
buttons: { ok: function() {} }
|
||||||
ok: function () {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
// usuń wiersz z tabeli (zachowaj bieżącą stronę)
|
var table = $( '#products' ).DataTable();
|
||||||
var table = $('#products').DataTable();
|
table.row( row ).remove().draw( false );
|
||||||
table.row(row).remove().draw(false);
|
} else {
|
||||||
}
|
$.alert({ title: 'Błąd', content: response, type: 'red' });
|
||||||
else
|
|
||||||
{
|
|
||||||
$.alert('Błąd: ' + response);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: function() {
|
error: function() {
|
||||||
$.alert('Wystąpił błąd podczas usuwania produktu. Spróbuj ponownie.');
|
$.alert({ title: 'Błąd', content: 'Wystąpił błąd podczas usuwania produktu.', type: 'red' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
cancel: {
|
cancel: { text: 'Anuluj' }
|
||||||
text: 'Anuluj',
|
|
||||||
action: function () {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Zapis min ROAS produktu
|
||||||
$( 'body' ).on( 'change', '.min_roas', function()
|
$( 'body' ).on( 'change', '.min_roas', function()
|
||||||
{
|
{
|
||||||
var input = $( this );
|
|
||||||
var product_id = $( this ).attr( 'product_id' );
|
var product_id = $( this ).attr( 'product_id' );
|
||||||
var min_roas = $( this ).val();
|
var min_roas = $( this ).val();
|
||||||
|
|
||||||
input.addClass('saving');
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: '/products/save_min_roas/',
|
url: '/products/save_min_roas/',
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
data: {
|
data: { product_id: product_id, min_roas: min_roas }
|
||||||
product_id: product_id,
|
});
|
||||||
min_roas: min_roas
|
|
||||||
},
|
|
||||||
success: function( response ) {
|
|
||||||
},
|
|
||||||
complete: function() {
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
})
|
|
||||||
|
|
||||||
|
// Zapis custom_label_4
|
||||||
$( 'body' ).on( 'change', '.custom_label_4', function()
|
$( 'body' ).on( 'change', '.custom_label_4', function()
|
||||||
{
|
{
|
||||||
var input = $( this );
|
|
||||||
var product_id = $( this ).attr( 'product_id' );
|
var product_id = $( this ).attr( 'product_id' );
|
||||||
var custom_label_4 = $( this ).val();
|
var custom_label_4 = $( this ).val();
|
||||||
|
|
||||||
input.addClass('saving');
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: '/products/save_custom_label_4/',
|
url: '/products/save_custom_label_4/',
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
data: {
|
data: { product_id: product_id, custom_label_4: custom_label_4 }
|
||||||
product_id: product_id,
|
|
||||||
custom_label_4: custom_label_4
|
|
||||||
},
|
|
||||||
success: function( response ) {
|
|
||||||
},
|
|
||||||
complete: function() {
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$( 'body' ).on( 'click', '.edit-product-title', function(e)
|
// Edycja produktu (tytuł, opis, kategoria Google)
|
||||||
|
$( 'body' ).on( 'click', '.edit-product-title', function( e )
|
||||||
{
|
{
|
||||||
$.confirm({
|
$.confirm({
|
||||||
title: 'Edytuj produkt',
|
title: 'Edytuj produkt',
|
||||||
content: '' +
|
content: '' +
|
||||||
'<form action="" class="formName">' +
|
'<form action="" class="formName">' +
|
||||||
'<div class="form-group">' +
|
'<div class="form-group">' +
|
||||||
|
'<label>Tytuł produktu</label>' +
|
||||||
|
'<div class="input-with-ai">' +
|
||||||
'<input type="text" value="" product_id="' + $( this ).attr( 'product_id' ) + '" placeholder="Tytuł produktu" class="name form-control" required />' +
|
'<input type="text" value="" product_id="' + $( this ).attr( 'product_id' ) + '" placeholder="Tytuł produktu" class="name form-control" required />' +
|
||||||
|
'<button type="button" class="btn btn-sm btn-ai-suggest" data-field="title" title="Zaproponuj tytuł przez AI"><i class="fa-solid fa-wand-magic-sparkles"></i> AI</button>' +
|
||||||
|
'</div>' +
|
||||||
'<small>0/150 znaków</small>' +
|
'<small>0/150 znaków</small>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="form-group">' +
|
'<div class="form-group">' +
|
||||||
'<textarea class="form-control description" rows="4" placeholder="Opis produktu (opcjonalnie)"></textarea>' +
|
'<label>URL strony produktu <small class="text-muted">(opcjonalnie, dla lepszego kontekstu AI)</small></label>' +
|
||||||
|
'<input type="url" class="form-control product-url" placeholder="https://sklep.pl/produkt/..." />' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="form-group">' +
|
'<div class="form-group">' +
|
||||||
' <select class="form-control google-category" id="google_category" style="width: 100%">' +
|
'<div class="desc-header">' +
|
||||||
' <option value="">— wybierz kategorię —</option>' +
|
'<label>Opis produktu</label>' +
|
||||||
' </select>' +
|
'<div class="desc-tabs">' +
|
||||||
|
'<button type="button" class="desc-tab active" data-tab="edit"><i class="fa-solid fa-code"></i> Edycja</button>' +
|
||||||
|
'<button type="button" class="desc-tab" data-tab="preview"><i class="fa-solid fa-eye"></i> Podgląd</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="input-with-ai">' +
|
||||||
|
'<div class="desc-wrap">' +
|
||||||
|
'<textarea class="form-control description" style="height:220px;resize:vertical" placeholder="Opis produktu (opcjonalnie)"></textarea>' +
|
||||||
|
'<div class="desc-preview" style="display:none;height:220px;overflow-y:auto;padding:10px 12px;border:1px solid #ddd;border-radius:4px;background:#fff;font-size:13px;line-height:1.6"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<button type="button" class="btn btn-sm btn-ai-suggest" data-field="description" title="Zaproponuj opis przez AI"><i class="fa-solid fa-wand-magic-sparkles"></i> AI</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="form-group">' +
|
||||||
|
'<label>Kategoria Google</label>' +
|
||||||
|
'<div class="input-with-ai">' +
|
||||||
|
'<select class="form-control google-category" id="google_category" style="width: calc(100% - 60px)">' +
|
||||||
|
'<option value="">— wybierz kategorię —</option>' +
|
||||||
|
'</select>' +
|
||||||
|
'<button type="button" class="btn btn-sm btn-ai-suggest" data-field="category" title="Zaproponuj kategorię przez AI"><i class="fa-solid fa-wand-magic-sparkles"></i> AI</button>' +
|
||||||
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</form>',
|
'</form>',
|
||||||
columnClass: 'col-md-8 col-md-offset-2 col-12',
|
useBootstrap: false,
|
||||||
|
boxWidth: '1280px',
|
||||||
theme: 'modern',
|
theme: 'modern',
|
||||||
draggable: true,
|
draggable: true,
|
||||||
buttons: {
|
buttons: {
|
||||||
formSubmit: {
|
formSubmit: {
|
||||||
text: 'Zapisz',
|
text: 'Zapisz',
|
||||||
btnClass: 'btn-blue',
|
btnClass: 'btn-blue',
|
||||||
action: function () {
|
action: function() {
|
||||||
var jc = this;
|
var jc = this;
|
||||||
var product_id = this.$content.find( '.name' ).attr( 'product_id' );
|
var product_id = this.$content.find( '.name' ).attr( 'product_id' );
|
||||||
var customTitle = this.$content.find('.name').val();
|
var customTitle = this.$content.find( '.name' ).val();
|
||||||
var googleProductCategory = this.$content.find('.google-category').val(); // NOWOŚĆ
|
var googleProductCategory = this.$content.find( '.google-category' ).val();
|
||||||
|
|
||||||
if ( !customTitle )
|
if ( customTitle && customTitle.length > 150 ) {
|
||||||
{
|
$.alert( 'Pole tytuł nie może przekraczać 150 znaków!' );
|
||||||
$.alert('Pole tytuł nie może być puste!');
|
this.$content.find( '.name' ).addClass( 'is-invalid' );
|
||||||
return false;
|
|
||||||
}
|
|
||||||
else if (customTitle.length > 150)
|
|
||||||
{
|
|
||||||
$.alert('Pole tytuł nie może przekraczać 150 znaków!');
|
|
||||||
this.$content.find('.name').addClass('is-invalid');
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
jc.showLoading(true);
|
jc.showLoading( true );
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: '/products/save_product_data/',
|
url: '/products/save_product_data/',
|
||||||
@@ -313,228 +325,239 @@
|
|||||||
data: {
|
data: {
|
||||||
product_id: product_id,
|
product_id: product_id,
|
||||||
custom_title: customTitle,
|
custom_title: customTitle,
|
||||||
custom_description: this.$content.find('.description').val(),
|
custom_description: this.$content.find( '.description' ).val(),
|
||||||
google_product_category: googleProductCategory // NOWOŚĆ – zapis
|
google_product_category: googleProductCategory,
|
||||||
|
product_url: this.$content.find( '.product-url' ).val()
|
||||||
},
|
},
|
||||||
success: function(response) {
|
success: function( response ) {
|
||||||
data = JSON.parse(response);
|
data = JSON.parse( response );
|
||||||
jc.hideLoading();
|
jc.hideLoading();
|
||||||
|
if ( data.status == 'ok' ) {
|
||||||
if ( data.status == 'ok' )
|
|
||||||
{
|
|
||||||
$.alert( 'Dane produktu zostały zapisane.' );
|
|
||||||
jc.close();
|
jc.close();
|
||||||
}
|
show_toast( 'Dane produktu zostały zapisane.', 'success' );
|
||||||
else
|
} else {
|
||||||
{
|
show_toast( 'Błąd: ' + response, 'error' );
|
||||||
$.alert('Błąd: ' + response);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: function() {
|
error: function() {
|
||||||
jc.hideLoading();
|
jc.hideLoading();
|
||||||
$.alert('Wystąpił błąd podczas zapisywania tytułu. Spróbuj ponownie.');
|
show_toast( 'Wystąpił błąd podczas zapisywania. Spróbuj ponownie.', 'error' );
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
cancel: {
|
cancel: { text: 'Anuluj', btnClass: 'btn-red' }
|
||||||
text: 'Anuluj',
|
|
||||||
btnClass: 'btn-red',
|
|
||||||
action: function () {
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
onContentReady: function() {
|
||||||
onContentReady: function () {
|
|
||||||
var jc = this;
|
var jc = this;
|
||||||
|
var $form = this.$content.find( 'form' );
|
||||||
var $form = this.$content.find('form');
|
var $inputField = this.$content.find( '.name' );
|
||||||
var $inputField = this.$content.find('.name');
|
var $charCount = this.$content.find( 'small' ).first();
|
||||||
var $charCount = this.$content.find('small').first();
|
var $description = this.$content.find( '.description' );
|
||||||
var $description = this.$content.find('.description');
|
var $productUrl = this.$content.find( '.product-url' );
|
||||||
var $googleCategory = this.$content.find('.google-category');
|
var $googleCategory = this.$content.find( '.google-category' );
|
||||||
|
var product_id = $inputField.attr( 'product_id' );
|
||||||
// 1) Pobierz dane produktu
|
|
||||||
var product_id = $inputField.attr('product_id');
|
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: '/products/get_product_data/',
|
url: '/products/get_product_data/',
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
data: { product_id: product_id },
|
data: { product_id: product_id },
|
||||||
success: function (response) {
|
success: function( response ) {
|
||||||
var data = JSON.parse(response);
|
var data = JSON.parse( response );
|
||||||
|
if ( data.status == 'ok' ) {
|
||||||
if (data.status == 'ok') {
|
if ( data.product_details.title ) {
|
||||||
if (data.product_details.title) {
|
$inputField.val( data.product_details.title );
|
||||||
$inputField.val(data.product_details.title);
|
$charCount.text( data.product_details.title.length + '/150 znaków' );
|
||||||
var currentLength = data.product_details.title.length;
|
|
||||||
$charCount.text(currentLength + '/150 znaków');
|
|
||||||
}
|
}
|
||||||
|
if ( data.product_details.description ) {
|
||||||
if (data.product_details.description) {
|
$description.val( data.product_details.description );
|
||||||
$description.val(data.product_details.description);
|
}
|
||||||
|
if ( data.product_details.product_url ) {
|
||||||
|
$productUrl.val( data.product_details.product_url );
|
||||||
}
|
}
|
||||||
|
|
||||||
// zapamiętujemy ID kategorii z backendu
|
|
||||||
jc.preselectedGoogleCategory = data.product_details.google_product_category || "";
|
jc.preselectedGoogleCategory = data.product_details.google_product_category || "";
|
||||||
} else {
|
|
||||||
$.alert('Błąd: ' + response);
|
|
||||||
}
|
}
|
||||||
}.bind(this),
|
|
||||||
error: function () {
|
|
||||||
$.alert('Wystąpił błąd podczas pobierania szczegółów produktu. Spróbuj ponownie.');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2) Pobierz kategorie z Twojego endpointu (bez CORS) i zainicjuj Select2
|
loadGoogleCategories( function( cats ) {
|
||||||
loadGoogleCategories(function (cats) {
|
jc.googleCategoriesData = cats;
|
||||||
if (typeof $.fn.select2 !== 'undefined') {
|
if ( typeof $.fn.select2 !== 'undefined' ) {
|
||||||
$googleCategory.select2({
|
$googleCategory.select2({
|
||||||
placeholder: 'Wpisz fragment nazwy kategorii...',
|
placeholder: 'Wpisz fragment nazwy kategorii...',
|
||||||
allowClear: true,
|
allowClear: true,
|
||||||
data: cats,
|
data: cats,
|
||||||
dropdownParent: jc.$content.closest('.jconfirm-box')
|
dropdownParent: jc.$content.closest( '.jconfirm-box' )
|
||||||
|
});
|
||||||
|
if ( jc.preselectedGoogleCategory ) {
|
||||||
|
$googleCategory.val( jc.preselectedGoogleCategory ).trigger( 'change' );
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (jc.preselectedGoogleCategory) {
|
$inputField.on( 'input', function() {
|
||||||
$googleCategory.val(jc.preselectedGoogleCategory).trigger('change');
|
var len = $( this ).val().length;
|
||||||
|
$charCount.text( len + '/150 znaków' );
|
||||||
|
$( this ).toggleClass( 'is-invalid', len > 150 );
|
||||||
|
});
|
||||||
|
|
||||||
|
// Opis — przełączanie zakładek Edycja / Podgląd
|
||||||
|
var $descPreview = this.$content.find( '.desc-preview' );
|
||||||
|
this.$content.on( 'click', '.desc-tab', function() {
|
||||||
|
var tab = $( this ).data( 'tab' );
|
||||||
|
jc.$content.find( '.desc-tab' ).removeClass( 'active' );
|
||||||
|
$( this ).addClass( 'active' );
|
||||||
|
if ( tab === 'preview' ) {
|
||||||
|
$descPreview.html( $description.val() || '<span style="color:#999">Brak opisu</span>' ).show();
|
||||||
|
$description.hide();
|
||||||
|
} else {
|
||||||
|
$description.show();
|
||||||
|
$descPreview.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// AI suggest buttons
|
||||||
|
this.$content.on( 'click', '.btn-ai-suggest', function() {
|
||||||
|
var $btn = $( this );
|
||||||
|
var field = $btn.data( 'field' );
|
||||||
|
var originalHtml = $btn.html();
|
||||||
|
|
||||||
|
$btn.prop( 'disabled', true ).html( '<i class="fa-solid fa-spinner fa-spin"></i>' );
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: '/products/ai_suggest/',
|
||||||
|
type: 'POST',
|
||||||
|
data: { product_id: product_id, field: field, product_url: $productUrl.val() },
|
||||||
|
success: function( response ) {
|
||||||
|
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 );
|
||||||
|
} else if ( field == 'description' ) {
|
||||||
|
$description.val( data.suggestion );
|
||||||
|
} else if ( field == 'category' ) {
|
||||||
|
var catId = data.suggestion.trim();
|
||||||
|
if ( $googleCategory.find( 'option[value="' + catId + '"]' ).length ) {
|
||||||
|
$googleCategory.val( catId ).trigger( 'change' );
|
||||||
|
} else {
|
||||||
|
$.alert({ title: 'AI sugestia', content: 'Sugerowana kategoria: ' + catId, type: 'blue' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( data.warning ) {
|
||||||
|
show_toast( data.warning, 'error' );
|
||||||
|
} else if ( data.page_fetched ) {
|
||||||
|
show_toast( 'Sugestia wygenerowana z treścią strony produktu', 'success' );
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('Select2 nie jest załadowany – pole kategorii nie będzie miało wyszukiwarki.');
|
show_toast( data.message || 'Wystąpił błąd AI.', 'error' );
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function() {
|
||||||
|
$.alert({ title: 'Błąd', content: 'Nie udało się połączyć z API.', type: 'red' });
|
||||||
|
},
|
||||||
|
complete: function() {
|
||||||
|
$btn.prop( 'disabled', false ).html( originalHtml );
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// licznik znaków
|
|
||||||
$inputField.on('input', function () {
|
|
||||||
var currentLength = $(this).val().length;
|
|
||||||
$charCount.text(currentLength + '/150 znaków');
|
|
||||||
|
|
||||||
if (currentLength > 150) {
|
|
||||||
$(this).addClass('is-invalid');
|
|
||||||
} else {
|
|
||||||
$(this).removeClass('is-invalid');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$form.on('submit', function (e) {
|
$form.on( 'submit', function( e ) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
jc.$$formSubmit.trigger('click');
|
jc.$$formSubmit.trigger( 'click' );
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$( 'body' ).on( 'blur', '#bestseller_min_roas', function(){
|
// Zapis min ROAS klienta (bestseller)
|
||||||
|
$( 'body' ).on( 'blur', '#bestseller_min_roas', function()
|
||||||
|
{
|
||||||
var min_roas = $( this ).val();
|
var min_roas = $( this ).val();
|
||||||
var client_id = $( '#client_id' ).val();
|
var client_id = $( '#client_id' ).val();
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: '/products/save_client_bestseller_min_roas/',
|
url: '/products/save_client_bestseller_min_roas/',
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
data: {
|
data: { client_id: client_id, min_roas: min_roas },
|
||||||
client_id: client_id,
|
|
||||||
min_roas: min_roas
|
|
||||||
},
|
|
||||||
success: function( response ) {
|
success: function( response ) {
|
||||||
data = JSON.parse(response);
|
data = JSON.parse( response );
|
||||||
if ( data.status == 'ok' )
|
if ( data.status == 'ok' ) {
|
||||||
{
|
$.alert({ title: 'Zapisano', content: 'Minimalny ROAS bestsellerów został zapisany.', type: 'green', autoClose: 'ok|2000', buttons: { ok: function() {} } });
|
||||||
$.alert( 'Minimalny ROAS bestsellerów został pomyślnie zapisany' );
|
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
$.alert('Błąd: ' + response);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: function() {
|
|
||||||
$.alert('Wystąpił błąd podczas zapisywania minimalnego ROAS. Spróbuj ponownie.');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Funkcja aktualizująca licznik i stan przycisku
|
// Checkbox: zaznacz/odznacz wszystkie
|
||||||
function updateSelectedCount() {
|
function updateSelectedCount() {
|
||||||
var count = $('.product-checkbox:checked').length;
|
var count = $( '.product-checkbox:checked' ).length;
|
||||||
$('#selected-count').text(count);
|
$( '#selected-count' ).text( count );
|
||||||
$('#delete-selected-products').prop('disabled', count === 0);
|
$( '#delete-selected-products' ).prop( 'disabled', count === 0 );
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zaznacz/odznacz wszystkie produkty na bieżącej stronie
|
$( 'body' ).on( 'change', '#select-all-products', function() {
|
||||||
$('body').on('change', '#select-all-products', function() {
|
$( '.product-checkbox' ).prop( 'checked', $( this ).is( ':checked' ) );
|
||||||
var isChecked = $(this).is(':checked');
|
|
||||||
$('.product-checkbox').prop('checked', isChecked);
|
|
||||||
updateSelectedCount();
|
updateSelectedCount();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Obsługa pojedynczych checkboxów
|
$( 'body' ).on( 'change', '.product-checkbox', function() {
|
||||||
$('body').on('change', '.product-checkbox', function() {
|
|
||||||
updateSelectedCount();
|
updateSelectedCount();
|
||||||
// Aktualizuj stan checkboxa "zaznacz wszystkie"
|
var allChecked = $( '.product-checkbox' ).length === $( '.product-checkbox:checked' ).length;
|
||||||
var allChecked = $('.product-checkbox').length === $('.product-checkbox:checked').length;
|
$( '#select-all-products' ).prop( 'checked', allChecked );
|
||||||
$('#select-all-products').prop('checked', allChecked);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset checkboxa "zaznacz wszystkie" przy zmianie strony/sortowaniu
|
$( '#products' ).on( 'draw.dt', function() {
|
||||||
$('#products').on('draw.dt', function() {
|
$( '#select-all-products' ).prop( 'checked', false );
|
||||||
$('#select-all-products').prop('checked', false);
|
|
||||||
updateSelectedCount();
|
updateSelectedCount();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Usuwanie zaznaczonych produktów
|
// Usuwanie zaznaczonych produktów
|
||||||
$('body').on('click', '#delete-selected-products', function() {
|
$( 'body' ).on( 'click', '#delete-selected-products', function()
|
||||||
|
{
|
||||||
var selectedIds = [];
|
var selectedIds = [];
|
||||||
$('.product-checkbox:checked').each(function() {
|
$( '.product-checkbox:checked' ).each( function() { selectedIds.push( $( this ).val() ); });
|
||||||
selectedIds.push($(this).val());
|
|
||||||
});
|
|
||||||
|
|
||||||
if (selectedIds.length === 0) {
|
if ( selectedIds.length === 0 ) {
|
||||||
$.alert('Nie zaznaczono żadnych produktów.');
|
$.alert( 'Nie zaznaczono żadnych produktów.' );
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$.confirm({
|
$.confirm({
|
||||||
title: 'Potwierdzenie',
|
title: 'Potwierdzenie',
|
||||||
content: 'Czy na pewno chcesz usunąć ' + selectedIds.length + ' zaznaczonych produktów?',
|
content: 'Czy na pewno chcesz usunąć ' + selectedIds.length + ' zaznaczonych produktów?',
|
||||||
|
type: 'red',
|
||||||
buttons: {
|
buttons: {
|
||||||
confirm: {
|
confirm: {
|
||||||
text: 'Usuń',
|
text: 'Usuń',
|
||||||
|
btnClass: 'btn-red',
|
||||||
keys: ['enter'],
|
keys: ['enter'],
|
||||||
action: function() {
|
action: function() {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: '/products/delete_products/',
|
url: '/products/delete_products/',
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
data: {
|
data: { product_ids: selectedIds },
|
||||||
product_ids: selectedIds
|
success: function( response ) {
|
||||||
},
|
var data = JSON.parse( response );
|
||||||
success: function(response) {
|
if ( data.status == 'ok' ) {
|
||||||
var data = JSON.parse(response);
|
|
||||||
if (data.status == 'ok') {
|
|
||||||
$.alert({
|
$.alert({
|
||||||
title: 'Sukces',
|
title: 'Sukces',
|
||||||
content: 'Usunięto ' + selectedIds.length + ' produktów.',
|
content: 'Usunięto ' + selectedIds.length + ' produktów.',
|
||||||
|
type: 'green',
|
||||||
autoClose: 'ok|2000',
|
autoClose: 'ok|2000',
|
||||||
buttons: {
|
buttons: { ok: function() {} }
|
||||||
ok: function() {}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
// Odśwież tabelę zachowując bieżącą stronę
|
$( '#products' ).DataTable().ajax.reload( null, false );
|
||||||
var table = $('#products').DataTable();
|
|
||||||
table.ajax.reload(null, false);
|
|
||||||
} else {
|
|
||||||
$.alert('Błąd: ' + data.message);
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
error: function() {
|
|
||||||
$.alert('Wystąpił błąd podczas usuwania produktów. Spróbuj ponownie.');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
cancel: {
|
cancel: { text: 'Anuluj' }
|
||||||
text: 'Anuluj',
|
|
||||||
action: function() {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -4,14 +4,13 @@
|
|||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<meta http-equiv="content-type" content="text/html; charset=utf-8">
|
<meta http-equiv="content-type" content="text/html; charset=utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>crmPro</title>
|
<title>adsPRO</title>
|
||||||
<meta name="keywords" content="">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<meta name="description" content="">
|
|
||||||
<meta name="robots" content="all">
|
|
||||||
<link href="/layout/favicon.png" rel="icon" type="image/x-icon">
|
<link href="/layout/favicon.png" rel="icon" type="image/x-icon">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.18.1/moment.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.18.1/moment.min.js"></script>
|
||||||
<script src="https://cdn.datatables.net/2.1.7/js/dataTables.min.js"></script>
|
<script src="https://cdn.datatables.net/2.1.7/js/dataTables.min.js"></script>
|
||||||
@@ -26,11 +25,9 @@
|
|||||||
<script src="/libraries/select2/js/select2.full.min.js"></script>
|
<script src="/libraries/select2/js/select2.full.min.js"></script>
|
||||||
<script src="/libraries/functions.js"></script>
|
<script src="/libraries/functions.js"></script>
|
||||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/2.1.7/css/dataTables.bootstrap5.min.css">
|
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/2.1.7/css/dataTables.bootstrap5.min.css">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet">
|
||||||
<link rel="Stylesheet" type="text/css" href="/libraries/framework/skin/default_skin/css/theme.css">
|
|
||||||
<link rel="stylesheet" type="text/css" href="/libraries/framework/vendor/plugins/datepicker/css/bootstrap-datetimepicker.css">
|
<link rel="stylesheet" type="text/css" href="/libraries/framework/vendor/plugins/datepicker/css/bootstrap-datetimepicker.css">
|
||||||
<link rel="Stylesheet" type="text/css" href="/libraries/framework/vendor/plugins/daterange/daterangepicker.css">
|
<link rel="stylesheet" type="text/css" href="/libraries/framework/vendor/plugins/daterange/daterangepicker.css">
|
||||||
<link rel="Stylesheet" type="text/css" href="/libraries/framework/admin-tools/admin-forms/css/admin-forms.css">
|
|
||||||
<link rel="stylesheet" type="text/css" href="/libraries/jquery-confirm/jquery-confirm.min.css">
|
<link rel="stylesheet" type="text/css" href="/libraries/jquery-confirm/jquery-confirm.min.css">
|
||||||
<link rel="stylesheet" type="text/css" href="/libraries/select2/css/select2.min.css">
|
<link rel="stylesheet" type="text/css" href="/libraries/select2/css/select2.min.css">
|
||||||
<link rel="stylesheet" type="text/css" href="/libraries/select2/css/select2-bootstrap-5-theme.min.css">
|
<link rel="stylesheet" type="text/css" href="/libraries/select2/css/select2-bootstrap-5-theme.min.css">
|
||||||
@@ -38,71 +35,146 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="logged">
|
<body class="logged">
|
||||||
<div class="top">
|
<?php
|
||||||
<div class="logo">
|
$module = $this -> current_module;
|
||||||
<a href="/">crm<span>Pro</span></a>
|
?>
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar" id="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<div class="sidebar-logo">
|
||||||
|
<a href="/">
|
||||||
|
<span class="logo-text">ads<strong>PRO</strong></span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-nav">
|
<button class="sidebar-toggle" id="sidebarToggle">
|
||||||
<div class="trigger">
|
<i class="fa-solid fa-angles-left"></i>
|
||||||
<i class="fa fa-user"></i> <span><?= $this -> user[ 'email' ];?></span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<nav class="sidebar-nav">
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li class="<?= $module === 'campaigns' ? 'active' : '' ?>">
|
||||||
<a href="/users/settings/">Ustawienia</a>
|
<a href="/campaigns">
|
||||||
|
<i class="fa-solid fa-bullhorn"></i>
|
||||||
|
<span>Kampanie</span>
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li id="divider"></li>
|
<li class="<?= $module === 'products' ? 'active' : '' ?>">
|
||||||
<li>
|
<a href="/products">
|
||||||
<a href="/users/logout/">Wyloguj się</a>
|
<i class="fa-solid fa-box-open"></i>
|
||||||
|
<span>Produkty</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="<?= $module === 'clients' ? 'active' : '' ?>">
|
||||||
|
<a href="/clients">
|
||||||
|
<i class="fa-solid fa-building"></i>
|
||||||
|
<span>Klienci</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="<?= $module === 'allegro' ? 'active' : '' ?>">
|
||||||
|
<a href="/allegro">
|
||||||
|
<i class="fa-solid fa-file-import"></i>
|
||||||
|
<span>Allegro import</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-divider"></li>
|
||||||
|
<li class="<?= $module === 'users' ? 'active' : '' ?>">
|
||||||
|
<a href="/settings">
|
||||||
|
<i class="fa-solid fa-gear"></i>
|
||||||
|
<span>Ustawienia</span>
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="sidebar-user">
|
||||||
|
<div class="user-avatar">
|
||||||
|
<i class="fa-solid fa-user"></i>
|
||||||
|
</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<span class="user-email"><?= $this -> user['email']; ?></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="main-menu">
|
<a href="/logout" class="sidebar-logout" title="Wyloguj się">
|
||||||
<ul>
|
<i class="fa-solid fa-right-from-bracket"></i>
|
||||||
<li>
|
<span>Wyloguj</span>
|
||||||
<a href="/campaigns/main_view/">Kampanie</a>
|
</a>
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="/products/main_view/">Produkty</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="/allegro/main_view/">Allegro import</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="main">
|
</aside>
|
||||||
<? if ( $this -> alert ):?>
|
|
||||||
<div class="alert"><?= $this -> alert;?></div>
|
<!-- Main content -->
|
||||||
<? endif;?>
|
<div class="main-wrapper" id="mainWrapper">
|
||||||
<?= $this -> content;?>
|
<header class="topbar">
|
||||||
|
<button class="topbar-toggle" id="topbarToggle">
|
||||||
|
<i class="fa-solid fa-bars"></i>
|
||||||
|
</button>
|
||||||
|
<div class="topbar-breadcrumb">
|
||||||
|
<?php
|
||||||
|
$breadcrumbs = [
|
||||||
|
'campaigns' => 'Kampanie',
|
||||||
|
'products' => 'Produkty',
|
||||||
|
'clients' => 'Klienci',
|
||||||
|
'allegro' => 'Allegro import',
|
||||||
|
'users' => 'Ustawienia',
|
||||||
|
];
|
||||||
|
echo $breadcrumbs[$module] ?? 'adsPRO';
|
||||||
|
?>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="content">
|
||||||
|
<? if ( $this -> alert ): ?>
|
||||||
|
<div class="app-alert"><?= $this -> alert; ?></div>
|
||||||
|
<? endif; ?>
|
||||||
|
<?= $this -> content; ?>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Popup domyslny -->
|
||||||
<div class="default_popup">
|
<div class="default_popup">
|
||||||
<div class="popup_content">
|
<div class="popup_content">
|
||||||
<div class="popup_header">
|
<div class="popup_header">
|
||||||
<div class="title"></div>
|
<div class="title"></div>
|
||||||
<div class="close"><i class="fa fa-times"></i></div>
|
<div class="close"><i class="fa-solid fa-xmark"></i></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="popup_body"></div>
|
<div class="popup_body"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="text/javascript">$
|
|
||||||
$( function() {
|
<script type="text/javascript">
|
||||||
$( 'input.date' ).datepicker({
|
// Sidebar toggle
|
||||||
|
$(function() {
|
||||||
|
var sidebar = $('#sidebar');
|
||||||
|
var mainWrapper = $('#mainWrapper');
|
||||||
|
var collapsed = localStorage.getItem('sidebar_collapsed') === 'true';
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
|
sidebar.addClass('collapsed');
|
||||||
|
mainWrapper.addClass('expanded');
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#sidebarToggle, #topbarToggle').on('click', function() {
|
||||||
|
sidebar.toggleClass('collapsed');
|
||||||
|
mainWrapper.toggleClass('expanded');
|
||||||
|
localStorage.setItem('sidebar_collapsed', sidebar.hasClass('collapsed'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Datepicker
|
||||||
|
$('input.date').datepicker({
|
||||||
language: 'pl',
|
language: 'pl',
|
||||||
autoClose: true
|
autoClose: true
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
$( 'body' ).on( 'click', '.default_popup .close', function(e) {
|
// Popup close
|
||||||
|
$('body').on('click', '.default_popup .close', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
$( '.default_popup .popup_body' ).empty();
|
$('.default_popup .popup_body').empty();
|
||||||
$( '.default_popup' ).hide();
|
$('.default_popup').hide();
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function show_default_popup( content ) {
|
function show_default_popup(content) {
|
||||||
$( '.default_popup .popup_body' ).html( content );
|
$('.default_popup .popup_body').html(content);
|
||||||
$( '.default_popup' ).fadeIn();
|
$('.default_popup').fadeIn();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -4,18 +4,41 @@
|
|||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<meta http-equiv="content-type" content="text/html; charset=utf-8">
|
<meta http-equiv="content-type" content="text/html; charset=utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>adsPRO</title>
|
<title>adsPRO - Logowanie</title>
|
||||||
<meta name="keywords" content="">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<meta name="description" content="">
|
|
||||||
<meta name="robots" content="all">
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
|
<script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
|
||||||
<link rel="stylesheet" type="text/css" href="/layout/style.css">
|
<link rel="stylesheet" type="text/css" href="/layout/style.css">
|
||||||
<link href="/layout/favicon.png" rel="icon" type="image/x-icon">
|
<link href="/layout/favicon.png" rel="icon" type="image/x-icon">
|
||||||
</head>
|
</head>
|
||||||
<body class="unlogged">
|
<body class="unlogged">
|
||||||
<?= $this -> content;?>
|
<div class="login-container">
|
||||||
|
<div class="login-brand">
|
||||||
|
<div class="brand-content">
|
||||||
|
<div class="brand-logo">ads<strong>PRO</strong></div>
|
||||||
|
<p class="brand-tagline">System zarządzania reklamami<br>Google ADS & Facebook ADS</p>
|
||||||
|
<div class="brand-features">
|
||||||
|
<div class="feature">
|
||||||
|
<i class="fa-solid fa-chart-line"></i>
|
||||||
|
<span>Analiza ROAS i wydajności</span>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<i class="fa-solid fa-bullhorn"></i>
|
||||||
|
<span>Zarządzanie kampaniami</span>
|
||||||
|
</div>
|
||||||
|
<div class="feature">
|
||||||
|
<i class="fa-solid fa-box-open"></i>
|
||||||
|
<span>Optymalizacja produktów</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="login-form-wrapper">
|
||||||
|
<?= $this -> content; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,97 +1,89 @@
|
|||||||
<div class="box box-login">
|
<div class="login-box">
|
||||||
<div class="title"><span>crmPRO</span> | logowanie</div>
|
<div class="login-header">
|
||||||
|
<h1>Witaj ponownie</h1>
|
||||||
|
<p>Zaloguj się do swojego konta</p>
|
||||||
|
</div>
|
||||||
<div class="alert login"></div>
|
<div class="alert login"></div>
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<form method="POST" class="login-form">
|
<form method="POST" class="login-form">
|
||||||
<div class="input-group">
|
<div class="form-group">
|
||||||
<span class="input-group-addon">
|
<label for="email">Email</label>
|
||||||
<i class="fa fa-user-circle-o"></i>
|
<div class="input-with-icon">
|
||||||
</span>
|
<i class="fa-solid fa-envelope"></i>
|
||||||
<div class="form-line">
|
<input type="text" class="form-control" name="email" id="email" placeholder="Wpisz adres email" autofocus>
|
||||||
<input type="text" class="form-control" name="email" id="email" placeholder="Email" autofocus="" >
|
</div>
|
||||||
<p class="form-error hide">Uzupełnij adres email.</p>
|
<p class="form-error hide">Uzupełnij adres email.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Hasło</label>
|
||||||
|
<div class="input-with-icon">
|
||||||
|
<i class="fa-solid fa-lock"></i>
|
||||||
|
<input type="password" class="form-control" id="password" name="password" placeholder="Wpisz hasło">
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group">
|
|
||||||
<span class="input-group-addon"><i class="fa fa-lock"></i></span>
|
|
||||||
<div class="form-line">
|
|
||||||
<input type="password" class="form-control" id="password" name="password" placeholder="Hasło" >
|
|
||||||
<p class="form-error hide">Uzupełnij hasło.</p>
|
<p class="form-error hide">Uzupełnij hasło.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group checkbox-group">
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="remember" name="remember">
|
||||||
|
<span>Zapamiętaj mnie</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group">
|
<button type="button" class="btn btn-primary btn-login">
|
||||||
<div class="form-line checkbox">
|
<span>Zaloguj się</span>
|
||||||
<input type="checkbox" id="remeber" name="remeber">Zapamiętaj mnie
|
<i class="fa-solid fa-arrow-right"></i>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-primary">Zaloguj się</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
$( function()
|
$(function() {
|
||||||
{
|
$('body').on('keypress', '.login-form input', function(e) {
|
||||||
$( 'body' ).on( 'keypress', '.login-form input', function(e)
|
if (e.which === 13)
|
||||||
{
|
|
||||||
if ( e.which === 13 )
|
|
||||||
submit_login_form();
|
submit_login_form();
|
||||||
});
|
});
|
||||||
|
|
||||||
$( 'body' ).on( 'click', '.login-form .btn', function()
|
$('body').on('click', '.btn-login', function() {
|
||||||
{
|
|
||||||
submit_login_form();
|
submit_login_form();
|
||||||
});
|
});
|
||||||
|
|
||||||
function submit_login_form()
|
function submit_login_form() {
|
||||||
{
|
var email = $('#email').val();
|
||||||
var email = $( '#email' ).val();
|
if ($.trim(email) === '') {
|
||||||
if ( $.trim( email ) === '' )
|
$('#email').parents('.form-group').find('.form-error').removeClass('hide');
|
||||||
{
|
$('#email').focus();
|
||||||
$( '#email' ).parents( '.form-line' ).children( 'p' ).removeClass( 'hide' );
|
|
||||||
$( '#email' ).focus();
|
|
||||||
return false;
|
return false;
|
||||||
} else
|
} else
|
||||||
$( '#email' ).parents( '.form-line' ).children( 'p' ).addClass( 'hide' );
|
$('#email').parents('.form-group').find('.form-error').addClass('hide');
|
||||||
|
|
||||||
var password = $( '#password' ).val();
|
var password = $('#password').val();
|
||||||
if ( $.trim( password ) === '' )
|
if ($.trim(password) === '') {
|
||||||
{
|
$('#password').parents('.form-group').find('.form-error').removeClass('hide');
|
||||||
$( '#password' ).parents( '.form-line' ).children( 'p' ).removeClass( 'hide' );
|
$('#password').focus();
|
||||||
$( '#password' ).focus();
|
|
||||||
return false;
|
return false;
|
||||||
} else
|
} else
|
||||||
$( '#password' ).parents( '.form-line' ).children( 'p' ).addClass( 'hide' );
|
$('#password').parents('.form-group').find('.form-error').addClass('hide');
|
||||||
|
|
||||||
$.ajax(
|
$.ajax({
|
||||||
{
|
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
cache: false,
|
cache: false,
|
||||||
url: '/users/login/',
|
url: '/users/login/',
|
||||||
data:
|
data: {
|
||||||
{
|
|
||||||
email: email,
|
email: email,
|
||||||
password: password,
|
password: password,
|
||||||
remember: $( '#remeber' ).is( ':checked' )
|
remember: $('#remember').is(':checked')
|
||||||
},
|
},
|
||||||
beforeSend: function()
|
beforeSend: function() {
|
||||||
{
|
$('.btn-login').html('<i class="fa-solid fa-spinner fa-spin"></i> Logowanie...').addClass('disabled');
|
||||||
$( '.login-form .btn' ).html( '<i class="fa fa-spinner spin"></i>' ).addClass( 'disabled' );
|
|
||||||
},
|
},
|
||||||
success: function( response )
|
success: function(response) {
|
||||||
{
|
$('.btn-login').html('<span>Zaloguj się</span> <i class="fa-solid fa-arrow-right"></i>').removeClass('disabled');
|
||||||
$( '.login-form .btn' ).html( 'Zaloguj się' ).removeClass( 'disabled' );
|
|
||||||
|
|
||||||
data = jQuery.parseJSON( response );
|
data = jQuery.parseJSON(response);
|
||||||
$( '.alert' ).html( data.msg );
|
$('.alert.login').html(data.msg);
|
||||||
|
|
||||||
if ( data.result === 'false' )
|
if (data.result === 'false')
|
||||||
$( '.alert' ).removeClass( 'alert-success' ).addClass( 'alert-danger' ).show();
|
$('.alert.login').removeClass('alert-success').addClass('alert-danger').show();
|
||||||
else
|
else {
|
||||||
{
|
$('.alert.login').removeClass('alert-danger').addClass('alert-success').show();
|
||||||
$( '.alert' ).removeClass( 'alert-danger' ).addClass( 'alert-success' ).show();
|
document.location.href = '/';
|
||||||
document.location.href = '/campaigns/main_view/';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,84 +1,154 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<div class="g-container">
|
<div class="settings-card">
|
||||||
<div class="block-header">
|
<div class="settings-card-header">
|
||||||
<h2>pushover <strong>API</strong></h2>
|
<div class="settings-card-icon"><i class="fa-solid fa-lock"></i></div>
|
||||||
<small class="text-muted">uzupełnił dane potrzebne do wysyłania powiadomień PUSH</small>
|
<div>
|
||||||
|
<h3>Zmiana hasła</h3>
|
||||||
|
<small>Zmień swoje stare hasło na nowe</small>
|
||||||
</div>
|
</div>
|
||||||
<div id="g-form-container">
|
</div>
|
||||||
<form method="POST" id="pushover-settings" class="g-form form-horizontal" action="/users/settings_save/">
|
<form method="POST" id="password-settings" action="/users/password_change/">
|
||||||
<?= \Html::input( [
|
<div class="settings-field">
|
||||||
'label' => 'Pushover API',
|
<label for="password_old">Stare hasło</label>
|
||||||
'name' => 'pushover_api',
|
<div class="settings-input-wrap">
|
||||||
'value' => $this -> user['pushover_api'],
|
<i class="fa-solid fa-key settings-input-icon"></i>
|
||||||
'inline' => false
|
<input type="password" id="password_old" name="password_old" class="form-control" required placeholder="Wprowadź stare hasło" />
|
||||||
]
|
<button type="button" class="settings-toggle-pw" onclick="password_toggle( this, 'password_old' )">
|
||||||
);?>
|
<i class="fa-solid fa-eye"></i>
|
||||||
<?= \Html::input( [
|
</button>
|
||||||
'label' => 'Pushover User',
|
</div>
|
||||||
'name' => 'pushover_user',
|
</div>
|
||||||
'value' => $this -> user['pushover_user'],
|
<div class="settings-field">
|
||||||
'inline' => false
|
<label for="password_new">Nowe hasło</label>
|
||||||
]
|
<div class="settings-input-wrap">
|
||||||
);?>
|
<i class="fa-solid fa-key settings-input-icon"></i>
|
||||||
<?= \Html::button( [
|
<input type="password" id="password_new" name="password_new" class="form-control" required placeholder="Wprowadź nowe hasło" />
|
||||||
'class' => 'btn-success',
|
<button type="button" class="settings-toggle-pw" onclick="password_toggle( this, 'password_new' )">
|
||||||
'text' => 'Zapisz ustawienia',
|
<i class="fa-solid fa-eye"></i>
|
||||||
'icon' => 'fa-check',
|
</button>
|
||||||
'js' => '$( "#pushover-settings" ).submit();'
|
</div>
|
||||||
]
|
</div>
|
||||||
);?>
|
<button type="submit" class="btn btn-success"><i class="fa-solid fa-check mr5"></i>Zmień hasło</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="col-12 col-md-6">
|
|
||||||
<div class="g-container">
|
|
||||||
<div class="block-header">
|
|
||||||
<h2>zmiana <strong>hasła</strong></h2>
|
|
||||||
<small class="text-muted">zmień swoje stare hasło na nowe</small>
|
|
||||||
</div>
|
|
||||||
<div id="g-form-container">
|
|
||||||
<form method="POST" id="password-settings" class="g-form form-horizontal" action="/users/password_change/">
|
|
||||||
<?= \Html::input_icon( [
|
|
||||||
'label' => 'Stare hasło',
|
|
||||||
'name' => 'password_old',
|
|
||||||
'type' => 'password',
|
|
||||||
'inline' => false,
|
|
||||||
'required' => true,
|
|
||||||
'icon_content' => '<i class="fa fa-eye"></i>',
|
|
||||||
'icon_js' => 'password_toggle( $( this ).children( "i" ), "password_old" ); return false;'
|
|
||||||
]
|
|
||||||
);?>
|
|
||||||
<?= \Html::input_icon( [
|
|
||||||
'label' => 'Nowe hasło',
|
|
||||||
'name' => 'password_new',
|
|
||||||
'type' => 'password',
|
|
||||||
'inline' => false,
|
|
||||||
'required' => true,
|
|
||||||
'icon_content' => '<i class="fa fa-eye"></i>',
|
|
||||||
'icon_js' => 'password_toggle( $( this ).children( "i" ), "password_new" ); return false;'
|
|
||||||
]
|
|
||||||
);?>
|
|
||||||
<?= \Html::button( [
|
|
||||||
'class' => 'btn-success',
|
|
||||||
'type' => 'submit',
|
|
||||||
'text' => 'Zmień hasło',
|
|
||||||
'icon' => 'fa-check'
|
|
||||||
]
|
|
||||||
);?>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row" style="margin-top: 25px;">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div class="settings-card-icon"><i class="fa-brands fa-google"></i></div>
|
||||||
|
<div>
|
||||||
|
<h3>Google Ads API</h3>
|
||||||
|
<small>Dane do połączenia z Google Ads REST API (wymagane do synchronizacji kampanii)</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
$last_error = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' );
|
||||||
|
if ( $last_error ): ?>
|
||||||
|
<div class="settings-alert-error">
|
||||||
|
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||||
|
<span><strong>Ostatni błąd API:</strong> <?= htmlspecialchars( $last_error ); ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<form method="POST" id="google-ads-settings" action="/settings/save_google_ads">
|
||||||
|
<div class="settings-fields-grid">
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="google_ads_developer_token">Developer Token</label>
|
||||||
|
<input type="text" id="google_ads_developer_token" name="google_ads_developer_token" class="form-control" value="<?= htmlspecialchars( \services\GoogleAdsApi::get_setting( 'google_ads_developer_token' ) ); ?>" placeholder="np. ABcdEf1234..." />
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="google_ads_client_id">OAuth2 Client ID</label>
|
||||||
|
<input type="text" id="google_ads_client_id" name="google_ads_client_id" class="form-control" value="<?= htmlspecialchars( \services\GoogleAdsApi::get_setting( 'google_ads_client_id' ) ); ?>" placeholder="np. 123456789.apps.googleusercontent.com" />
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="google_ads_client_secret">OAuth2 Client Secret</label>
|
||||||
|
<div class="settings-input-wrap">
|
||||||
|
<input type="password" id="google_ads_client_secret" name="google_ads_client_secret" class="form-control" value="<?= htmlspecialchars( \services\GoogleAdsApi::get_setting( 'google_ads_client_secret' ) ); ?>" />
|
||||||
|
<button type="button" class="settings-toggle-pw" onclick="password_toggle( this, 'google_ads_client_secret' )">
|
||||||
|
<i class="fa-solid fa-eye"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="google_ads_refresh_token">OAuth2 Refresh Token</label>
|
||||||
|
<div class="settings-input-wrap">
|
||||||
|
<input type="password" id="google_ads_refresh_token" name="google_ads_refresh_token" class="form-control" value="<?= htmlspecialchars( \services\GoogleAdsApi::get_setting( 'google_ads_refresh_token' ) ); ?>" />
|
||||||
|
<button type="button" class="settings-toggle-pw" onclick="password_toggle( this, 'google_ads_refresh_token' )">
|
||||||
|
<i class="fa-solid fa-eye"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="google_ads_manager_account_id">Manager Account ID <span class="text-muted">(opcjonalnie, dla MCC)</span></label>
|
||||||
|
<input type="text" id="google_ads_manager_account_id" name="google_ads_manager_account_id" class="form-control" value="<?= htmlspecialchars( \services\GoogleAdsApi::get_setting( 'google_ads_manager_account_id' ) ); ?>" placeholder="np. 123-456-7890" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-success" onclick="$( '#google-ads-settings' ).submit();"><i class="fa-solid fa-check mr5"></i>Zapisz ustawienia Google Ads</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" style="margin-top: 25px;">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div class="settings-card-icon"><i class="fa-solid fa-robot"></i></div>
|
||||||
|
<div>
|
||||||
|
<h3>OpenAI (ChatGPT)</h3>
|
||||||
|
<small>Klucz API i model do optymalizacji tytułów i opisów produktów przez AI</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="POST" id="openai-settings" action="/settings/save_openai">
|
||||||
|
<div class="settings-fields-grid">
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="openai_api_key">API Key</label>
|
||||||
|
<div class="settings-input-wrap">
|
||||||
|
<input type="password" id="openai_api_key" name="openai_api_key" class="form-control" value="<?= htmlspecialchars( \services\GoogleAdsApi::get_setting( 'openai_api_key' ) ); ?>" placeholder="sk-..." />
|
||||||
|
<button type="button" class="settings-toggle-pw" onclick="password_toggle( this, 'openai_api_key' )">
|
||||||
|
<i class="fa-solid fa-eye"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label for="openai_model">Model</label>
|
||||||
|
<?php $current_model = \services\GoogleAdsApi::get_setting( 'openai_model' ) ?: 'gpt-5-mini'; ?>
|
||||||
|
<select id="openai_model" name="openai_model" class="form-control">
|
||||||
|
<option value="gpt-5.2" <?= $current_model === 'gpt-5.2' ? 'selected' : ''; ?>>GPT-5.2 (najnowszy, $1.75/$14 per 1M)</option>
|
||||||
|
<option value="gpt-5-mini" <?= $current_model === 'gpt-5-mini' ? 'selected' : ''; ?>>GPT-5 Mini (szybki, $0.25/$2 per 1M)</option>
|
||||||
|
<option value="gpt-4.1" <?= $current_model === 'gpt-4.1' ? 'selected' : ''; ?>>GPT-4.1</option>
|
||||||
|
<option value="gpt-4.1-mini" <?= $current_model === 'gpt-4.1-mini' ? 'selected' : ''; ?>>GPT-4.1 Mini</option>
|
||||||
|
<option value="gpt-4o" <?= $current_model === 'gpt-4o' ? 'selected' : ''; ?>>GPT-4o (legacy)</option>
|
||||||
|
<option value="gpt-4o-mini" <?= $current_model === 'gpt-4o-mini' ? 'selected' : ''; ?>>GPT-4o Mini (legacy)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-success" onclick="$( '#openai-settings' ).submit();"><i class="fa-solid fa-check mr5"></i>Zapisz ustawienia OpenAI</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
function password_toggle( input, id )
|
function password_toggle( btn, id )
|
||||||
{
|
{
|
||||||
$( input ).toggleClass( 'fa-eye' ).toggleClass( 'fa-eye-slash' );
|
var icon = btn.querySelector( 'i' );
|
||||||
if ( $( '#' + id ).attr( 'type' ) === 'password' )
|
var input = document.getElementById( id );
|
||||||
$( '#' + id ).attr( 'type', 'text' );
|
|
||||||
|
if ( input.type === 'password' )
|
||||||
|
{
|
||||||
|
input.type = 'text';
|
||||||
|
icon.classList.remove( 'fa-eye' );
|
||||||
|
icon.classList.add( 'fa-eye-slash' );
|
||||||
|
}
|
||||||
else
|
else
|
||||||
$( '#' + id ).attr( 'type', 'password' );
|
{
|
||||||
|
input.type = 'password';
|
||||||
|
icon.classList.remove( 'fa-eye-slash' );
|
||||||
|
icon.classList.add( 'fa-eye' );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
Reference in New Issue
Block a user