diff --git a/.claude/settings.local.json b/.claude/settings.local.json index dfc3df7..10c1f82 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,9 @@ "WebFetch(domain:www.storegrowers.com)", "WebFetch(domain:platform.openai.com)", "WebFetch(domain:openai.com)", - "Bash(sass:*)" + "Bash(sass:*)", + "WebFetch(domain:developers.google.com)", + "Bash(cd:*)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6482051 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,99 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +adsPRO is a PHP SaaS application for managing Google Ads campaigns, products, and clients. It integrates with Google Ads API, OpenAI, and Claude AI to provide AI-powered ad optimization. UI language is Polish. + +## Architecture + +Custom lightweight MVC framework with three layers: + +- **Controllers** (`autoload/controls/class.*.php`, namespace `\controls`) - handle requests, return rendered views or JSON +- **Factories** (`autoload/factory/class.*.php`, namespace `\factory`) - data access layer, all static methods, use Medoo ORM +- **Views** (`autoload/view/class.*.php`, namespace `\view`) - compose templates with data +- **Services** (`autoload/services/class.*.php`, namespace `\services`) - external API integrations (GoogleAdsApi, ClaudeApi, OpenAiApi) +- **Templates** (`templates/`) - PHP files, variables accessed via `$this->varName` + +### Autoloading + +PSR-0-like: `\controls\Campaigns` resolves to `autoload/controls/class.Campaigns.php`. + +### Routing + +Entry point: `index.php`. URL `/module/action/key=value` maps to `\controls\Module::action()`. Route aliases defined in `$route_aliases` array. Default route: `campaigns/main_view`. + +### Database + +MySQL via Medoo ORM (`global $mdb`). Common patterns: +```php +$mdb->select('table', '*', ['field' => $value]); +$mdb->get('table', '*', ['id' => $id]); +$mdb->insert('table', ['field' => $value]); +$mdb->update('table', ['field' => $value], ['id' => $id]); +$mdb->query($sql, [':param' => $value])->fetchAll(\PDO::FETCH_ASSOC); +``` + +### Key Utility Classes + +- `\S` - static helpers: `\S::get('param')` (POST/GET), `\S::get_session()`, `\S::set_session()`, `\S::alert()`, `\S::send_email()` +- `\Tpl::view('path/template', ['var' => $data])` - template rendering +- `\Html::input()`, `\Html::select()`, etc. - form component builders +- `\Cache::store()`, `\Cache::fetch()` - file-based caching + +### Authentication + +Session-based with cookie auto-login. User stored in `$_SESSION['user']`. Public paths whitelisted in `index.php`. IP validated per session. + +## Commands + +### Database Migrations + +```bash +# Run migrations (via browser or CLI) +php install.php + +# With demo data +php install.php --with_demo + +# Force re-run all +php install.php --force +``` + +Migration files in `migrations/` follow pattern `NNN_description.sql`. Tracked in `schema_migrations` table (idempotent). + +### SASS Compilation + +VS Code Live Sass Compiler watches `layout/style.scss` and compiles to `layout/style.css` (compressed). + +### Deployment + +Files auto-upload to remote server via VS Code FTP-Kr extension (`.vscode/ftp-kr.json`). No build step required. + +## Code Conventions + +- **PHP style**: Spaces inside parentheses `if ( $x )`, braces on new line, 2-space indent in templates, 4-space in classes +- **Naming**: Classes PascalCase, methods/variables/columns snake_case, namespaces lowercase +- **Static methods**: Controllers and factories use `static public function` +- **JSON endpoints**: `echo json_encode([...]); exit;` +- **Template variables**: passed as array to `\Tpl::view()`, accessed as `$this->varName` + +## Frontend Stack + +jQuery 3.6, DataTables 2.1, Bootstrap 4, Select2 4.1, Highcharts, Font Awesome 6.5, jquery-confirm for modals. All loaded via CDN or from `libraries/`. + +## Entry Points + +| File | Purpose | +|------|---------| +| `index.php` | Main app (routing + auth) | +| `ajax.php` | AJAX requests (authenticated) | +| `api.php` | Public API | +| `cron.php` | Background jobs | +| `install.php` | Database migration runner | +| `config.php` | DB and email credentials | + +## API Settings Storage + +Google Ads, Claude, and OpenAI API keys are stored in the `settings` table (key-value) and managed via the Settings page (`\controls\Users::settings`). diff --git a/autoload/controls/class.Api.php b/autoload/controls/class.Api.php index 572a3b8..25c8f31 100644 --- a/autoload/controls/class.Api.php +++ b/autoload/controls/class.Api.php @@ -1,4 +1,4 @@ - 'ok' ] ); exit; } -} \ No newline at end of file + + static public function products_data_import() + { + global $mdb; + + $json = file_get_contents( 'php://input' ); + $data = json_decode( $json, true ); + + if ( !is_array( $data ) || empty( $data['client_id'] ) || empty( $data['date'] ) || !isset( $data['data'] ) || !is_array( $data['data'] ) ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Nieprawidlowe dane wejsciowe. Oczekiwano: client_id, date, data[].' ] ); + exit; + } + + $client_id = (int) $data['client_id']; + $date = date( 'Y-m-d', strtotime( $data['date'] ) ); + + if ( !$mdb -> count( 'clients', [ 'id' => $client_id ] ) ) + { + echo json_encode( [ 'status' => 'error', 'message' => 'Nie znaleziono klienta o podanym ID.' ] ); + exit; + } + + $processed = 0; + $skipped = 0; + $touched_product_ids = []; + + foreach ( $data['data'] as $offer ) + { + $offer_external_id = trim( (string) ( $offer['OfferId'] ?? '' ) ); + if ( $offer_external_id === '' ) + { + $skipped++; + continue; + } + + $product_title = trim( (string) ( $offer['ProductTitle'] ?? '' ) ); + if ( $product_title === '' ) + { + $product_title = $offer_external_id; + } + + if ( !$mdb -> count( 'products', [ 'AND' => [ 'client_id' => $client_id, 'offer_id' => $offer_external_id ] ] ) ) + { + $mdb -> insert( 'products', [ + 'client_id' => $client_id, + 'offer_id' => $offer_external_id, + 'name' => $product_title + ] ); + $product_id = $mdb -> id(); + } + else + { + $product_id = $mdb -> get( 'products', 'id', [ 'AND' => [ 'client_id' => $client_id, 'offer_id' => $offer_external_id ] ] ); + $offer_current_name = $mdb -> get( 'products', 'name', [ 'AND' => [ 'client_id' => $client_id, 'offer_id' => $offer_external_id ] ] ); + + if ( $offer_current_name != $product_title and $date == date( 'Y-m-d', strtotime( '-1 days', time() ) ) ) + { + $mdb -> update( 'products', [ 'name' => $product_title ], [ 'AND' => [ 'client_id' => $client_id, 'offer_id' => $offer_external_id ] ] ); + } + } + + if ( !$product_id ) + { + $skipped++; + continue; + } + + $impressions = (int) round( self::normalize_number( $offer['Impressions'] ?? 0 ) ); + $clicks = (int) round( self::normalize_number( $offer['Clicks'] ?? 0 ) ); + $cost = self::normalize_number( $offer['Cost'] ?? 0 ); + $conversions = self::normalize_number( $offer['Conversions'] ?? 0 ); + $conversion_value = self::normalize_number( $offer['ConversionValue'] ?? 0 ); + $ctr = ( $impressions > 0 ) ? round( $clicks / $impressions, 4 ) * 100 : 0; + + $offer_data = [ + 'impressions' => $impressions, + 'clicks' => $clicks, + 'ctr' => $ctr, + 'cost' => $cost, + 'conversions' => $conversions, + 'conversions_value' => $conversion_value, + 'updated' => 1 + ]; + + if ( $mdb -> count( 'products_history', [ 'AND' => [ 'product_id' => $product_id, 'date_add' => $date ] ] ) ) + { + $offer_data_old = $mdb -> get( 'products_history', '*', [ 'AND' => [ 'product_id' => $product_id, 'date_add' => $date ] ] ); + + if ( + $offer_data_old['impressions'] == $offer_data['impressions'] + and $offer_data_old['clicks'] == $offer_data['clicks'] + and number_format( (float) str_replace( ',', '.', $offer_data_old['cost'] ), 5 ) == number_format( (float) $offer_data['cost'], 5 ) + and (float) $offer_data_old['conversions'] == (float) $offer_data['conversions'] + and number_format( (float) str_replace( ',', '.', $offer_data_old['conversions_value'] ), 5 ) == number_format( (float) $offer_data['conversions_value'], 5 ) + ) + { + $touched_product_ids[ $product_id ] = true; + $processed++; + continue; + } + + $mdb -> update( 'products_history', $offer_data, [ + 'AND' => [ 'product_id' => $product_id, 'date_add' => $date ] + ] ); + } + else + { + $offer_data['product_id'] = $product_id; + $offer_data['date_add'] = $date; + $mdb -> insert( 'products_history', $offer_data ); + } + + $touched_product_ids[ $product_id ] = true; + $processed++; + } + + $history_30_rows = 0; + foreach ( array_keys( $touched_product_ids ) as $product_id ) + { + \controls\Cron::cron_product_history_30_save( (int) $product_id, $date ); + $mdb -> update( 'products_history', [ 'updated' => 0 ], [ 'AND' => [ 'product_id' => (int) $product_id, 'date_add' => $date ] ] ); + $history_30_rows++; + } + + $temp_rows = self::rebuild_products_temp_for_client( $client_id ); + + echo json_encode( [ + 'status' => 'ok', + 'client_id' => $client_id, + 'date' => $date, + 'processed' => $processed, + 'skipped' => $skipped, + 'history_30_products' => $history_30_rows, + 'products_temp_rows' => $temp_rows + ] ); + exit; + } + + static private function rebuild_products_temp_for_client( $client_id ) + { + global $mdb; + + $client_id = (int) $client_id; + if ( $client_id <= 0 ) + { + return 0; + } + + $product_ids = $mdb -> select( 'products', 'id', [ 'client_id' => $client_id ] ); + if ( empty( $product_ids ) ) + { + return 0; + } + + $mdb -> delete( 'products_temp', [ 'product_id' => $product_ids ] ); + + $rows = $mdb -> query( + 'SELECT p.id AS product_id, p.name, + COALESCE( SUM( ph.impressions ), 0 ) AS impressions, + COALESCE( SUM( ph.clicks ), 0 ) AS clicks, + COALESCE( SUM( ph.cost ), 0 ) AS cost, + COALESCE( SUM( ph.conversions ), 0 ) AS conversions, + COALESCE( SUM( ph.conversions_value ), 0 ) AS conversions_value + FROM products AS p + LEFT JOIN products_history AS ph ON p.id = ph.product_id + WHERE p.client_id = :client_id + GROUP BY p.id, p.name', + [ ':client_id' => $client_id ] + ) -> fetchAll( \PDO::FETCH_ASSOC ); + + $inserted = 0; + foreach ( $rows as $row ) + { + $impressions = (int) $row['impressions']; + $clicks = (int) $row['clicks']; + $cost = (float) $row['cost']; + $conversions = (float) $row['conversions']; + $conversion_value = (float) $row['conversions_value']; + $ctr = ( $impressions > 0 ) ? round( $clicks / $impressions, 4 ) * 100 : 0; + $cpc = ( $clicks > 0 ) ? round( $cost / $clicks, 6 ) : 0; + $roas = ( $cost > 0 ) ? round( $conversion_value / $cost, 2 ) * 100 : 0; + + $mdb -> insert( 'products_temp', [ + 'product_id' => (int) $row['product_id'], + 'name' => $row['name'], + 'impressions' => $impressions, + 'impressions_30' => (int) \factory\Products::get_impressions_30( (int) $row['product_id'] ), + 'clicks' => $clicks, + 'clicks_30' => (int) \factory\Products::get_clicks_30( (int) $row['product_id'] ), + 'ctr' => $ctr, + 'cost' => $cost, + 'conversions' => $conversions, + 'conversions_value' => $conversion_value, + 'cpc' => $cpc, + 'roas' => $roas + ] ); + + $inserted++; + } + + return $inserted; + } + + static private function normalize_number( $value ) + { + if ( $value === null || $value === '' ) + { + return 0.0; + } + + if ( is_int( $value ) || is_float( $value ) ) + { + return (float) $value; + } + + $value = trim( (string) $value ); + if ( $value === '' ) + { + return 0.0; + } + + $value = preg_replace( '/[^\d,.\-]/', '', $value ); + if ( $value === '' || $value === '-' || $value === ',' || $value === '.' ) + { + return 0.0; + } + + $has_comma = strpos( $value, ',' ) !== false; + $has_dot = strpos( $value, '.' ) !== false; + + if ( $has_comma && $has_dot ) + { + $last_comma = strrpos( $value, ',' ); + $last_dot = strrpos( $value, '.' ); + + if ( $last_comma > $last_dot ) + { + $value = str_replace( '.', '', $value ); + $value = str_replace( ',', '.', $value ); + } + else + { + $value = str_replace( ',', '', $value ); + } + } + else + { + $value = str_replace( ',', '.', $value ); + } + + return (float) $value; + } +} diff --git a/autoload/controls/class.CampaignTerms.php b/autoload/controls/class.CampaignTerms.php new file mode 100644 index 0000000..c891eef --- /dev/null +++ b/autoload/controls/class.CampaignTerms.php @@ -0,0 +1,177 @@ + \factory\Campaigns::get_clients(), + ] ); + } + + static public function get_campaigns_list() + { + $client_id = (int) \S::get( 'client_id' ); + echo json_encode( [ 'campaigns' => \factory\Campaigns::get_campaigns_list( $client_id ) ] ); + exit; + } + + static public function get_campaign_ad_groups() + { + $campaign_id = (int) \S::get( 'campaign_id' ); + + if ( $campaign_id <= 0 ) + { + echo json_encode( [ 'ad_groups' => [] ] ); + exit; + } + + echo json_encode( [ 'ad_groups' => \factory\Campaigns::get_campaign_ad_groups( $campaign_id ) ] ); + exit; + } + + static public function get_campaign_phrase_details() + { + $campaign_id = (int) \S::get( 'campaign_id' ); + $ad_group_id = (int) \S::get( 'ad_group_id' ); + + if ( $campaign_id <= 0 ) + { + echo json_encode( [ 'search_terms' => [], 'negative_keywords' => [] ] ); + exit; + } + + echo json_encode( [ + 'search_terms' => \factory\Campaigns::get_campaign_search_terms( $campaign_id, $ad_group_id ), + 'negative_keywords' => \factory\Campaigns::get_campaign_negative_keywords( $campaign_id, $ad_group_id ) + ] ); + exit; + } + + static public function add_negative_keyword() + { + $search_term_id = (int) \S::get( 'search_term_id' ); + $match_type = strtoupper( trim( (string) \S::get( 'match_type' ) ) ); + $scope = strtolower( trim( (string) \S::get( 'scope' ) ) ); + + if ( $search_term_id <= 0 ) + { + echo json_encode( [ 'success' => false, 'message' => 'Nie podano frazy do wykluczenia.' ] ); + exit; + } + + if ( !in_array( $match_type, [ 'PHRASE', 'EXACT', 'BROAD' ], true ) ) + { + $match_type = 'PHRASE'; + } + if ( !in_array( $scope, [ 'campaign', 'ad_group' ], true ) ) + { + $scope = 'campaign'; + } + + $context = \factory\Campaigns::get_search_term_context( $search_term_id ); + if ( !$context ) + { + echo json_encode( [ 'success' => false, 'message' => 'Nie znaleziono danych frazy.' ] ); + exit; + } + + $customer_id = trim( (string) ( $context['google_ads_customer_id'] ?? '' ) ); + $campaign_external_id = trim( (string) ( $context['external_campaign_id'] ?? '' ) ); + $ad_group_external_id = trim( (string) ( $context['external_ad_group_id'] ?? '' ) ); + $keyword_text = trim( (string) ( $context['search_term'] ?? '' ) ); + + $missing_data = ( $customer_id === '' || $keyword_text === '' ); + if ( $scope === 'campaign' && $campaign_external_id === '' ) + { + $missing_data = true; + } + if ( $scope === 'ad_group' && $ad_group_external_id === '' ) + { + $missing_data = true; + } + + if ( $missing_data ) + { + echo json_encode( [ + 'success' => false, + 'message' => 'Brak wymaganych danych Google Ads dla tej frazy.', + 'debug' => [ + 'customer_id' => $customer_id, + 'campaign_external_id' => $campaign_external_id, + 'ad_group_external_id' => $ad_group_external_id, + 'keyword_text' => $keyword_text, + 'scope' => $scope, + 'context' => $context + ] + ] ); + exit; + } + + $api = new \services\GoogleAdsApi(); + if ( !$api -> is_configured() ) + { + echo json_encode( [ 'success' => false, 'message' => 'Google Ads API nie jest skonfigurowane.' ] ); + exit; + } + + if ( $scope === 'campaign' ) + { + $api_result = $api -> add_negative_keyword_to_campaign( $customer_id, $campaign_external_id, $keyword_text, $match_type ); + } + else + { + $api_result = $api -> add_negative_keyword_to_ad_group( $customer_id, $ad_group_external_id, $keyword_text, $match_type ); + } + + if ( !( $api_result['success'] ?? false ) ) + { + $last_error = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ); + echo json_encode( [ + 'success' => false, + 'message' => 'Nie udalo sie zapisac frazy wykluczajacej w Google Ads.', + 'error' => $last_error, + 'debug' => [ + 'customer_id' => $customer_id, + 'campaign_external_id' => $campaign_external_id, + 'ad_group_external_id' => $ad_group_external_id, + 'keyword_text' => $keyword_text, + 'match_type' => $match_type, + 'scope' => $scope, + 'api_result' => $api_result + ] + ] ); + exit; + } + + \factory\Campaigns::upsert_campaign_negative_keyword( + (int) $context['db_campaign_id'], + $scope === 'campaign' ? null : (int) $context['db_ad_group_id'], + $scope, + $keyword_text, + $match_type + ); + + $scope_label = $scope === 'campaign' ? 'kampanii' : 'grupy reklam'; + + echo json_encode( [ + 'success' => true, + 'message' => ( $api_result['duplicate'] ?? false ) ? 'Fraza byla juz wykluczona na poziomie ' . $scope_label . '.' : 'Fraza zostala dodana do wykluczajacych na poziomie ' . $scope_label . '.', + 'duplicate' => (bool) ( $api_result['duplicate'] ?? false ), + 'match_type' => $match_type, + 'scope' => $scope, + 'debug' => [ + 'customer_id' => $customer_id, + 'campaign_external_id' => $campaign_external_id, + 'ad_group_external_id' => $ad_group_external_id, + 'keyword_text' => $keyword_text, + 'scope' => $scope, + 'api_response' => $api_result['response'] ?? null, + 'sent_operation' => $api_result['sent_operation'] ?? null, + 'verification' => $api_result['verification'] ?? null + ] + ] ); + exit; + } +} diff --git a/autoload/controls/class.Campaigns.php b/autoload/controls/class.Campaigns.php index e733863..6d49709 100644 --- a/autoload/controls/class.Campaigns.php +++ b/autoload/controls/class.Campaigns.php @@ -104,6 +104,38 @@ class Campaigns exit; } + static public function get_campaign_ad_groups() + { + $campaign_id = (int) \S::get( 'campaign_id' ); + + if ( $campaign_id <= 0 ) + { + echo json_encode( [ 'ad_groups' => [] ] ); + exit; + } + + echo json_encode( [ 'ad_groups' => \factory\Campaigns::get_campaign_ad_groups( $campaign_id ) ] ); + exit; + } + + static public function get_campaign_phrase_details() + { + $campaign_id = (int) \S::get( 'campaign_id' ); + $ad_group_id = (int) \S::get( 'ad_group_id' ); + + if ( $campaign_id <= 0 ) + { + echo json_encode( [ 'search_terms' => [], 'negative_keywords' => [] ] ); + exit; + } + + echo json_encode( [ + 'search_terms' => \factory\Campaigns::get_campaign_search_terms( $campaign_id, $ad_group_id ), + 'negative_keywords' => \factory\Campaigns::get_campaign_negative_keywords( $campaign_id, $ad_group_id ) + ] ); + exit; + } + static public function delete_campaign() { $campaign_id = \S::get( 'campaign_id' ); diff --git a/autoload/controls/class.Cron.php b/autoload/controls/class.Cron.php index 06d60bc..919ac1a 100644 --- a/autoload/controls/class.Cron.php +++ b/autoload/controls/class.Cron.php @@ -6,35 +6,400 @@ class Cron { global $mdb; - if ( !$client_id = \S::get( 'client_id' ) ) + $api = new \services\GoogleAdsApi(); + if ( !$api -> is_configured() ) { - echo "Nie podano ID klienta."; + echo json_encode( [ 'result' => 'Google Ads API nie jest skonfigurowane. Uzupelnij dane w Ustawieniach.' ] ); exit; } - // check if client exists - if ( !$mdb -> count( 'clients', [ 'id' => $client_id ] ) ) + $date = \S::get( 'date' ) ? date( 'Y-m-d', strtotime( \S::get( 'date' ) ) ) : date( 'Y-m-d', strtotime( '-1 days' ) ); + $client_id = (int) \S::get( 'client_id' ); + + if ( $client_id > 0 ) { - echo json_encode( [ 'result' => "Nie znaleziono klienta o podanym ID.", "client" => "Nie istnieje" ] ); + $client = $mdb -> get( 'clients', '*', [ 'AND' => [ + 'id' => $client_id, + 'google_ads_customer_id[!]' => null, + 'deleted' => 0 + ] ] ); + + if ( !$client || trim( (string) $client['google_ads_customer_id'] ) === '' ) + { + echo json_encode( [ 'result' => 'Nie znaleziono klienta z poprawnym Google Ads Customer ID.', 'client_id' => $client_id ] ); + exit; + } + + $sync = self::sync_products_fetch_for_client( $client, $api, $date ); + $history_30 = self::aggregate_products_history_30_for_client( (int) $client['id'], $date ); + $temp_rows = self::rebuild_products_temp_for_client( (int) $client['id'] ); + + echo json_encode( [ + 'result' => empty( $sync['errors'] ) ? 'Synchronizacja produktow zakonczona.' : 'Synchronizacja produktow zakonczona z bledami.', + 'client_id' => (int) $client['id'], + 'date' => $date, + 'phase' => 'single_full', + 'processed_products' => (int) $sync['processed_products'], + 'skipped' => (int) $sync['skipped'], + 'history_30_products' => (int) $history_30, + 'products_temp_rows' => (int) $temp_rows, + 'errors' => $sync['errors'] + ] ); exit; } + $client_ids = $mdb -> query( "SELECT id FROM clients WHERE deleted = 0 AND google_ads_customer_id IS NOT NULL AND google_ads_customer_id <> '' ORDER BY id ASC" ) -> fetchAll( \PDO::FETCH_COLUMN ); + $client_ids = array_values( array_unique( array_map( 'intval', $client_ids ) ) ); + + if ( empty( $client_ids ) ) + { + echo json_encode( [ 'result' => 'Brak klientow z ustawionym Google Ads Customer ID do przetworzenia.', 'processed_clients' => 0, 'errors' => [] ] ); + exit; + } + + $state_key = 'cron_products_pipeline_state'; + $state = self::get_products_pipeline_state( $state_key, $date, $client_ids ); + + if ( $state['phase'] === 'done' ) + { + echo json_encode( [ + 'result' => 'Pipeline cron_products jest zakonczony dla tej daty.', + 'date' => $state['import_date'], + 'phase' => 'done', + 'total_clients' => count( $client_ids ) + ] ); + exit; + } + + $phase_map = [ + 'fetch' => 'fetch_done_ids', + 'aggregate_30' => 'aggregate_30_done_ids', + 'aggregate_temp' => 'aggregate_temp_done_ids' + ]; + + $done_key = $phase_map[ $state['phase'] ] ?? null; + if ( !$done_key ) + { + $state = self::init_products_pipeline_state( $date, $client_ids ); + $done_key = 'fetch_done_ids'; + } + + $next_client_id = self::pick_next_client_id( $client_ids, $state[ $done_key ] ); + + if ( !$next_client_id ) + { + $state = self::advance_products_pipeline_phase( $state ); + self::save_products_pipeline_state( $state_key, $state ); + + echo json_encode( [ + 'result' => 'Faza zakonczona. Przejdz do nastepnej fazy kolejnym wywolaniem.', + 'date' => $state['import_date'], + 'phase' => $state['phase'], + 'total_clients' => count( $client_ids ) + ] ); + exit; + } + + $selected_client = $mdb -> get( 'clients', '*', [ 'id' => $next_client_id ] ); + if ( !$selected_client ) + { + echo json_encode( [ 'result' => 'Nie udalo sie wybrac klienta do synchronizacji produktow.', 'client_id' => $next_client_id ] ); + exit; + } + + $response = [ + 'result' => '', + 'date' => $state['import_date'], + 'phase' => $state['phase'], + 'client_id' => (int) $selected_client['id'], + 'errors' => [] + ]; + + if ( $state['phase'] === 'fetch' ) + { + $sync = self::sync_products_fetch_for_client( $selected_client, $api, $state['import_date'] ); + $response['result'] = empty( $sync['errors'] ) ? 'Pobieranie produktow zakonczone.' : 'Pobieranie produktow zakonczone z bledami.'; + $response['processed_products'] = (int) $sync['processed_products']; + $response['skipped'] = (int) $sync['skipped']; + $response['errors'] = $sync['errors']; + } + else if ( $state['phase'] === 'aggregate_30' ) + { + $history_30 = self::aggregate_products_history_30_for_client( (int) $selected_client['id'], $state['import_date'] ); + $response['result'] = 'Pierwsza agregacja (history_30) zakonczona.'; + $response['history_30_products'] = (int) $history_30; + } + else if ( $state['phase'] === 'aggregate_temp' ) + { + $temp_rows = self::rebuild_products_temp_for_client( (int) $selected_client['id'] ); + $response['result'] = 'Druga agregacja (products_temp) zakonczona.'; + $response['products_temp_rows'] = (int) $temp_rows; + } + + // Oznaczamy klienta jako przetworzonego rowniez po bledzie, aby nie zapetlac wywolan. + $state[ $done_key ][] = (int) $next_client_id; + $state[ $done_key ] = array_values( array_unique( array_map( 'intval', $state[ $done_key ] ) ) ); + self::save_products_pipeline_state( $state_key, $state ); + + $processed_in_phase = count( array_intersect( $client_ids, $state[ $done_key ] ) ); + + $response['processed_clients_in_phase'] = $processed_in_phase; + $response['remaining_clients_in_phase'] = max( 0, count( $client_ids ) - $processed_in_phase ); + $response['total_clients'] = count( $client_ids ); + + echo json_encode( $response ); + exit; + } + + static private function init_products_pipeline_state( $date, $client_ids ) + { + return [ + 'import_date' => date( 'Y-m-d', strtotime( $date ) ), + 'clients_hash' => md5( implode( ',', $client_ids ) ), + 'phase' => 'fetch', + 'fetch_done_ids' => [], + 'aggregate_30_done_ids' => [], + 'aggregate_temp_done_ids' => [] + ]; + } + + static private function get_products_pipeline_state( $state_key, $date, $client_ids ) + { + $state_raw = self::get_setting_value( $state_key, '' ); + $state = json_decode( (string) $state_raw, true ); + + $expected_date = date( 'Y-m-d', strtotime( $date ) ); + $expected_hash = md5( implode( ',', $client_ids ) ); + + if ( !is_array( $state ) ) + { + return self::init_products_pipeline_state( $expected_date, $client_ids ); + } + + if ( ( $state['import_date'] ?? '' ) !== $expected_date || ( $state['clients_hash'] ?? '' ) !== $expected_hash ) + { + return self::init_products_pipeline_state( $expected_date, $client_ids ); + } + + foreach ( [ 'fetch_done_ids', 'aggregate_30_done_ids', 'aggregate_temp_done_ids' ] as $key ) + { + if ( !isset( $state[ $key ] ) || !is_array( $state[ $key ] ) ) + { + $state[ $key ] = []; + } + $state[ $key ] = array_values( array_unique( array_map( 'intval', $state[ $key ] ) ) ); + } + + if ( !isset( $state['phase'] ) ) + { + $state['phase'] = 'fetch'; + } + + return $state; + } + + static private function save_products_pipeline_state( $state_key, $state ) + { + self::set_setting_value( $state_key, json_encode( $state, JSON_UNESCAPED_UNICODE ) ); + } + + static private function advance_products_pipeline_phase( $state ) + { + if ( $state['phase'] === 'fetch' ) + { + $state['phase'] = 'aggregate_30'; + return $state; + } + + if ( $state['phase'] === 'aggregate_30' ) + { + $state['phase'] = 'aggregate_temp'; + return $state; + } + + $state['phase'] = 'done'; + return $state; + } + + static private function sync_products_fetch_for_client( $client, $api, $date ) + { + global $mdb; + + $client_id = (int) $client['id']; + $customer_id = trim( (string) ( $client['google_ads_customer_id'] ?? '' ) ); + $date = date( 'Y-m-d', strtotime( $date ) ); + + $products = $api -> get_products_for_date( $customer_id, $date ); + if ( $products === false ) + { + $last_err = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ); + return [ + 'date' => $date, + 'processed_products' => 0, + 'skipped' => 0, + 'history_30_products' => 0, + 'products_temp_rows' => 0, + 'errors' => [ 'Blad API dla klienta ' . $client['name'] . ' (ID: ' . $customer_id . '): ' . $last_err ] + ]; + } + + if ( !is_array( $products ) ) + { + $products = []; + } + + $processed = 0; + $skipped = 0; + $touched_product_ids = []; + + foreach ( $products as $offer ) + { + $offer_external_id = trim( (string) ( $offer['OfferId'] ?? '' ) ); + if ( $offer_external_id === '' ) + { + $skipped++; + continue; + } + + $product_title = trim( (string) ( $offer['ProductTitle'] ?? '' ) ); + if ( $product_title === '' ) + { + $product_title = $offer_external_id; + } + + if ( !$mdb -> count( 'products', [ 'AND' => [ 'client_id' => $client_id, 'offer_id' => $offer_external_id ] ] ) ) + { + $mdb -> insert( 'products', [ + 'client_id' => $client_id, + 'offer_id' => $offer_external_id, + 'name' => $product_title + ] ); + $product_id = $mdb -> id(); + } + else + { + $product_id = $mdb -> get( 'products', 'id', [ 'AND' => [ 'client_id' => $client_id, 'offer_id' => $offer_external_id ] ] ); + $offer_current_name = $mdb -> get( 'products', 'name', [ 'AND' => [ 'client_id' => $client_id, 'offer_id' => $offer_external_id ] ] ); + + if ( $offer_current_name != $product_title and $date == date( 'Y-m-d', strtotime( '-1 days' ) ) ) + { + $mdb -> update( 'products', [ 'name' => $product_title ], [ 'AND' => [ 'client_id' => $client_id, 'offer_id' => $offer_external_id ] ] ); + } + } + + if ( !$product_id ) + { + $skipped++; + continue; + } + + $impressions = (int) round( (float) ( $offer['Impressions'] ?? 0 ) ); + $clicks = (int) round( (float) ( $offer['Clicks'] ?? 0 ) ); + $cost = (float) ( $offer['Cost'] ?? 0 ); + $conversions = (float) ( $offer['Conversions'] ?? 0 ); + $conversion_value = (float) ( $offer['ConversionValue'] ?? 0 ); + $ctr = ( $impressions > 0 ) ? round( $clicks / $impressions, 4 ) * 100 : 0; + + $offer_data = [ + 'impressions' => $impressions, + 'clicks' => $clicks, + 'ctr' => $ctr, + 'cost' => $cost, + 'conversions' => $conversions, + 'conversions_value' => $conversion_value, + 'updated' => 1 + ]; + + if ( $mdb -> count( 'products_history', [ 'AND' => [ 'product_id' => $product_id, 'date_add' => $date ] ] ) ) + { + $offer_data_old = $mdb -> get( 'products_history', '*', [ 'AND' => [ 'product_id' => $product_id, 'date_add' => $date ] ] ); + + if ( + $offer_data_old['impressions'] == $offer_data['impressions'] + and $offer_data_old['clicks'] == $offer_data['clicks'] + and number_format( (float) str_replace( ',', '.', $offer_data_old['cost'] ), 5 ) == number_format( (float) $offer_data['cost'], 5 ) + and (float) $offer_data_old['conversions'] == (float) $offer_data['conversions'] + and number_format( (float) str_replace( ',', '.', $offer_data_old['conversions_value'] ), 5 ) == number_format( (float) $offer_data['conversions_value'], 5 ) + ) + { + $touched_product_ids[ $product_id ] = true; + $processed++; + continue; + } + + $mdb -> update( 'products_history', $offer_data, [ + 'AND' => [ 'product_id' => $product_id, 'date_add' => $date ] + ] ); + } + else + { + $offer_data['product_id'] = $product_id; + $offer_data['date_add'] = $date; + $mdb -> insert( 'products_history', $offer_data ); + } + + $touched_product_ids[ $product_id ] = true; + $processed++; + } + + return [ + 'date' => $date, + 'processed_products' => $processed, + 'skipped' => $skipped, + 'touched_products' => count( $touched_product_ids ), + 'errors' => [] + ]; + } + + static private function aggregate_products_history_30_for_client( $client_id, $date ) + { + global $mdb; + + $client_id = (int) $client_id; + $date = date( 'Y-m-d', strtotime( $date ) ); + + $rows = $mdb -> query( + 'SELECT ph.id, ph.product_id, ph.date_add + FROM products_history AS ph + INNER JOIN products AS p ON p.id = ph.product_id + WHERE p.client_id = ' . $client_id . ' + AND ph.updated = 1 + AND ph.date_add = \'' . $date . '\' + ORDER BY ph.product_id ASC' + ) -> fetchAll( \PDO::FETCH_ASSOC ); + + $processed = 0; + foreach ( $rows as $row ) + { + $product_id = (int) $row['product_id']; + self::cron_product_history_30_save( $product_id, $row['date_add'] ); + $mdb -> update( 'products_history', [ 'updated' => 0 ], [ 'id' => (int) $row['id'] ] ); + $processed++; + } + + return $processed; + } + + static private function rebuild_products_temp_for_client( $client_id ) + { + global $mdb; + $client_bestseller_min_roas = \factory\Products::get_client_bestseller_min_roas( $client_id ); - $db_result = $mdb -> query( 'SELECT * FROM products AS p INNER JOIN products_history AS ph ON p.id = ph.product_id WHERE p.client_id = ' . $client_id ) -> fetchAll( \PDO::FETCH_ASSOC ); + $db_result = $mdb -> query( 'SELECT * FROM products AS p INNER JOIN products_history AS ph ON p.id = ph.product_id WHERE p.client_id = ' . (int) $client_id ) -> fetchAll( \PDO::FETCH_ASSOC ); $aggregated_data = []; foreach ( $db_result as $row ) { - $product_id = $row['product_id']; + $product_id = (int) $row['product_id']; if ( !isset( $aggregated_data[$client_id] ) ) { $aggregated_data[$client_id] = []; } - if (!isset($aggregated_data[$client_id][$product_id])) + if ( !isset( $aggregated_data[$client_id][$product_id] ) ) { $aggregated_data[$client_id][$product_id] = [ 'product_id' => $product_id, @@ -47,34 +412,40 @@ class Cron ]; } - $aggregated_data[$client_id][$product_id]['impressions'] += $row['impressions']; - $aggregated_data[$client_id][$product_id]['clicks'] += $row['clicks']; - $aggregated_data[$client_id][$product_id]['cost'] += $row['cost']; - $aggregated_data[$client_id][$product_id]['conversions'] += $row['conversions']; - $aggregated_data[$client_id][$product_id]['conversions_value'] += $row['conversions_value']; + $aggregated_data[$client_id][$product_id]['impressions'] += (int) $row['impressions']; + $aggregated_data[$client_id][$product_id]['clicks'] += (int) $row['clicks']; + $aggregated_data[$client_id][$product_id]['cost'] += (float) $row['cost']; + $aggregated_data[$client_id][$product_id]['conversions'] += (float) $row['conversions']; + $aggregated_data[$client_id][$product_id]['conversions_value'] += (float) $row['conversions_value']; } - $products_ids = $mdb -> select( 'products', 'id', [ 'client_id' => $client_id ] ); + $products_ids = $mdb -> select( 'products', 'id', [ 'client_id' => (int) $client_id ] ); + $products_ids_array = []; foreach ( $products_ids as $product_id ) { - $products_ids_array[] = $product_id; + $products_ids_array[] = (int) $product_id; } - $mdb -> delete( 'products_temp', [ 'product_id' => $products_ids_array ] ); + if ( !empty( $products_ids_array ) ) + { + $mdb -> delete( 'products_temp', [ 'product_id' => $products_ids_array ] ); + } + + $processed_rows = 0; foreach ( $aggregated_data as $client_offers ) { foreach ( $client_offers as $offer_data ) { - // Obliczamy wartości CPC oraz conversions_price + // Obliczamy wartoci CPC oraz ROAS $cpc = $offer_data['clicks'] > 0 ? round( $offer_data['cost'] / $offer_data['clicks'], 6 ) : 0; - $roas = ($offer_data['conversions'] > 0 and $offer_data['cost']) ? round($offer_data['conversions_value'] / $offer_data['cost'], 2) * 100 : 0; + $roas = ( $offer_data['conversions'] > 0 and $offer_data['cost'] ) ? round( $offer_data['conversions_value'] / $offer_data['cost'], 2 ) * 100 : 0; $impressions_30 = \factory\Products::get_impressions_30( $offer_data['product_id'] ); // update custom_label_4 only current is empty or is bestseller $custom_label_4 = \factory\Products::get_product_data( $offer_data['product_id'], 'custom_label_4' ); - if ( $custom_label_4 == null || $custom_label_4 == 'bestseller' and (int)$client_bestseller_min_roas > 0 ) + if ( $custom_label_4 == null || ( $custom_label_4 == 'bestseller' and (int)$client_bestseller_min_roas > 0 ) ) { if ( $roas > $client_bestseller_min_roas and $offer_data['conversions'] > 10 ) { @@ -119,45 +490,6 @@ class Cron } } - // if ( $impressions_30 <= 30 ) - // $custom_label_3 = 'product_zombie'; - // else - // $custom_label_3 = null; - - // $offers_data_tmp = $mdb -> get( 'products_data', '*', [ 'product_id' => $offer_data['product_id'] ] ); - // if ( isset( $offers_data_tmp['id'] ) ) - // { - // if ( $custom_label_3 != $offers_data_tmp['custom_label_3'] ) - // $mdb -> insert( 'products_comments', [ - // 'product_id' => $offer_data['product_id'], - // 'comment' => 'Zmiana pola "custom_label_3" na: ' . $custom_label_3, - // 'type' => 1, - // 'date_add' => date( 'Y-m-d' ) - // ] ); - - // $mdb -> update( 'products_data', [ - // 'custom_label_3' => $custom_label_3 - // ], [ 'id' => $offers_data_tmp['id'] ] ); - // } - // else - // { - // $mdb -> insert( 'products_data', [ - // 'product_id' => $offer_data['product_id'], - // 'custom_label_3' => $custom_label_3 - // ] ); - - // if ( $custom_label_3 == 'product_zombie' ) - // { - // $mdb -> insert( 'products_comments', [ - // 'product_id' => $offer_data['product_id'], - // 'comment' => 'Zmiana pola "custom_label_3" na: product_zombie', - // 'type' => 1, - // 'date_add' => date( 'Y-m-d' ) - // ] ); - // } - // } - - // Zapisujemy każdy zsumowany wpis do offers_temp $clicks_30 = \factory\Products::get_clicks_30( $offer_data['product_id'] ); $mdb -> insert( 'products_temp', [ @@ -167,18 +499,19 @@ class Cron 'impressions_30' => $impressions_30, 'clicks' => $offer_data['clicks'], 'clicks_30' => $clicks_30, - 'ctr' => round( $offer_data['clicks'] / $offer_data['impressions'], 4 ) * 100, + 'ctr' => ( $offer_data['impressions'] > 0 ) ? round( $offer_data['clicks'] / $offer_data['impressions'], 4 ) * 100 : 0, 'cost' => $offer_data['cost'], 'conversions' => $offer_data['conversions'], 'conversions_value' => $offer_data['conversions_value'], 'cpc' => $cpc, 'roas' => $roas, - ]); + ] ); + + $processed_rows++; } } - echo json_encode( [ 'result' => "Agregacja zakończona, dane zapisane do offers_temp." ] ); - exit; + return $processed_rows; } static public function cron_products_history_30() @@ -214,7 +547,7 @@ class Cron $end_time = microtime(true); $execution_time = $end_time - $start_time; - echo json_encode( [ 'result' => "Agregacja zakończona, dane zapisane do offers_history_30. Czas wykonania skryptu: " . round($execution_time, 4) . " sekund." ] ); + echo json_encode( [ 'result' => "Agregacja zakonczona, dane zapisane do offers_history_30. Czas wykonania skryptu: " . round($execution_time, 4) . " sekund." ] ); exit; } @@ -235,7 +568,7 @@ class Cron // Inicjalizacja tablic do przechowywania danych $offers_data = []; - // Grupowanie danych według produktów + // Grupowanie danych wedug produktow foreach ( $data as $entry ) { if ( !isset( $offers_data[$product_id] ) ) @@ -251,7 +584,7 @@ class Cron ]; } - // Sumowanie danych według produktu + // Sumowanie danych wedug produktu $offers_data[$product_id]['impressions'] += $entry['impressions']; $offers_data[$product_id]['clicks'] += $entry['clicks']; $offers_data[$product_id]['cost'] += $entry['cost']; @@ -327,7 +660,7 @@ class Cron // if empty results if ( empty( $results ) ) { - echo json_encode( [ 'result' => "Brak produktów do wygenerowania pliku XML." ] ); + echo json_encode( [ 'result' => "Brak produktow do wygenerowania pliku XML." ] ); exit; } @@ -377,7 +710,7 @@ class Cron file_put_contents('xml/custom-feed-' . $_GET['client_id'] . '.xml', $doc->saveXML()); - echo json_encode( [ 'result' => "Plik XML został wygenerowany https://adspro.projectpro.pl/xml/custom-feed-" . $_GET['client_id'] . ".xml." ] ); + echo json_encode( [ 'result' => "Plik XML zostal wygenerowany https://adspro.projectpro.pl/xml/custom-feed-" . $_GET['client_id'] . ".xml." ] ); exit; } @@ -459,7 +792,7 @@ class Cron } } - echo json_encode( [ 'result' => "Agregacja zakończona, dane zapisane do phrases_temp." ] ); + echo json_encode( [ 'result' => "Agregacja zakonczona, dane zapisane do phrases_temp." ] ); exit; } @@ -467,23 +800,23 @@ class Cron { global $mdb; - $start_time = microtime( true ); // Rozpoczęcie mierzenia czasu + $start_time = microtime( true ); // Rozpoczcie mierzenia czasu $client_id = \S::get( 'client_id' ); // Pobranie ID klienta - if ( !$client_id ) // Jeśli nie podano ID klienta + if ( !$client_id ) // Jeli nie podano ID klienta { - echo json_encode( [ 'result' => "Nie podano ID klienta." ] ); // Wyświetlenie komunikatu - exit; // Zakończenie działania skryptu + echo json_encode( [ 'result' => "Nie podano ID klienta." ] ); // Wyswietlenie komunikatu + exit; // Zakonczenie dziaania skryptu } if ( !$mdb -> count( 'clients', [ 'id' => $client_id ] ) ) // Sprawdzenie, czy klient istnieje { - echo json_encode( [ 'result' => "Nie znaleziono klienta o podanym ID.", "client" => "Nie istnieje" ] ); // Wyświetlenie komunikatu - exit; // Zakończenie działania skryptu + echo json_encode( [ 'result' => "Nie znaleziono klienta o podanym ID.", "client" => "Nie istnieje" ] ); // Wyswietlenie komunikatu + exit; // Zakonczenie dziaania skryptu } - // Pobranie bieżącej daty i daty sprzed 30 dni + // Pobranie biecej daty i daty sprzed 30 dni $phrases = $mdb -> query( 'SELECT * FROM phrases WHERE client_id = ' . $client_id ) -> fetchAll( \PDO::FETCH_ASSOC ); // Pobranie fraz dla danego klienta foreach ( $phrases as $phrase ) @@ -509,10 +842,10 @@ class Cron $mdb -> update( 'phrases_history', [ 'updated' => 0 ], [ 'AND' => [ 'phrase_id' => $phrase['id'], 'updated' => 1 ] ] ); } - $end_time = microtime( true ); // Zakończenie mierzenia czasu + $end_time = microtime( true ); // Zakonczenie mierzenia czasu $execution_time = $end_time - $start_time; // Obliczenie czasu wykonania - echo json_encode( [ 'result' => "Agregacja zakończona, dane zapisane do phrases_history_30. Czas wykonania skryptu: " . round( $execution_time, 4 ) . " sekund.", 'client' => \factory\Campaigns::get_client_name( $client_id ) ] ); // Wyświetlenie komunikatu + echo json_encode( [ 'result' => "Agregacja zakonczona, dane zapisane do phrases_history_30. Czas wykonania skryptu: " . round( $execution_time, 4 ) . " sekund.", 'client' => \factory\Campaigns::get_client_name( $client_id ) ] ); // Wyswietlenie komunikatu exit; } @@ -532,9 +865,42 @@ class Cron exit; } - // Pobierz klientów z ustawionym Google Ads Customer ID + $client_id = (int) \S::get( 'client_id' ); + + if ( $client_id > 0 ) + { + $client = $mdb -> get( 'clients', '*', [ 'AND' => [ + 'id' => $client_id, + 'google_ads_customer_id[!]' => null, + 'deleted' => 0 + ] ] ); + + if ( !$client ) + { + echo json_encode( [ 'result' => 'Nie znaleziono klienta z poprawnym Google Ads Customer ID.', 'client_id' => $client_id ] ); + exit; + } + + $sync = self::sync_campaigns_for_client( $client, $api ); + + echo json_encode( [ + 'result' => empty( $sync['errors'] ) ? 'Synchronizacja kampanii zakonczona.' : 'Synchronizacja kampanii zakonczona z bledami.', + 'client_id' => (int) $client['id'], + 'processed_records' => (int) $sync['processed_records'], + 'ad_groups_synced' => (int) ( $sync['ad_groups_synced'] ?? 0 ), + 'search_terms_synced' => (int) ( $sync['search_terms_synced'] ?? 0 ), + 'negative_keywords_synced' => (int) ( $sync['negative_keywords_synced'] ?? 0 ), + 'errors' => $sync['errors'] + ] ); + exit; + } + $clients = $mdb -> select( 'clients', '*', [ - 'google_ads_customer_id[!]' => null + 'AND' => [ + 'google_ads_customer_id[!]' => null, + 'deleted' => 0 + ], + 'ORDER' => [ 'id' => 'ASC' ] ] ); if ( empty( $clients ) ) @@ -543,105 +909,677 @@ class Cron exit; } - $today = date( 'Y-m-d' ); - $processed = 0; - $errors = []; + $client_ids = []; + $clients_map = []; 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 ) + $cid = (int) $client['id']; + if ( $cid <= 0 ) { - $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 ) ) + $client_ids[] = $cid; + $clients_map[$cid] = $client; + } + + $client_ids = array_values( array_unique( $client_ids ) ); + + $state_key = 'cron_campaigns_state'; + $state = self::get_daily_cron_state( $state_key ); + $next_client_id = self::pick_next_client_id( $client_ids, $state['processed_ids'] ); + + if ( !$next_client_id ) + { + echo json_encode( [ + 'result' => 'Wszyscy klienci kampanii zostali juz dzis przetworzeni.', + 'date' => $state['date'], + 'processed_clients' => count( array_intersect( $client_ids, $state['processed_ids'] ) ), + 'total_clients' => count( $client_ids ) + ] ); + exit; + } + + $selected_client = $clients_map[$next_client_id] ?? null; + if ( !$selected_client ) + { + echo json_encode( [ 'result' => 'Nie udalo sie wybrac klienta do synchronizacji kampanii.', 'client_id' => $next_client_id ] ); + exit; + } + + $sync = self::sync_campaigns_for_client( $selected_client, $api ); + + // Oznaczamy klienta jako przetworzonego rowniez po bledzie, aby nie zapetlac wywolan. + $state['processed_ids'][] = (int) $next_client_id; + $state['processed_ids'] = array_values( array_unique( array_map( 'intval', $state['processed_ids'] ) ) ); + self::save_daily_cron_state( $state_key, $state ); + + $processed_today = count( array_intersect( $client_ids, $state['processed_ids'] ) ); + + echo json_encode( [ + 'result' => empty( $sync['errors'] ) ? 'Synchronizacja kampanii zakonczona.' : 'Synchronizacja kampanii zakonczona z bledami.', + 'client_id' => $next_client_id, + 'processed_records' => (int) $sync['processed_records'], + 'ad_groups_synced' => (int) ( $sync['ad_groups_synced'] ?? 0 ), + 'search_terms_synced' => (int) ( $sync['search_terms_synced'] ?? 0 ), + 'negative_keywords_synced' => (int) ( $sync['negative_keywords_synced'] ?? 0 ), + 'processed_clients_today' => $processed_today, + 'remaining_clients_today' => max( 0, count( $client_ids ) - $processed_today ), + 'errors' => $sync['errors'] + ] ); + exit; + } + + static private function sync_campaigns_for_client( $client, $api ) + { + global $mdb; + + $today = date( 'Y-m-d' ); + $processed = 0; + $errors = []; + $customer_id = $client['google_ads_customer_id']; + $campaigns_db_map = []; + + $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; + + return [ + 'processed_records' => 0, + 'ad_groups_synced' => 0, + 'search_terms_synced' => 0, + 'negative_keywords_synced' => 0, + 'errors' => $errors + ]; + } + + if ( !is_array( $campaigns_30 ) ) + { + $campaigns_30 = []; + } + + $campaigns_all_time = $api -> get_campaigns_all_time( $customer_id ); + $all_time_map = []; + $all_time_totals = [ + 'cost' => 0.0, + 'conversion_value' => 0.0, + ]; + + if ( is_array( $campaigns_all_time ) ) + { + foreach ( $campaigns_all_time as $cat ) { - 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++; + $all_time_map[ $cat['campaign_id'] ] = $cat['roas_all_time']; + $all_time_totals['cost'] += (float) ( $cat['cost_all_time'] ?? 0 ); + $all_time_totals['conversion_value'] += (float) ( $cat['conversion_value_all_time'] ?? 0 ); } } - echo json_encode( [ - 'result' => 'Synchronizacja zakonczona. Przetworzono kampanii: ' . $processed . '.', + $account_30_totals = [ + 'budget' => 0.0, + 'money_spent' => 0.0, + 'conversion_value' => 0.0, + ]; + + foreach ( $campaigns_30 as $campaign ) + { + $external_campaign_id = isset( $campaign['campaign_id'] ) ? (string) $campaign['campaign_id'] : ''; + if ( $external_campaign_id === '' ) + { + continue; + } + + $account_30_totals['budget'] += (float) ( $campaign['budget'] ?? 0 ); + $account_30_totals['money_spent'] += (float) ( $campaign['money_spent'] ?? 0 ); + $account_30_totals['conversion_value'] += (float) ( $campaign['conversion_value'] ?? 0 ); + + if ( !$mdb -> count( 'campaigns', [ 'AND' => [ + 'client_id' => $client['id'], + 'campaign_id' => $external_campaign_id + ] ] ) ) + { + $mdb -> insert( 'campaigns', [ + 'client_id' => $client['id'], + 'campaign_id' => $external_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' => $external_campaign_id + ] ] ); + + $mdb -> update( 'campaigns', [ + 'campaign_name' => $campaign['campaign_name'] + ], [ 'id' => $db_campaign_id ] ); + } + + $bidding_strategy = self::format_bidding_strategy( + $campaign['bidding_strategy'], + $campaign['target_roas'] ?? 0 + ); + + $history_data = [ + 'roas_30_days' => $campaign['roas_30_days'], + 'roas_all_time' => $all_time_map[ $external_campaign_id ] ?? 0, + 'budget' => $campaign['budget'], + 'money_spent' => $campaign['money_spent'], + 'conversion_value' => $campaign['conversion_value'], + 'bidding_strategy' => $bidding_strategy, + ]; + + 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 ); + } + + $campaigns_db_map[ $external_campaign_id ] = (int) $db_campaign_id; + $processed++; + } + + $account_roas_30 = ( $account_30_totals['money_spent'] > 0 ) + ? round( ( $account_30_totals['conversion_value'] / $account_30_totals['money_spent'] ) * 100, 2 ) + : 0; + + $account_roas_all_time = ( $all_time_totals['cost'] > 0 ) + ? round( ( $all_time_totals['conversion_value'] / $all_time_totals['cost'] ) * 100, 2 ) + : 0; + + if ( !$mdb -> count( 'campaigns', [ 'AND' => [ + 'client_id' => $client['id'], + 'campaign_id' => 0 + ] ] ) ) + { + $mdb -> insert( 'campaigns', [ + 'client_id' => $client['id'], + 'campaign_id' => 0, + 'campaign_name' => '--- konto ---' + ] ); + $db_account_campaign_id = $mdb -> id(); + } + else + { + $db_account_campaign_id = $mdb -> get( 'campaigns', 'id', [ 'AND' => [ + 'client_id' => $client['id'], + 'campaign_id' => 0 + ] ] ); + + $mdb -> update( 'campaigns', [ + 'campaign_name' => '--- konto ---' + ], [ 'id' => $db_account_campaign_id ] ); + } + + $account_history_data = [ + 'roas_30_days' => $account_roas_30, + 'roas_all_time' => $account_roas_all_time, + 'budget' => $account_30_totals['budget'], + 'money_spent' => $account_30_totals['money_spent'], + 'conversion_value' => $account_30_totals['conversion_value'], + 'bidding_strategy' => 'Konto (agregacja wszystkich kampanii)', + ]; + + if ( $mdb -> count( 'campaigns_history', [ 'AND' => [ + 'campaign_id' => $db_account_campaign_id, + 'date_add' => $today + ] ] ) ) + { + $mdb -> update( 'campaigns_history', $account_history_data, [ 'AND' => [ + 'campaign_id' => $db_account_campaign_id, + 'date_add' => $today + ] ] ); + } + else + { + $account_history_data['campaign_id'] = $db_account_campaign_id; + $account_history_data['date_add'] = $today; + $mdb -> insert( 'campaigns_history', $account_history_data ); + } + + $processed++; + + $ad_groups_sync = self::sync_campaign_ad_groups_for_client( $campaigns_db_map, $customer_id, $api, $today ); + $search_terms_sync = self::sync_campaign_search_terms_for_client( $campaigns_db_map, $ad_groups_sync['ad_group_map'], $customer_id, $api, $today ); + $negative_keywords_sync = self::sync_campaign_negative_keywords_for_client( $campaigns_db_map, $ad_groups_sync['ad_group_map'], $customer_id, $api, $today ); + + $errors = array_merge( $errors, $ad_groups_sync['errors'], $search_terms_sync['errors'], $negative_keywords_sync['errors'] ); + + return [ + 'processed_records' => $processed, + 'ad_groups_synced' => (int) $ad_groups_sync['count'], + 'search_terms_synced' => (int) $search_terms_sync['count'], + 'negative_keywords_synced' => (int) $negative_keywords_sync['count'], 'errors' => $errors + ]; + } + + static private function sync_campaign_ad_groups_for_client( $campaigns_db_map, $customer_id, $api, $date_sync ) + { + global $mdb; + + $campaign_db_ids = array_values( array_unique( array_map( 'intval', array_values( $campaigns_db_map ) ) ) ); + if ( empty( $campaign_db_ids ) ) + { + return [ 'count' => 0, 'ad_group_map' => [], 'errors' => [] ]; + } + + $ad_groups_30 = $api -> get_ad_groups_30_days( $customer_id ); + if ( $ad_groups_30 === false ) + { + $last_err = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ); + return [ 'count' => 0, 'ad_group_map' => [], 'errors' => [ 'Blad pobierania grup reklam (30 dni): ' . $last_err ] ]; + } + + $ad_groups_all_time = $api -> get_ad_groups_all_time( $customer_id ); + if ( $ad_groups_all_time === false ) + { + $last_err = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ); + return [ 'count' => 0, 'ad_group_map' => [], 'errors' => [ 'Blad pobierania grup reklam (all time): ' . $last_err ] ]; + } + + if ( !is_array( $ad_groups_30 ) ) + { + $ad_groups_30 = []; + } + + if ( !is_array( $ad_groups_all_time ) ) + { + $ad_groups_all_time = []; + } + + $map_30 = []; + foreach ( $ad_groups_30 as $row ) + { + $campaign_external_id = isset( $row['campaign_id'] ) ? (string) $row['campaign_id'] : ''; + $ad_group_external_id = isset( $row['ad_group_id'] ) ? (string) $row['ad_group_id'] : ''; + if ( $campaign_external_id === '' || $ad_group_external_id === '' ) + { + continue; + } + $map_30[ $campaign_external_id . '|' . $ad_group_external_id ] = $row; + } + + $map_all_time = []; + foreach ( $ad_groups_all_time as $row ) + { + $campaign_external_id = isset( $row['campaign_id'] ) ? (string) $row['campaign_id'] : ''; + $ad_group_external_id = isset( $row['ad_group_id'] ) ? (string) $row['ad_group_id'] : ''; + if ( $campaign_external_id === '' || $ad_group_external_id === '' ) + { + continue; + } + $map_all_time[ $campaign_external_id . '|' . $ad_group_external_id ] = $row; + } + + $mdb -> delete( 'campaign_ad_groups', [ 'campaign_id' => $campaign_db_ids ] ); + + $keys = array_values( array_unique( array_merge( array_keys( $map_30 ), array_keys( $map_all_time ) ) ) ); + $ad_group_db_map = []; + $count = 0; + + foreach ( $keys as $key ) + { + $parts = explode( '|', $key, 2 ); + $campaign_external_id = $parts[0] ?? ''; + $ad_group_external_id = $parts[1] ?? ''; + + $db_campaign_id = (int) ( $campaigns_db_map[ $campaign_external_id ] ?? 0 ); + if ( $db_campaign_id <= 0 || $ad_group_external_id === '' ) + { + continue; + } + + $row_30 = $map_30[ $key ] ?? []; + $row_all_time = $map_all_time[ $key ] ?? []; + + $ad_group_name = trim( (string) ( $row_30['ad_group_name'] ?? ( $row_all_time['ad_group_name'] ?? '' ) ) ); + if ( $ad_group_name === '' ) + { + $ad_group_name = 'Ad group #' . $ad_group_external_id; + } + + $mdb -> insert( 'campaign_ad_groups', [ + 'campaign_id' => $db_campaign_id, + 'ad_group_id' => (int) $ad_group_external_id, + 'ad_group_name' => $ad_group_name, + 'impressions_30' => (int) ( $row_30['impressions'] ?? 0 ), + 'clicks_30' => (int) ( $row_30['clicks'] ?? 0 ), + 'cost_30' => (float) ( $row_30['cost'] ?? 0 ), + 'conversions_30' => (float) ( $row_30['conversions'] ?? 0 ), + 'conversion_value_30' => (float) ( $row_30['conversion_value'] ?? 0 ), + 'roas_30' => (float) ( $row_30['roas'] ?? 0 ), + 'impressions_all_time' => (int) ( $row_all_time['impressions'] ?? 0 ), + 'clicks_all_time' => (int) ( $row_all_time['clicks'] ?? 0 ), + 'cost_all_time' => (float) ( $row_all_time['cost'] ?? 0 ), + 'conversions_all_time' => (float) ( $row_all_time['conversions'] ?? 0 ), + 'conversion_value_all_time' => (float) ( $row_all_time['conversion_value'] ?? 0 ), + 'roas_all_time' => (float) ( $row_all_time['roas'] ?? 0 ), + 'date_sync' => $date_sync + ] ); + + $db_ad_group_id = (int) $mdb -> id(); + if ( $db_ad_group_id > 0 ) + { + $ad_group_db_map[ $key ] = $db_ad_group_id; + $count++; + } + } + + return [ 'count' => $count, 'ad_group_map' => $ad_group_db_map, 'errors' => [] ]; + } + + static private function sync_campaign_search_terms_for_client( $campaigns_db_map, $ad_group_db_map, $customer_id, $api, $date_sync ) + { + global $mdb; + + $campaign_db_ids = array_values( array_unique( array_map( 'intval', array_values( $campaigns_db_map ) ) ) ); + if ( empty( $campaign_db_ids ) ) + { + return [ 'count' => 0, 'errors' => [] ]; + } + + $search_terms_30 = $api -> get_search_terms_30_days( $customer_id ); + if ( $search_terms_30 === false ) + { + $last_err = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ); + return [ 'count' => 0, 'errors' => [ 'Blad pobierania fraz wyszukiwanych (30 dni): ' . $last_err ] ]; + } + + $search_terms_all_time = $api -> get_search_terms_all_time( $customer_id ); + if ( $search_terms_all_time === false ) + { + $last_err = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ); + return [ 'count' => 0, 'errors' => [ 'Blad pobierania fraz wyszukiwanych (all time): ' . $last_err ] ]; + } + + if ( !is_array( $search_terms_30 ) ) + { + $search_terms_30 = []; + } + + if ( !is_array( $search_terms_all_time ) ) + { + $search_terms_all_time = []; + } + + $map_30 = []; + foreach ( $search_terms_30 as $row ) + { + $campaign_external_id = isset( $row['campaign_id'] ) ? (string) $row['campaign_id'] : ''; + $ad_group_external_id = isset( $row['ad_group_id'] ) ? (string) $row['ad_group_id'] : ''; + $search_term = trim( (string) ( $row['search_term'] ?? '' ) ); + if ( $campaign_external_id === '' || $ad_group_external_id === '' || $search_term === '' ) + { + continue; + } + $map_30[ $campaign_external_id . '|' . $ad_group_external_id . '|' . strtolower( $search_term ) ] = $row; + } + + $map_all_time = []; + foreach ( $search_terms_all_time as $row ) + { + $campaign_external_id = isset( $row['campaign_id'] ) ? (string) $row['campaign_id'] : ''; + $ad_group_external_id = isset( $row['ad_group_id'] ) ? (string) $row['ad_group_id'] : ''; + $search_term = trim( (string) ( $row['search_term'] ?? '' ) ); + if ( $campaign_external_id === '' || $ad_group_external_id === '' || $search_term === '' ) + { + continue; + } + $map_all_time[ $campaign_external_id . '|' . $ad_group_external_id . '|' . strtolower( $search_term ) ] = $row; + } + + $mdb -> delete( 'campaign_search_terms', [ 'campaign_id' => $campaign_db_ids ] ); + + $keys = array_values( array_unique( array_merge( array_keys( $map_30 ), array_keys( $map_all_time ) ) ) ); + $count = 0; + + foreach ( $keys as $key ) + { + $parts = explode( '|', $key, 3 ); + $campaign_external_id = $parts[0] ?? ''; + $ad_group_external_id = $parts[1] ?? ''; + $db_campaign_id = (int) ( $campaigns_db_map[ $campaign_external_id ] ?? 0 ); + $db_ad_group_id = (int) ( $ad_group_db_map[ $campaign_external_id . '|' . $ad_group_external_id ] ?? 0 ); + + if ( $db_campaign_id <= 0 || $db_ad_group_id <= 0 ) + { + continue; + } + + $row_30 = $map_30[ $key ] ?? []; + $row_all_time = $map_all_time[ $key ] ?? []; + + $search_term = trim( (string) ( $row_30['search_term'] ?? ( $row_all_time['search_term'] ?? '' ) ) ); + if ( $search_term === '' ) + { + continue; + } + + $clicks_30 = (int) ( $row_30['clicks'] ?? 0 ); + $clicks_all_time = (int) ( $row_all_time['clicks'] ?? 0 ); + if ( $clicks_30 <= 0 && $clicks_all_time <= 0 ) + { + continue; + } + + $mdb -> insert( 'campaign_search_terms', [ + 'campaign_id' => $db_campaign_id, + 'ad_group_id' => $db_ad_group_id, + 'search_term' => $search_term, + 'impressions_30' => (int) ( $row_30['impressions'] ?? 0 ), + 'clicks_30' => $clicks_30, + 'cost_30' => (float) ( $row_30['cost'] ?? 0 ), + 'conversions_30' => (float) ( $row_30['conversions'] ?? 0 ), + 'conversion_value_30' => (float) ( $row_30['conversion_value'] ?? 0 ), + 'roas_30' => (float) ( $row_30['roas'] ?? 0 ), + 'impressions_all_time' => (int) ( $row_all_time['impressions'] ?? 0 ), + 'clicks_all_time' => $clicks_all_time, + 'cost_all_time' => (float) ( $row_all_time['cost'] ?? 0 ), + 'conversions_all_time' => (float) ( $row_all_time['conversions'] ?? 0 ), + 'conversion_value_all_time' => (float) ( $row_all_time['conversion_value'] ?? 0 ), + 'roas_all_time' => (float) ( $row_all_time['roas'] ?? 0 ), + 'date_sync' => $date_sync + ] ); + + $count++; + } + + return [ 'count' => $count, 'errors' => [] ]; + } + + static private function sync_campaign_negative_keywords_for_client( $campaigns_db_map, $ad_group_db_map, $customer_id, $api, $date_sync ) + { + global $mdb; + + $campaign_db_ids = array_values( array_unique( array_map( 'intval', array_values( $campaigns_db_map ) ) ) ); + if ( empty( $campaign_db_ids ) ) + { + return [ 'count' => 0, 'errors' => [] ]; + } + + $negatives = $api -> get_negative_keywords( $customer_id ); + if ( $negatives === false ) + { + $last_err = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' ); + return [ 'count' => 0, 'errors' => [ 'Blad pobierania fraz wykluczajacych: ' . $last_err ] ]; + } + + if ( !is_array( $negatives ) ) + { + $negatives = []; + } + + $mdb -> delete( 'campaign_negative_keywords', [ 'campaign_id' => $campaign_db_ids ] ); + + $count = 0; + $seen = []; + + foreach ( $negatives as $row ) + { + $campaign_external_id = isset( $row['campaign_id'] ) ? (string) $row['campaign_id'] : ''; + $db_campaign_id = (int) ( $campaigns_db_map[ $campaign_external_id ] ?? 0 ); + if ( $db_campaign_id <= 0 ) + { + continue; + } + + $scope = ( $row['scope'] ?? '' ) === 'ad_group' ? 'ad_group' : 'campaign'; + $db_ad_group_id = null; + + if ( $scope === 'ad_group' ) + { + $ad_group_external_id = isset( $row['ad_group_id'] ) ? (string) $row['ad_group_id'] : ''; + $mapped_ad_group_id = (int) ( $ad_group_db_map[ $campaign_external_id . '|' . $ad_group_external_id ] ?? 0 ); + if ( $mapped_ad_group_id <= 0 ) + { + continue; + } + $db_ad_group_id = $mapped_ad_group_id; + } + + $keyword_text = trim( (string) ( $row['keyword_text'] ?? '' ) ); + if ( $keyword_text === '' ) + { + continue; + } + + $match_type = trim( (string) ( $row['match_type'] ?? '' ) ); + $uniq_key = $db_campaign_id . '|' . (int) $db_ad_group_id . '|' . $scope . '|' . strtolower( $keyword_text ) . '|' . strtolower( $match_type ); + + if ( isset( $seen[ $uniq_key ] ) ) + { + continue; + } + $seen[ $uniq_key ] = true; + + $mdb -> insert( 'campaign_negative_keywords', [ + 'campaign_id' => $db_campaign_id, + 'ad_group_id' => $db_ad_group_id, + 'scope' => $scope, + 'keyword_text' => $keyword_text, + 'match_type' => $match_type, + 'date_sync' => $date_sync + ] ); + + $count++; + } + + return [ 'count' => $count, 'errors' => [] ]; + } + + static private function get_daily_cron_state( $state_key ) + { + $today = date( 'Y-m-d' ); + + $state_raw = self::get_setting_value( $state_key, '' ); + $state = json_decode( (string) $state_raw, true ); + + if ( !is_array( $state ) || ( $state['date'] ?? '' ) !== $today ) + { + return [ + 'date' => $today, + 'processed_ids' => [] + ]; + } + + $processed_ids = []; + if ( isset( $state['processed_ids'] ) && is_array( $state['processed_ids'] ) ) + { + foreach ( $state['processed_ids'] as $id ) + { + $id = (int) $id; + if ( $id > 0 ) + { + $processed_ids[] = $id; + } + } + } + + return [ + 'date' => $today, + 'processed_ids' => array_values( array_unique( $processed_ids ) ) + ]; + } + + static private function save_daily_cron_state( $state_key, $state ) + { + $payload = [ + 'date' => date( 'Y-m-d' ), + 'processed_ids' => array_values( array_unique( array_map( 'intval', $state['processed_ids'] ?? [] ) ) ) + ]; + + self::set_setting_value( $state_key, json_encode( $payload, JSON_UNESCAPED_UNICODE ) ); + } + + static private function pick_next_client_id( $client_ids, $processed_ids ) + { + $processed_lookup = []; + foreach ( $processed_ids as $pid ) + { + $pid = (int) $pid; + if ( $pid > 0 ) + { + $processed_lookup[$pid] = true; + } + } + + foreach ( $client_ids as $cid ) + { + $cid = (int) $cid; + if ( $cid > 0 && !isset( $processed_lookup[$cid] ) ) + { + return $cid; + } + } + + return 0; + } + + static private function get_setting_value( $setting_key, $default = null ) + { + global $mdb; + + $value = $mdb -> get( 'settings', 'setting_value', [ 'setting_key' => $setting_key ] ); + + if ( $value === null || $value === false ) + { + return $default; + } + + return $value; + } + + static private function set_setting_value( $setting_key, $setting_value ) + { + global $mdb; + + if ( $mdb -> count( 'settings', [ 'setting_key' => $setting_key ] ) ) + { + $mdb -> update( 'settings', [ 'setting_value' => $setting_value ], [ 'setting_key' => $setting_key ] ); + return; + } + + $mdb -> insert( 'settings', [ + 'setting_key' => $setting_key, + 'setting_value' => $setting_value ] ); - exit; } static private function format_bidding_strategy( $strategy_type, $target_roas = 0 ) @@ -679,7 +1617,7 @@ class Cron // Inicjalizacja tablic do przechowywania danych $phrases_data = []; - // Grupowanie danych według fraz + // Grupowanie danych wedug fraz foreach ( $data as $entry ) { $phrase_id = $entry['phrase_id']; @@ -697,7 +1635,7 @@ class Cron ]; } - // Sumowanie danych według fraz + // Sumowanie danych wedug fraz $phrases_data[$phrase_id]['impressions'] += $entry['impressions']; $phrases_data[$phrase_id]['clicks'] += $entry['clicks']; $phrases_data[$phrase_id]['cost'] += $entry['cost']; diff --git a/autoload/factory/class.Campaigns.php b/autoload/factory/class.Campaigns.php index 42b2aeb..24b1282 100644 --- a/autoload/factory/class.Campaigns.php +++ b/autoload/factory/class.Campaigns.php @@ -35,6 +35,177 @@ class Campaigns return $mdb -> get( 'clients', 'name', [ 'id' => $client_id ] ); } + static public function get_campaign_ad_groups( $campaign_id ) + { + global $mdb; + + return $mdb -> query( + 'SELECT + id, + campaign_id, + ad_group_id, + ad_group_name, + impressions_30, + clicks_30, + cost_30, + conversions_30, + conversion_value_30, + roas_30, + impressions_all_time, + clicks_all_time, + cost_all_time, + conversions_all_time, + conversion_value_all_time, + roas_all_time + FROM campaign_ad_groups + WHERE campaign_id = :campaign_id + ORDER BY clicks_30 DESC, clicks_all_time DESC, ad_group_name ASC', + [ ':campaign_id' => (int) $campaign_id ] + ) -> fetchAll( \PDO::FETCH_ASSOC ); + } + + static public function get_campaign_search_terms( $campaign_id, $ad_group_id = 0 ) + { + global $mdb; + + $sql = 'SELECT + st.id, + st.campaign_id, + st.ad_group_id, + ag.ad_group_name, + st.search_term, + st.impressions_30, + st.clicks_30, + st.cost_30, + st.conversions_30, + st.conversion_value_30, + st.roas_30, + st.impressions_all_time, + st.clicks_all_time, + st.cost_all_time, + st.conversions_all_time, + st.conversion_value_all_time, + st.roas_all_time + FROM campaign_search_terms AS st + LEFT JOIN campaign_ad_groups AS ag ON ag.id = st.ad_group_id + WHERE st.campaign_id = :campaign_id'; + + $params = [ ':campaign_id' => (int) $campaign_id ]; + + if ( (int) $ad_group_id > 0 ) + { + $sql .= ' AND st.ad_group_id = :ad_group_id'; + $params[':ad_group_id'] = (int) $ad_group_id; + } + + $sql .= ' ORDER BY st.clicks_30 DESC, st.clicks_all_time DESC, st.search_term ASC'; + + return $mdb -> query( $sql, $params ) -> fetchAll( \PDO::FETCH_ASSOC ); + } + + static public function get_campaign_negative_keywords( $campaign_id, $ad_group_id = 0 ) + { + global $mdb; + + $sql = 'SELECT + nk.id, + nk.campaign_id, + nk.ad_group_id, + ag.ad_group_name, + nk.scope, + nk.keyword_text, + nk.match_type + FROM campaign_negative_keywords AS nk + LEFT JOIN campaign_ad_groups AS ag ON ag.id = nk.ad_group_id + WHERE nk.campaign_id = :campaign_id'; + + $params = [ ':campaign_id' => (int) $campaign_id ]; + + if ( (int) $ad_group_id > 0 ) + { + $sql .= ' AND ( nk.scope = \'campaign\' OR nk.ad_group_id = :ad_group_id )'; + $params[':ad_group_id'] = (int) $ad_group_id; + } + + $sql .= ' ORDER BY nk.scope ASC, nk.keyword_text ASC'; + + return $mdb -> query( $sql, $params ) -> fetchAll( \PDO::FETCH_ASSOC ); + } + + static public function get_search_term_context( $search_term_row_id ) + { + global $mdb; + + return $mdb -> query( + 'SELECT + st.id AS search_term_row_id, + st.search_term, + st.campaign_id AS db_campaign_id, + st.ad_group_id AS db_ad_group_id, + c.client_id, + c.campaign_id AS external_campaign_id, + ag.ad_group_id AS external_ad_group_id, + cl.google_ads_customer_id + FROM campaign_search_terms AS st + INNER JOIN campaigns AS c ON c.id = st.campaign_id + INNER JOIN clients AS cl ON cl.id = c.client_id + LEFT JOIN campaign_ad_groups AS ag ON ag.id = st.ad_group_id + WHERE st.id = :search_term_row_id + LIMIT 1', + [ ':search_term_row_id' => (int) $search_term_row_id ] + ) -> fetch( \PDO::FETCH_ASSOC ); + } + + static public function upsert_campaign_negative_keyword( $campaign_id, $ad_group_id, $scope, $keyword_text, $match_type ) + { + global $mdb; + + $campaign_id = (int) $campaign_id; + $ad_group_id = $ad_group_id !== null ? (int) $ad_group_id : null; + $scope = $scope === 'campaign' ? 'campaign' : 'ad_group'; + $keyword_text = trim( (string) $keyword_text ); + $match_type = strtoupper( trim( (string) $match_type ) ); + + if ( $campaign_id <= 0 || $keyword_text === '' ) + { + return false; + } + + $existing = $mdb -> query( + 'SELECT id + FROM campaign_negative_keywords + WHERE campaign_id = :campaign_id + AND ( ( :ad_group_id IS NULL AND ad_group_id IS NULL ) OR ad_group_id = :ad_group_id ) + AND scope = :scope + AND LOWER(keyword_text) = LOWER(:keyword_text) + AND UPPER(COALESCE(match_type, \'\')) = :match_type + LIMIT 1', + [ + ':campaign_id' => $campaign_id, + ':ad_group_id' => $ad_group_id, + ':scope' => $scope, + ':keyword_text' => $keyword_text, + ':match_type' => $match_type + ] + ) -> fetchColumn(); + + if ( $existing ) + { + return (int) $existing; + } + + $mdb -> insert( 'campaign_negative_keywords', [ + 'campaign_id' => $campaign_id, + 'ad_group_id' => $ad_group_id, + 'scope' => $scope, + 'keyword_text' => $keyword_text, + 'match_type' => $match_type, + 'date_sync' => date( 'Y-m-d' ) + ] ); + + return (int) $mdb -> id(); + } + static public function delete_campaign( $campaign_id ) { global $mdb; @@ -47,4 +218,4 @@ class Campaigns global $mdb; return $mdb -> delete( 'campaigns_history', [ 'id' => $history_id ] ); } -} \ No newline at end of file +} diff --git a/autoload/services/class.GoogleAdsApi.php b/autoload/services/class.GoogleAdsApi.php index 22cdcd1..3e47dde 100644 --- a/autoload/services/class.GoogleAdsApi.php +++ b/autoload/services/class.GoogleAdsApi.php @@ -180,8 +180,323 @@ class GoogleAdsApi return $results; } + public function mutate( $customer_id, $mutate_operations, $partial_failure = false ) + { + $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:mutate'; + + $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 ); + } + + $payload = [ + 'mutateOperations' => array_values( $mutate_operations ), + 'partialFailure' => (bool) $partial_failure + ]; + + $ch = curl_init( $url ); + curl_setopt_array( $ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_POSTFIELDS => json_encode( $payload ), + 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', 'mutate failed: HTTP ' . $http_code . ' | ' . $error . ' | ' . substr( (string) $response, 0, 1000 ) ); + return false; + } + + $data = json_decode( $response, true ); + if ( !is_array( $data ) ) + { + self::set_setting( 'google_ads_last_error', 'mutate failed: niepoprawna odpowiedz JSON' ); + return false; + } + + self::set_setting( 'google_ads_last_error', null ); + return $data; + } + + public function add_negative_keyword_to_ad_group( $customer_id, $ad_group_id, $keyword_text, $match_type = 'PHRASE' ) + { + $customer_id = trim( str_replace( '-', '', (string) $customer_id ) ); + $ad_group_id = trim( (string) $ad_group_id ); + $keyword_text = trim( (string) $keyword_text ); + $match_type = strtoupper( trim( (string) $match_type ) ); + + if ( $customer_id === '' || $ad_group_id === '' || $keyword_text === '' ) + { + self::set_setting( 'google_ads_last_error', 'Brak wymaganych danych do dodania frazy wykluczajacej.' ); + return [ 'success' => false, 'duplicate' => false ]; + } + + if ( !in_array( $match_type, [ 'PHRASE', 'EXACT', 'BROAD' ], true ) ) + { + $match_type = 'PHRASE'; + } + + $operation = [ + 'adGroupCriterionOperation' => [ + 'create' => [ + 'adGroup' => 'customers/' . $customer_id . '/adGroups/' . $ad_group_id, + 'negative' => true, + 'keyword' => [ + 'text' => $keyword_text, + 'matchType' => $match_type + ] + ] + ] + ]; + + $result = $this -> mutate( $customer_id, [ $operation ] ); + + if ( $result === false ) + { + $last_error = (string) self::get_setting( 'google_ads_last_error' ); + $is_duplicate = stripos( $last_error, 'DUPLICATE' ) !== false + || stripos( $last_error, 'already exists' ) !== false; + + if ( $is_duplicate ) + { + return [ + 'success' => true, + 'duplicate' => true, + 'verification' => $this -> verify_negative_keyword_exists( $customer_id, 'ad_group', $keyword_text, $match_type, null, $ad_group_id ) + ]; + } + + return [ 'success' => false, 'duplicate' => false, 'sent_operation' => $operation ]; + } + + return [ + 'success' => true, + 'duplicate' => false, + 'response' => $result, + 'sent_operation' => $operation, + 'verification' => $this -> verify_negative_keyword_exists( $customer_id, 'ad_group', $keyword_text, $match_type, null, $ad_group_id ) + ]; + } + + public function add_negative_keyword_to_campaign( $customer_id, $campaign_id, $keyword_text, $match_type = 'PHRASE' ) + { + $customer_id = trim( str_replace( '-', '', (string) $customer_id ) ); + $campaign_id = trim( (string) $campaign_id ); + $keyword_text = trim( (string) $keyword_text ); + $match_type = strtoupper( trim( (string) $match_type ) ); + + if ( $customer_id === '' || $campaign_id === '' || $keyword_text === '' ) + { + self::set_setting( 'google_ads_last_error', 'Brak wymaganych danych do dodania frazy wykluczajacej.' ); + return [ 'success' => false, 'duplicate' => false ]; + } + + if ( !in_array( $match_type, [ 'PHRASE', 'EXACT', 'BROAD' ], true ) ) + { + $match_type = 'PHRASE'; + } + + $operation = [ + 'campaignCriterionOperation' => [ + 'create' => [ + 'campaign' => 'customers/' . $customer_id . '/campaigns/' . $campaign_id, + 'negative' => true, + 'keyword' => [ + 'text' => $keyword_text, + 'matchType' => $match_type + ] + ] + ] + ]; + + $result = $this -> mutate( $customer_id, [ $operation ] ); + + if ( $result === false ) + { + $last_error = (string) self::get_setting( 'google_ads_last_error' ); + $is_duplicate = stripos( $last_error, 'DUPLICATE' ) !== false + || stripos( $last_error, 'already exists' ) !== false; + + if ( $is_duplicate ) + { + return [ + 'success' => true, + 'duplicate' => true, + 'verification' => $this -> verify_negative_keyword_exists( $customer_id, 'campaign', $keyword_text, $match_type, $campaign_id, null ) + ]; + } + + return [ 'success' => false, 'duplicate' => false, 'sent_operation' => $operation ]; + } + + return [ + 'success' => true, + 'duplicate' => false, + 'response' => $result, + 'sent_operation' => $operation, + 'verification' => $this -> verify_negative_keyword_exists( $customer_id, 'campaign', $keyword_text, $match_type, $campaign_id, null ) + ]; + } + + private function gaql_escape( $value ) + { + return str_replace( [ '\\', '\'' ], [ '\\\\', '\\\'' ], (string) $value ); + } + + private function verify_negative_keyword_exists( $customer_id, $scope, $keyword_text, $match_type, $campaign_id = null, $ad_group_id = null ) + { + $customer_id = trim( str_replace( '-', '', (string) $customer_id ) ); + $scope = $scope === 'campaign' ? 'campaign' : 'ad_group'; + $match_type = strtoupper( trim( (string) $match_type ) ); + $keyword_text_escaped = $this -> gaql_escape( trim( (string) $keyword_text ) ); + + if ( $scope === 'campaign' ) + { + $campaign_id = trim( (string) $campaign_id ); + if ( $campaign_id === '' || $keyword_text_escaped === '' ) + { + return [ 'found' => false, 'scope' => $scope, 'rows' => [], 'error' => 'Brak danych do weryfikacji.' ]; + } + + $gaql = "SELECT " + . "campaign_criterion.resource_name, " + . "campaign.id, " + . "campaign_criterion.keyword.text, " + . "campaign_criterion.keyword.match_type " + . "FROM campaign_criterion " + . "WHERE campaign.id = " . $campaign_id . " " + . "AND campaign_criterion.type = 'KEYWORD' " + . "AND campaign_criterion.negative = TRUE " + . "AND campaign_criterion.keyword.text = '" . $keyword_text_escaped . "' " + . "AND campaign_criterion.keyword.match_type = " . $match_type . " " + . "LIMIT 5"; + } + else + { + $ad_group_id = trim( (string) $ad_group_id ); + if ( $ad_group_id === '' || $keyword_text_escaped === '' ) + { + return [ 'found' => false, 'scope' => $scope, 'rows' => [], 'error' => 'Brak danych do weryfikacji.' ]; + } + + $gaql = "SELECT " + . "ad_group_criterion.resource_name, " + . "campaign.id, " + . "ad_group.id, " + . "ad_group_criterion.keyword.text, " + . "ad_group_criterion.keyword.match_type " + . "FROM ad_group_criterion " + . "WHERE ad_group.id = " . $ad_group_id . " " + . "AND ad_group_criterion.type = 'KEYWORD' " + . "AND ad_group_criterion.negative = TRUE " + . "AND ad_group_criterion.keyword.text = '" . $keyword_text_escaped . "' " + . "AND ad_group_criterion.keyword.match_type = " . $match_type . " " + . "LIMIT 5"; + } + + $rows = []; + $last_error = null; + for ( $i = 0; $i < 3; $i++ ) + { + $result = $this -> search_stream( $customer_id, $gaql ); + if ( is_array( $result ) ) + { + $rows = $result; + if ( count( $rows ) > 0 ) + { + return [ 'found' => true, 'scope' => $scope, 'rows' => $rows ]; + } + } + else + { + $last_error = (string) self::get_setting( 'google_ads_last_error' ); + } + + usleep( 400000 ); + } + + return [ + 'found' => count( $rows ) > 0, + 'scope' => $scope, + 'rows' => $rows, + 'error' => $last_error + ]; + } + // --- Kampanie: dane 30-dniowe --- + public function get_products_for_date( $customer_id, $date ) + { + $date = date( 'Y-m-d', strtotime( $date ) ); + + $gaql = "SELECT " + . "segments.date, " + . "segments.product_item_id, " + . "segments.product_title, " + . "metrics.impressions, " + . "metrics.clicks, " + . "metrics.cost_micros, " + . "metrics.conversions, " + . "metrics.conversions_value " + . "FROM shopping_performance_view " + . "WHERE segments.date = '" . $date . "'"; + + $results = $this -> search_stream( $customer_id, $gaql ); + if ( $results === false ) return false; + + $products = []; + + foreach ( $results as $row ) + { + $offer_id = trim( (string) ( $row['segments']['productItemId'] ?? '' ) ); + if ( $offer_id === '' ) + { + continue; + } + + if ( !isset( $products[ $offer_id ] ) ) + { + $products[ $offer_id ] = [ + 'OfferId' => $offer_id, + 'ProductTitle' => (string) ( $row['segments']['productTitle'] ?? $offer_id ), + 'Impressions' => 0, + 'Clicks' => 0, + 'Cost' => 0.0, + 'Conversions' => 0.0, + 'ConversionValue' => 0.0 + ]; + } + + $products[ $offer_id ]['Impressions'] += (int) ( $row['metrics']['impressions'] ?? 0 ); + $products[ $offer_id ]['Clicks'] += (int) ( $row['metrics']['clicks'] ?? 0 ); + $products[ $offer_id ]['Cost'] += (float) ( $row['metrics']['costMicros'] ?? 0 ) / 1000000; + $products[ $offer_id ]['Conversions'] += (float) ( $row['metrics']['conversions'] ?? 0 ); + $products[ $offer_id ]['ConversionValue'] += (float) ( $row['metrics']['conversionsValue'] ?? 0 ); + } + + return array_values( $products ); + } + public function get_campaigns_30_days( $customer_id ) { $gaql = "SELECT " @@ -264,11 +579,296 @@ class GoogleAdsApi $value = (float) ( $row['metrics']['conversionsValue'] ?? 0 ); $campaigns[] = [ - 'campaign_id' => $cid, - 'roas_all_time' => ( $cost > 0 ) ? round( ( $value / $cost ) * 100, 2 ) : 0, + 'campaign_id' => $cid, + 'cost_all_time' => $cost, + 'conversion_value_all_time' => $value, + 'roas_all_time' => ( $cost > 0 ) ? round( ( $value / $cost ) * 100, 2 ) : 0, ]; } return $campaigns; } + + public function get_ad_groups_30_days( $customer_id ) + { + $gaql = "SELECT " + . "campaign.id, " + . "ad_group.id, " + . "ad_group.name, " + . "metrics.impressions, " + . "metrics.clicks, " + . "metrics.cost_micros, " + . "metrics.conversions, " + . "metrics.conversions_value " + . "FROM ad_group " + . "WHERE campaign.status != 'REMOVED' " + . "AND ad_group.status != 'REMOVED' " + . "AND segments.date DURING LAST_30_DAYS"; + + $results = $this -> search_stream( $customer_id, $gaql ); + if ( $results === false ) return false; + + return $this -> aggregate_ad_groups( $results ); + } + + public function get_ad_groups_all_time( $customer_id ) + { + $gaql = "SELECT " + . "campaign.id, " + . "ad_group.id, " + . "ad_group.name, " + . "metrics.impressions, " + . "metrics.clicks, " + . "metrics.cost_micros, " + . "metrics.conversions, " + . "metrics.conversions_value " + . "FROM ad_group " + . "WHERE campaign.status != 'REMOVED' " + . "AND ad_group.status != 'REMOVED'"; + + $results = $this -> search_stream( $customer_id, $gaql ); + if ( $results === false ) return false; + + return $this -> aggregate_ad_groups( $results ); + } + + public function get_search_terms_30_days( $customer_id ) + { + $gaql = "SELECT " + . "campaign.id, " + . "ad_group.id, " + . "ad_group.name, " + . "search_term_view.search_term, " + . "metrics.impressions, " + . "metrics.clicks, " + . "metrics.cost_micros, " + . "metrics.conversions, " + . "metrics.conversions_value " + . "FROM search_term_view " + . "WHERE campaign.status != 'REMOVED' " + . "AND ad_group.status != 'REMOVED' " + . "AND metrics.clicks > 0 " + . "AND segments.date DURING LAST_30_DAYS"; + + $results = $this -> search_stream( $customer_id, $gaql ); + if ( $results === false ) return false; + + return $this -> aggregate_search_terms( $results ); + } + + public function get_search_terms_all_time( $customer_id ) + { + $gaql = "SELECT " + . "campaign.id, " + . "ad_group.id, " + . "ad_group.name, " + . "search_term_view.search_term, " + . "metrics.impressions, " + . "metrics.clicks, " + . "metrics.cost_micros, " + . "metrics.conversions, " + . "metrics.conversions_value " + . "FROM search_term_view " + . "WHERE campaign.status != 'REMOVED' " + . "AND ad_group.status != 'REMOVED' " + . "AND metrics.clicks > 0"; + + $results = $this -> search_stream( $customer_id, $gaql ); + if ( $results === false ) return false; + + return $this -> aggregate_search_terms( $results ); + } + + public function get_negative_keywords( $customer_id ) + { + $campaign_gaql = "SELECT " + . "campaign.id, " + . "campaign_criterion.keyword.text, " + . "campaign_criterion.keyword.match_type " + . "FROM campaign_criterion " + . "WHERE campaign.status != 'REMOVED' " + . "AND campaign_criterion.type = 'KEYWORD' " + . "AND campaign_criterion.negative = TRUE"; + + $ad_group_gaql = "SELECT " + . "campaign.id, " + . "ad_group.id, " + . "ad_group_criterion.keyword.text, " + . "ad_group_criterion.keyword.match_type " + . "FROM ad_group_criterion " + . "WHERE campaign.status != 'REMOVED' " + . "AND ad_group.status != 'REMOVED' " + . "AND ad_group_criterion.type = 'KEYWORD' " + . "AND ad_group_criterion.negative = TRUE"; + + $campaign_results = $this -> search_stream( $customer_id, $campaign_gaql ); + if ( $campaign_results === false ) return false; + + $ad_group_results = $this -> search_stream( $customer_id, $ad_group_gaql ); + if ( $ad_group_results === false ) return false; + + $negatives = []; + $seen = []; + + foreach ( $campaign_results as $row ) + { + $campaign_id = $row['campaign']['id'] ?? null; + $text = trim( (string) ( $row['campaignCriterion']['keyword']['text'] ?? '' ) ); + $match_type = (string) ( $row['campaignCriterion']['keyword']['matchType'] ?? '' ); + + if ( !$campaign_id || $text === '' ) + { + continue; + } + + $key = 'campaign|' . $campaign_id . '||' . strtolower( $text ) . '|' . $match_type; + if ( isset( $seen[ $key ] ) ) + { + continue; + } + + $seen[ $key ] = true; + $negatives[] = [ + 'scope' => 'campaign', + 'campaign_id' => (int) $campaign_id, + 'ad_group_id' => null, + 'keyword_text' => $text, + 'match_type' => $match_type + ]; + } + + foreach ( $ad_group_results as $row ) + { + $campaign_id = $row['campaign']['id'] ?? null; + $ad_group_id = $row['adGroup']['id'] ?? null; + $text = trim( (string) ( $row['adGroupCriterion']['keyword']['text'] ?? '' ) ); + $match_type = (string) ( $row['adGroupCriterion']['keyword']['matchType'] ?? '' ); + + if ( !$campaign_id || !$ad_group_id || $text === '' ) + { + continue; + } + + $key = 'ad_group|' . $campaign_id . '|' . $ad_group_id . '|' . strtolower( $text ) . '|' . $match_type; + if ( isset( $seen[ $key ] ) ) + { + continue; + } + + $seen[ $key ] = true; + $negatives[] = [ + 'scope' => 'ad_group', + 'campaign_id' => (int) $campaign_id, + 'ad_group_id' => (int) $ad_group_id, + 'keyword_text' => $text, + 'match_type' => $match_type + ]; + } + + return $negatives; + } + + private function aggregate_ad_groups( $results ) + { + $ad_groups = []; + + foreach ( $results as $row ) + { + $campaign_id = $row['campaign']['id'] ?? null; + $ad_group_id = $row['adGroup']['id'] ?? null; + + if ( !$campaign_id || !$ad_group_id ) + { + continue; + } + + $key = $campaign_id . '|' . $ad_group_id; + + if ( !isset( $ad_groups[ $key ] ) ) + { + $ad_groups[ $key ] = [ + 'campaign_id' => (int) $campaign_id, + 'ad_group_id' => (int) $ad_group_id, + 'ad_group_name' => (string) ( $row['adGroup']['name'] ?? '' ), + 'impressions' => 0, + 'clicks' => 0, + 'cost' => 0.0, + 'conversions' => 0.0, + 'conversion_value' => 0.0, + 'roas' => 0.0 + ]; + } + + $ad_groups[ $key ]['impressions'] += (int) ( $row['metrics']['impressions'] ?? 0 ); + $ad_groups[ $key ]['clicks'] += (int) ( $row['metrics']['clicks'] ?? 0 ); + $ad_groups[ $key ]['cost'] += (float) ( $row['metrics']['costMicros'] ?? 0 ) / 1000000; + $ad_groups[ $key ]['conversions'] += (float) ( $row['metrics']['conversions'] ?? 0 ); + $ad_groups[ $key ]['conversion_value'] += (float) ( $row['metrics']['conversionsValue'] ?? 0 ); + } + + foreach ( $ad_groups as &$ad_group ) + { + $ad_group['roas'] = ( $ad_group['cost'] > 0 ) + ? round( ( $ad_group['conversion_value'] / $ad_group['cost'] ) * 100, 2 ) + : 0; + } + + return array_values( $ad_groups ); + } + + private function aggregate_search_terms( $results ) + { + $terms = []; + + foreach ( $results as $row ) + { + $campaign_id = $row['campaign']['id'] ?? null; + $ad_group_id = $row['adGroup']['id'] ?? null; + $search_term = trim( (string) ( $row['searchTermView']['searchTerm'] ?? '' ) ); + + if ( !$campaign_id || !$ad_group_id || $search_term === '' ) + { + continue; + } + + $key = $campaign_id . '|' . $ad_group_id . '|' . strtolower( $search_term ); + + if ( !isset( $terms[ $key ] ) ) + { + $terms[ $key ] = [ + 'campaign_id' => (int) $campaign_id, + 'ad_group_id' => (int) $ad_group_id, + 'ad_group_name' => (string) ( $row['adGroup']['name'] ?? '' ), + 'search_term' => $search_term, + 'impressions' => 0, + 'clicks' => 0, + 'cost' => 0.0, + 'conversions' => 0.0, + 'conversion_value' => 0.0, + 'roas' => 0.0 + ]; + } + + $terms[ $key ]['impressions'] += (int) ( $row['metrics']['impressions'] ?? 0 ); + $terms[ $key ]['clicks'] += (int) ( $row['metrics']['clicks'] ?? 0 ); + $terms[ $key ]['cost'] += (float) ( $row['metrics']['costMicros'] ?? 0 ) / 1000000; + $terms[ $key ]['conversions'] += (float) ( $row['metrics']['conversions'] ?? 0 ); + $terms[ $key ]['conversion_value'] += (float) ( $row['metrics']['conversionsValue'] ?? 0 ); + } + + foreach ( $terms as $key => &$term ) + { + if ( (int) $term['clicks'] <= 0 ) + { + unset( $terms[ $key ] ); + continue; + } + + $term['roas'] = ( $term['cost'] > 0 ) + ? round( ( $term['conversion_value'] / $term['cost'] ) * 100, 2 ) + : 0; + } + + return array_values( $terms ); + } } diff --git a/docs/database.sql b/docs/database.sql new file mode 100644 index 0000000..ff893eb --- /dev/null +++ b/docs/database.sql @@ -0,0 +1,344 @@ +-- -------------------------------------------------------- +-- Host: host700513.hostido.net.pl +-- Wersja serwera: 10.11.15-MariaDB-cll-lve - MariaDB Server +-- Serwer OS: Linux +-- HeidiSQL Wersja: 12.6.0.6765 +-- -------------------------------------------------------- + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET NAMES utf8 */; +/*!50503 SET NAMES utf8mb4 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- Zrzut struktury tabela host700513_adspro.campaigns +CREATE TABLE IF NOT EXISTS `campaigns` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `client_id` int(11) NOT NULL DEFAULT 0, + `campaign_id` bigint(20) NOT NULL DEFAULT 0, + `campaign_name` varchar(255) NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + KEY `client_id` (`client_id`), + CONSTRAINT `FK__clients` FOREIGN KEY (`client_id`) REFERENCES `clients` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=123 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Eksport danych został odznaczony. + +-- Zrzut struktury tabela host700513_adspro.campaigns_comments +CREATE TABLE IF NOT EXISTS `campaigns_comments` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `campaign_id` int(11) NOT NULL, + `comment` text NOT NULL, + `date_add` date NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`) USING BTREE, + KEY `campaign_id` (`campaign_id`), + CONSTRAINT `FK_campaigns_comments_campaigns` FOREIGN KEY (`campaign_id`) REFERENCES `campaigns` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +-- Eksport danych został odznaczony. + +-- Zrzut struktury tabela host700513_adspro.campaigns_history +CREATE TABLE IF NOT EXISTS `campaigns_history` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `campaign_id` int(11) NOT NULL DEFAULT 0, + `roas_30_days` decimal(20,6) NOT NULL DEFAULT 0.000000, + `roas_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000, + `budget` decimal(20,6) NOT NULL DEFAULT 0.000000, + `money_spent` decimal(20,6) NOT NULL DEFAULT 0.000000, + `conversion_value` decimal(20,6) NOT NULL DEFAULT 0.000000, + `bidding_strategy` text DEFAULT NULL, + `date_add` date NOT NULL DEFAULT '0000-00-00', + PRIMARY KEY (`id`) USING BTREE, + KEY `offer_id` (`campaign_id`) USING BTREE, + CONSTRAINT `FK_campaigns_history_campaigns` FOREIGN KEY (`campaign_id`) REFERENCES `campaigns` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=4400 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +-- Eksport danych został odznaczony. + +-- Zrzut struktury tabela host700513_adspro.clients +CREATE TABLE IF NOT EXISTS `clients` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL DEFAULT '0', + `google_ads_customer_id` varchar(20) DEFAULT NULL, + `google_ads_start_date` date DEFAULT NULL, + `deleted` int(11) DEFAULT 0, + `bestseller_min_roas` int(11) DEFAULT NULL, + PRIMARY KEY (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=46 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Eksport danych został odznaczony. + +-- Zrzut struktury tabela host700513_adspro.phrases +CREATE TABLE IF NOT EXISTS `phrases` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `client_id` int(11) NOT NULL DEFAULT 0, + `phrase` varchar(255) NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + KEY `FK_phrases_clients` (`client_id`), + CONSTRAINT `FK_phrases_clients` FOREIGN KEY (`client_id`) REFERENCES `clients` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=5512 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Eksport danych został odznaczony. + +-- Zrzut struktury tabela host700513_adspro.phrases_history +CREATE TABLE IF NOT EXISTS `phrases_history` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `phrase_id` int(11) NOT NULL DEFAULT 0, + `impressions` int(11) NOT NULL DEFAULT 0, + `clicks` int(11) NOT NULL DEFAULT 0, + `cost` decimal(20,6) NOT NULL DEFAULT 0.000000, + `conversions` decimal(20,6) NOT NULL DEFAULT 0.000000, + `conversions_value` decimal(20,6) NOT NULL DEFAULT 0.000000, + `date_add` date NOT NULL DEFAULT '0000-00-00', + `updated` int(11) NOT NULL DEFAULT 0, + `deleted` int(11) DEFAULT 0, + PRIMARY KEY (`id`) USING BTREE, + KEY `offer_id` (`phrase_id`) USING BTREE, + CONSTRAINT `FK_phrases_history_phrases` FOREIGN KEY (`phrase_id`) REFERENCES `phrases` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=13088 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +-- Eksport danych został odznaczony. + +-- Zrzut struktury tabela host700513_adspro.phrases_history_30 +CREATE TABLE IF NOT EXISTS `phrases_history_30` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `phrase_id` int(11) NOT NULL, + `impressions` int(11) NOT NULL, + `clicks` int(11) NOT NULL, + `cost` decimal(20,6) NOT NULL, + `conversions` decimal(20,6) NOT NULL DEFAULT 0.000000, + `conversions_value` decimal(20,6) NOT NULL, + `roas` decimal(20,6) NOT NULL, + `date_add` date NOT NULL DEFAULT '0000-00-00', + `deleted` int(11) DEFAULT 0, + PRIMARY KEY (`id`) USING BTREE, + KEY `offer_id` (`phrase_id`) USING BTREE, + CONSTRAINT `FK_phrases_history_30_phrases` FOREIGN KEY (`phrase_id`) REFERENCES `phrases` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=1795 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +-- Eksport danych został odznaczony. + +-- Zrzut struktury tabela host700513_adspro.phrases_temp +CREATE TABLE IF NOT EXISTS `phrases_temp` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `phrase_id` int(11) DEFAULT NULL, + `phrase` varchar(255) DEFAULT NULL, + `impressions` int(11) DEFAULT NULL, + `clicks` int(11) DEFAULT NULL, + `cost` decimal(20,6) DEFAULT NULL, + `conversions` decimal(20,6) DEFAULT NULL, + `conversions_value` decimal(20,6) DEFAULT NULL, + `cpc` decimal(20,6) DEFAULT NULL, + `roas` decimal(20,0) DEFAULT NULL, + PRIMARY KEY (`id`) USING BTREE, + KEY `offer_id` (`phrase_id`) USING BTREE, + CONSTRAINT `FK_phrases_temp_phrases` FOREIGN KEY (`phrase_id`) REFERENCES `phrases` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=353973 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +-- Eksport danych został odznaczony. + +-- Zrzut struktury tabela host700513_adspro.products +CREATE TABLE IF NOT EXISTS `products` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `client_id` int(11) NOT NULL DEFAULT 0, + `offer_id` varchar(50) NOT NULL DEFAULT '0', + `name` varchar(255) NOT NULL DEFAULT '0', + `min_roas` int(11) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `FK_offers_clients` (`client_id`), + CONSTRAINT `FK_offers_clients` FOREIGN KEY (`client_id`) REFERENCES `clients` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=5927 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Eksport danych został odznaczony. + +-- Zrzut struktury tabela host700513_adspro.products_comments +CREATE TABLE IF NOT EXISTS `products_comments` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `product_id` int(11) NOT NULL, + `comment` text NOT NULL, + `date_add` date NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + KEY `product_id` (`product_id`) USING BTREE, + CONSTRAINT `FK_products_comments_products` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=118 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Eksport danych został odznaczony. + +-- Zrzut struktury tabela host700513_adspro.products_data +CREATE TABLE IF NOT EXISTS `products_data` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `product_id` int(11) DEFAULT NULL, + `custom_label_4` varchar(255) DEFAULT NULL, + `custom_label_3` varchar(255) DEFAULT NULL, + `title` varchar(255) DEFAULT NULL, + `description` text DEFAULT NULL, + `google_product_category` text DEFAULT NULL, + `product_url` varchar(500) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `product_id` (`product_id`) USING BTREE, + CONSTRAINT `FK_products_data_products` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=118 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Eksport danych został odznaczony. + +-- Zrzut struktury tabela host700513_adspro.products_history +CREATE TABLE IF NOT EXISTS `products_history` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `product_id` int(11) NOT NULL DEFAULT 0, + `impressions` int(11) NOT NULL DEFAULT 0, + `clicks` int(11) NOT NULL DEFAULT 0, + `ctr` decimal(20,6) NOT NULL DEFAULT 0.000000, + `cost` decimal(20,6) NOT NULL DEFAULT 0.000000, + `conversions` decimal(20,6) NOT NULL DEFAULT 0.000000, + `conversions_value` decimal(20,6) NOT NULL DEFAULT 0.000000, + `date_add` date NOT NULL DEFAULT '0000-00-00', + `updated` int(11) NOT NULL DEFAULT 0, + `deleted` int(11) DEFAULT 0, + PRIMARY KEY (`id`) USING BTREE, + KEY `product_id` (`product_id`) USING BTREE, + CONSTRAINT `FK_products_history_products` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=63549 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + +-- Eksport danych został odznaczony. + +-- Zrzut struktury tabela host700513_adspro.products_history_30 +CREATE TABLE IF NOT EXISTS `products_history_30` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `product_id` int(11) NOT NULL, + `impressions` int(11) NOT NULL, + `clicks` int(11) NOT NULL, + `ctr` decimal(20,6) NOT NULL DEFAULT 0.000000, + `cost` decimal(20,6) NOT NULL, + `conversions` decimal(20,6) NOT NULL DEFAULT 0.000000, + `conversions_value` decimal(20,6) NOT NULL, + `roas` decimal(20,6) NOT NULL, + `roas_all_time` decimal(20,6) NOT NULL, + `date_add` date NOT NULL DEFAULT '0000-00-00', + `deleted` int(11) DEFAULT 0, + PRIMARY KEY (`id`), + KEY `product_id` (`product_id`) USING BTREE, + CONSTRAINT `FK_products_history_30_products` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=27655 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Eksport danych został odznaczony. + +-- Zrzut struktury tabela host700513_adspro.products_temp +CREATE TABLE IF NOT EXISTS `products_temp` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `product_id` int(11) DEFAULT NULL, + `name` varchar(255) DEFAULT NULL, + `impressions` int(11) DEFAULT NULL, + `impressions_30` int(11) DEFAULT NULL, + `clicks` int(11) DEFAULT NULL, + `clicks_30` int(11) DEFAULT NULL, + `ctr` decimal(20,6) DEFAULT NULL, + `cost` decimal(20,6) DEFAULT NULL, + `conversions` decimal(20,6) DEFAULT NULL, + `conversions_value` decimal(20,6) DEFAULT NULL, + `cpc` decimal(20,6) DEFAULT NULL, + `roas` decimal(20,0) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `product_id` (`product_id`) USING BTREE, + CONSTRAINT `FK_products_temp_products` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=298845 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Eksport danych został odznaczony. + +-- Zrzut struktury tabela host700513_adspro.settings +CREATE TABLE IF NOT EXISTS `settings` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `setting_key` varchar(100) NOT NULL, + `setting_value` text DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_setting_key` (`setting_key`) +) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci; + +-- Eksport danych został odznaczony. + +-- Zrzut struktury tabela host700513_adspro.users +CREATE TABLE IF NOT EXISTS `users` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `email` varchar(255) NOT NULL, + `password` varchar(255) NOT NULL, + `name` varchar(255) DEFAULT NULL, + `surname` varchar(255) DEFAULT NULL, + `default_project` int(11) DEFAULT NULL, + `color` varchar(50) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `email` (`email`) +) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_polish_ci; + +-- Eksport danych został odznaczony. + +/*!40103 SET TIME_ZONE=IFNULL(@OLD_TIME_ZONE, 'system') */; +/*!40101 SET SQL_MODE=IFNULL(@OLD_SQL_MODE, '') */; +/*!40014 SET FOREIGN_KEY_CHECKS=IFNULL(@OLD_FOREIGN_KEY_CHECKS, 1) */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40111 SET SQL_NOTES=IFNULL(@OLD_SQL_NOTES, 1) */; + +-- ================================ +-- DODANE: struktury kampanie > grupy/frazy +-- ================================ + +CREATE TABLE IF NOT EXISTS `campaign_ad_groups` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `campaign_id` int(11) NOT NULL, + `ad_group_id` bigint(20) NOT NULL, + `ad_group_name` varchar(255) NOT NULL DEFAULT '', + `impressions_30` int(11) NOT NULL DEFAULT 0, + `clicks_30` int(11) NOT NULL DEFAULT 0, + `cost_30` decimal(20,6) NOT NULL DEFAULT 0.000000, + `conversions_30` decimal(20,6) NOT NULL DEFAULT 0.000000, + `conversion_value_30` decimal(20,6) NOT NULL DEFAULT 0.000000, + `roas_30` decimal(20,6) NOT NULL DEFAULT 0.000000, + `impressions_all_time` int(11) NOT NULL DEFAULT 0, + `clicks_all_time` int(11) NOT NULL DEFAULT 0, + `cost_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000, + `conversions_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000, + `conversion_value_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000, + `roas_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000, + `date_sync` date DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_campaign_ad_groups_campaign_ad_group` (`campaign_id`,`ad_group_id`), + KEY `idx_campaign_ad_groups_campaign_id` (`campaign_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `campaign_search_terms` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `campaign_id` int(11) NOT NULL, + `ad_group_id` int(11) NOT NULL, + `search_term` varchar(255) NOT NULL, + `impressions_30` int(11) NOT NULL DEFAULT 0, + `clicks_30` int(11) NOT NULL DEFAULT 0, + `cost_30` decimal(20,6) NOT NULL DEFAULT 0.000000, + `conversions_30` decimal(20,6) NOT NULL DEFAULT 0.000000, + `conversion_value_30` decimal(20,6) NOT NULL DEFAULT 0.000000, + `roas_30` decimal(20,6) NOT NULL DEFAULT 0.000000, + `impressions_all_time` int(11) NOT NULL DEFAULT 0, + `clicks_all_time` int(11) NOT NULL DEFAULT 0, + `cost_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000, + `conversions_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000, + `conversion_value_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000, + `roas_all_time` decimal(20,6) NOT NULL DEFAULT 0.000000, + `date_sync` date DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_campaign_search_terms` (`campaign_id`,`ad_group_id`,`search_term`), + KEY `idx_campaign_search_terms_campaign_id` (`campaign_id`), + KEY `idx_campaign_search_terms_ad_group_id` (`ad_group_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `campaign_negative_keywords` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `campaign_id` int(11) NOT NULL, + `ad_group_id` int(11) DEFAULT NULL, + `scope` varchar(20) NOT NULL DEFAULT 'campaign', + `keyword_text` varchar(255) NOT NULL, + `match_type` varchar(40) DEFAULT NULL, + `date_sync` date DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_campaign_negative_keywords_campaign_id` (`campaign_id`), + KEY `idx_campaign_negative_keywords_ad_group_id` (`ad_group_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/install.php b/install.php new file mode 100644 index 0000000..6066112 --- /dev/null +++ b/install.php @@ -0,0 +1,195 @@ + 0 && substr( $trimmed, -$dl ) === $delimiter ) + { + return rtrim( substr( $trimmed, 0, -$dl ) ); + } + + return $trimmed; +} + +function execute_sql_file( PDO $pdo, $file_path ) +{ + $handle = fopen( $file_path, 'r' ); + if ( !$handle ) + { + throw new RuntimeException( 'Nie mozna otworzyc pliku: ' . $file_path ); + } + + $delimiter = ';'; + $buffer = ''; + + while ( ( $line = fgets( $handle ) ) !== false ) + { + if ( preg_match( '/^\s*DELIMITER\s+(\S+)\s*$/i', $line, $m ) ) + { + $delimiter = $m[1]; + continue; + } + + $trim = trim( $line ); + if ( $trim !== '' && preg_match( '/^\s*(--|#)/', $trim ) ) + { + continue; + } + + $buffer .= $line; + + if ( statement_complete( $buffer, $delimiter ) ) + { + $statement = strip_statement_delimiter( $buffer, $delimiter ); + $statement = trim( $statement ); + + if ( $statement !== '' ) + { + $pdo -> exec( $statement ); + } + + $buffer = ''; + } + } + + fclose( $handle ); + + $tail = trim( $buffer ); + if ( $tail !== '' ) + { + $pdo -> exec( $tail ); + } +} + +try +{ + $dsn = 'mysql:host=' . $database['host'] . ';dbname=' . $database['name'] . ';charset=utf8'; + $pdo = new PDO( $dsn, $database['user'], $database['password'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true, + ] ); + + $pdo -> exec( + 'CREATE TABLE IF NOT EXISTS `schema_migrations` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `filename` VARCHAR(255) NOT NULL, + `applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_schema_migrations_filename` (`filename`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8' + ); + + $include_demo = has_flag( 'with_demo' ); + $force = has_flag( 'force' ); + + $files = glob( __DIR__ . '/migrations/*.sql' ); + sort( $files, SORT_NATURAL | SORT_FLAG_CASE ); + + $selected = []; + foreach ( $files as $file_path ) + { + $filename = basename( $file_path ); + + if ( preg_match( '/^\d+_.+\.sql$/', $filename ) ) + { + $selected[] = $file_path; + continue; + } + + if ( $include_demo && $filename === 'demo_data.sql' ) + { + $selected[] = $file_path; + } + } + + if ( empty( $selected ) ) + { + echo "Brak migracji do uruchomienia.\n"; + exit; + } + + echo "Uruchamianie migracji...\n"; + echo 'Tryb force: ' . ( $force ? 'TAK' : 'NIE' ) . "\n"; + echo 'Demo data: ' . ( $include_demo ? 'TAK' : 'NIE' ) . "\n\n"; + + $applied_count = 0; + $skipped_count = 0; + + foreach ( $selected as $file_path ) + { + $filename = basename( $file_path ); + + $stmt = $pdo -> prepare( 'SELECT COUNT(1) FROM schema_migrations WHERE filename = :filename' ); + $stmt -> execute( [ ':filename' => $filename ] ); + $already_applied = (int) $stmt -> fetchColumn() > 0; + + if ( $already_applied && !$force ) + { + echo '[SKIP] ' . $filename . " (juz zastosowana)\n"; + $skipped_count++; + continue; + } + + execute_sql_file( $pdo, $file_path ); + + $stmt = $pdo -> prepare( + 'INSERT INTO schema_migrations (filename, applied_at) + VALUES (:filename, NOW()) + ON DUPLICATE KEY UPDATE applied_at = NOW()' + ); + $stmt -> execute( [ ':filename' => $filename ] ); + + echo '[OK] ' . $filename . "\n"; + $applied_count++; + } + + echo "\nZakonczono.\n"; + echo 'Zastosowano: ' . $applied_count . "\n"; + echo 'Pominieto: ' . $skipped_count . "\n"; +} +catch ( Throwable $e ) +{ + http_response_code( 500 ); + echo '[BLAD] ' . $e -> getMessage() . "\n"; + exit( 1 ); +} diff --git a/layout/style.css b/layout/style.css index 64838c2..99cf33f 100644 --- a/layout/style.css +++ b/layout/style.css @@ -1595,12 +1595,14 @@ table#products a.custom_name { color: #718096; } -.jconfirm-box .form-group .select2-container { +.jconfirm-box .form-group .select2-container, +.adspro-dialog-box .form-group .select2-container { width: 100% !important; margin-top: 8px; } -.jconfirm-box .select2-container--default .select2-selection--single { +.jconfirm-box .select2-container--default .select2-selection--single, +.adspro-dialog-box .select2-container--default .select2-selection--single { background-color: #FFFFFF; border: 1px solid #E2E8F0; border-radius: 6px; @@ -1613,42 +1615,50 @@ table#products a.custom_name { font-size: 14px; } -.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__rendered { +.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__rendered, +.adspro-dialog-box .select2-container--default .select2-selection--single .select2-selection__rendered { padding-left: 0; line-height: 1.4; color: #495057; } -.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__placeholder { +.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__placeholder, +.adspro-dialog-box .select2-container--default .select2-selection--single .select2-selection__placeholder { color: #CBD5E0; } -.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__arrow { +.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__arrow, +.adspro-dialog-box .select2-container--default .select2-selection--single .select2-selection__arrow { height: 100%; right: 8px; } .jconfirm-box .select2-container--default.select2-container--focus .select2-selection--single, -.jconfirm-box .select2-container--default .select2-selection--single:hover { +.jconfirm-box .select2-container--default .select2-selection--single:hover, +.adspro-dialog-box .select2-container--default.select2-container--focus .select2-selection--single, +.adspro-dialog-box .select2-container--default .select2-selection--single:hover { border-color: #6690F4; box-shadow: 0 0 0 3px rgba(102, 144, 244, 0.1); outline: 0; } -.jconfirm-box .select2-container .select2-dropdown { +.jconfirm-box .select2-container .select2-dropdown, +.adspro-dialog-box .select2-container .select2-dropdown { border-color: #E2E8F0; border-radius: 0 0 6px 6px; font-size: 14px; } -.jconfirm-box .select2-container .select2-search--dropdown .select2-search__field { +.jconfirm-box .select2-container .select2-search--dropdown .select2-search__field, +.adspro-dialog-box .select2-container .select2-search--dropdown .select2-search__field { padding: 6px 10px; border-radius: 4px; border: 1px solid #E2E8F0; font-size: 14px; } -.jconfirm-box .select2-container--default .select2-results__option--highlighted[aria-selected] { +.jconfirm-box .select2-container--default .select2-results__option--highlighted[aria-selected], +.adspro-dialog-box .select2-container--default .select2-results__option--highlighted[aria-selected] { background-color: #6690F4; color: #FFFFFF; } @@ -1664,3 +1674,5 @@ table#products a.custom_name { margin-left: 0 !important; } } + +/*# sourceMappingURL=style.css.map */ diff --git a/layout/style.css.map b/layout/style.css.map index 5f7207b..6e9e74d 100644 --- a/layout/style.css.map +++ b/layout/style.css.map @@ -1 +1 @@ -{"version":3,"sources":["style.scss"],"names":[],"mappings":"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","file":"style.css","sourcesContent":["// === adsPRO - Nowe style ===\r\n\r\n// --- Zmienne ---\r\n$cPrimary: #6690F4;\r\n$cPrimaryDark: #3164db;\r\n$cSidebarBg: #1E2A3A;\r\n$cSidebarText: #A8B7C7;\r\n$cSidebarHover: #263548;\r\n$cSidebarActive: $cPrimary;\r\n$cContentBg: #F4F6F9;\r\n$cWhite: #FFFFFF;\r\n$cText: #4E5E6A;\r\n$cTextDark: #2D3748;\r\n$cBorder: #E2E8F0;\r\n$cSuccess: #57B951;\r\n$cSuccessDark: #4a9c3b;\r\n$cDanger: #CC0000;\r\n$cDangerDark: #b30000;\r\n$cWarning: #FF8C00;\r\n$cGreenLight: #57b951;\r\n\r\n$sidebarWidth: 260px;\r\n$sidebarCollapsed: 70px;\r\n$topbarHeight: 56px;\r\n$transitionSpeed: 0.3s;\r\n\r\n// --- Reset i baza ---\r\n* {\r\n box-sizing: border-box;\r\n}\r\n\r\nbody {\r\n font-family: \"Open Sans\", sans-serif;\r\n margin: 0;\r\n padding: 0;\r\n font-size: 14px;\r\n color: $cText;\r\n background: $cContentBg;\r\n}\r\n\r\n.hide {\r\n display: none;\r\n}\r\n\r\n// --- Typografia ---\r\nsmall {\r\n font-size: .75em;\r\n}\r\n\r\n.text-right {\r\n text-align: right;\r\n}\r\n\r\n.text-bold {\r\n font-weight: 700 !important;\r\n}\r\n\r\n.nowrap {\r\n white-space: nowrap;\r\n}\r\n\r\n// ===========================\r\n// LOGIN PAGE (unlogged)\r\n// ===========================\r\nbody.unlogged {\r\n background: $cContentBg;\r\n margin: 0;\r\n padding: 0;\r\n}\r\n\r\n.login-container {\r\n display: flex;\r\n min-height: 100vh;\r\n}\r\n\r\n.login-brand {\r\n flex: 0 0 45%;\r\n background: linear-gradient(135deg, $cSidebarBg 0%, #2C3E57 50%, $cPrimary 100%);\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n padding: 60px;\r\n position: relative;\r\n overflow: hidden;\r\n\r\n &::before {\r\n content: '';\r\n position: absolute;\r\n top: -50%;\r\n right: -50%;\r\n width: 100%;\r\n height: 100%;\r\n background: radial-gradient(circle, rgba($cPrimary, 0.15) 0%, transparent 70%);\r\n border-radius: 50%;\r\n }\r\n\r\n .brand-content {\r\n position: relative;\r\n z-index: 1;\r\n color: $cWhite;\r\n max-width: 400px;\r\n }\r\n\r\n .brand-logo {\r\n font-size: 48px;\r\n font-weight: 300;\r\n margin-bottom: 20px;\r\n letter-spacing: -1px;\r\n\r\n strong {\r\n font-weight: 700;\r\n }\r\n }\r\n\r\n .brand-tagline {\r\n font-size: 18px;\r\n opacity: 0.85;\r\n line-height: 1.6;\r\n margin-bottom: 50px;\r\n }\r\n\r\n .brand-features {\r\n .feature {\r\n display: flex;\r\n align-items: center;\r\n gap: 15px;\r\n margin-bottom: 20px;\r\n opacity: 0.8;\r\n\r\n i {\r\n font-size: 20px;\r\n width: 40px;\r\n height: 40px;\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n background: rgba($cWhite, 0.1);\r\n border-radius: 10px;\r\n }\r\n\r\n span {\r\n font-size: 15px;\r\n }\r\n }\r\n }\r\n}\r\n\r\n.login-form-wrapper {\r\n flex: 1;\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n padding: 60px;\r\n background: $cWhite;\r\n}\r\n\r\n.login-box {\r\n width: 100%;\r\n max-width: 420px;\r\n\r\n .login-header {\r\n margin-bottom: 35px;\r\n\r\n h1 {\r\n font-size: 28px;\r\n font-weight: 700;\r\n color: $cTextDark;\r\n margin: 0 0 8px;\r\n }\r\n\r\n p {\r\n color: #718096;\r\n font-size: 15px;\r\n margin: 0;\r\n }\r\n }\r\n\r\n .form-group {\r\n margin-bottom: 20px;\r\n\r\n label {\r\n display: block;\r\n font-size: 13px;\r\n font-weight: 600;\r\n color: $cTextDark;\r\n margin-bottom: 6px;\r\n }\r\n }\r\n\r\n .input-with-icon {\r\n position: relative;\r\n\r\n i {\r\n position: absolute;\r\n left: 14px;\r\n top: 50%;\r\n transform: translateY(-50%);\r\n color: #A0AEC0;\r\n font-size: 14px;\r\n }\r\n\r\n .form-control {\r\n padding-left: 42px;\r\n }\r\n }\r\n\r\n .form-control {\r\n width: 100%;\r\n height: 46px;\r\n border: 2px solid $cBorder;\r\n border-radius: 8px;\r\n padding: 0 14px;\r\n font-size: 14px;\r\n font-family: \"Open Sans\", sans-serif;\r\n color: $cTextDark;\r\n transition: border-color $transitionSpeed, box-shadow $transitionSpeed;\r\n\r\n &::placeholder {\r\n color: #CBD5E0;\r\n }\r\n\r\n &:focus {\r\n border-color: $cPrimary;\r\n box-shadow: 0 0 0 3px rgba($cPrimary, 0.15);\r\n outline: none;\r\n }\r\n }\r\n\r\n .form-error {\r\n color: $cDanger;\r\n font-size: 12px;\r\n margin-top: 4px;\r\n }\r\n\r\n .checkbox-group {\r\n .checkbox-label {\r\n display: flex;\r\n align-items: center;\r\n gap: 8px;\r\n cursor: pointer;\r\n font-size: 13px;\r\n color: #718096;\r\n font-weight: 400;\r\n\r\n input[type=\"checkbox\"] {\r\n width: 16px;\r\n height: 16px;\r\n accent-color: $cPrimary;\r\n }\r\n }\r\n }\r\n\r\n .btn-login {\r\n width: 100%;\r\n height: 48px;\r\n font-size: 15px;\r\n font-weight: 600;\r\n border-radius: 8px;\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n gap: 8px;\r\n\r\n &.disabled {\r\n opacity: 0.7;\r\n pointer-events: none;\r\n }\r\n }\r\n\r\n .alert {\r\n display: none;\r\n padding: 12px 16px;\r\n border-radius: 8px;\r\n font-size: 13px;\r\n margin-bottom: 20px;\r\n\r\n &.alert-danger {\r\n background: #FFF5F5;\r\n color: $cDanger;\r\n border: 1px solid #FED7D7;\r\n }\r\n\r\n &.alert-success {\r\n background: #F0FFF4;\r\n color: #276749;\r\n border: 1px solid #C6F6D5;\r\n }\r\n }\r\n}\r\n\r\n// Responsywność logowania\r\n@media (max-width: 768px) {\r\n .login-brand {\r\n display: none;\r\n }\r\n\r\n .login-form-wrapper {\r\n padding: 30px 20px;\r\n }\r\n}\r\n\r\n// ===========================\r\n// LAYOUT (logged) - SIDEBAR\r\n// ===========================\r\nbody.logged {\r\n display: flex;\r\n min-height: 100vh;\r\n background: $cContentBg;\r\n}\r\n\r\n// --- Sidebar ---\r\n.sidebar {\r\n width: $sidebarWidth;\r\n min-height: 100vh;\r\n background: $cSidebarBg;\r\n position: fixed;\r\n top: 0;\r\n left: 0;\r\n z-index: 1000;\r\n display: flex;\r\n flex-direction: column;\r\n transition: width $transitionSpeed ease;\r\n overflow: hidden;\r\n\r\n &.collapsed {\r\n width: $sidebarCollapsed;\r\n\r\n .sidebar-header {\r\n padding: 16px 0;\r\n justify-content: center;\r\n\r\n .sidebar-logo {\r\n display: none;\r\n }\r\n\r\n .sidebar-toggle i {\r\n transform: rotate(180deg);\r\n }\r\n }\r\n\r\n .sidebar-nav ul li a {\r\n padding: 12px 0;\r\n justify-content: center;\r\n\r\n span {\r\n display: none;\r\n }\r\n\r\n i {\r\n margin-right: 0;\r\n font-size: 18px;\r\n }\r\n }\r\n\r\n .sidebar-footer {\r\n .sidebar-user {\r\n justify-content: center;\r\n\r\n .user-info {\r\n display: none;\r\n }\r\n }\r\n\r\n .sidebar-logout {\r\n justify-content: center;\r\n\r\n span {\r\n display: none;\r\n }\r\n }\r\n }\r\n\r\n .nav-divider {\r\n margin: 8px 15px;\r\n }\r\n }\r\n}\r\n\r\n.sidebar-header {\r\n display: flex;\r\n align-items: center;\r\n justify-content: space-between;\r\n padding: 20px 20px 16px;\r\n border-bottom: 1px solid rgba($cWhite, 0.08);\r\n\r\n .sidebar-logo a {\r\n color: $cWhite;\r\n text-decoration: none;\r\n font-size: 24px;\r\n font-weight: 300;\r\n letter-spacing: -0.5px;\r\n\r\n strong {\r\n font-weight: 700;\r\n }\r\n }\r\n\r\n .sidebar-toggle {\r\n background: none;\r\n border: none;\r\n color: $cSidebarText;\r\n cursor: pointer;\r\n padding: 6px;\r\n border-radius: 6px;\r\n transition: all $transitionSpeed;\r\n\r\n &:hover {\r\n background: rgba($cWhite, 0.08);\r\n color: $cWhite;\r\n }\r\n\r\n i {\r\n transition: transform $transitionSpeed;\r\n }\r\n }\r\n}\r\n\r\n.sidebar-nav {\r\n flex: 1;\r\n padding: 12px 0;\r\n overflow-y: auto;\r\n\r\n ul {\r\n list-style: none;\r\n margin: 0;\r\n padding: 0;\r\n\r\n li {\r\n &.nav-divider {\r\n height: 1px;\r\n background: rgba($cWhite, 0.08);\r\n margin: 8px 20px;\r\n }\r\n\r\n a {\r\n display: flex;\r\n align-items: center;\r\n padding: 11px 20px;\r\n color: $cSidebarText;\r\n text-decoration: none;\r\n font-size: 14px;\r\n transition: all 0.2s;\r\n border-left: 3px solid transparent;\r\n\r\n i {\r\n width: 20px;\r\n text-align: center;\r\n margin-right: 12px;\r\n font-size: 15px;\r\n }\r\n\r\n &:hover {\r\n background: $cSidebarHover;\r\n color: $cWhite;\r\n }\r\n }\r\n\r\n &.active>a {\r\n background: rgba($cPrimary, 0.15);\r\n color: $cWhite;\r\n border-left-color: $cPrimary;\r\n\r\n i {\r\n color: $cPrimary;\r\n }\r\n }\r\n }\r\n }\r\n}\r\n\r\n.sidebar-footer {\r\n padding: 16px 20px;\r\n border-top: 1px solid rgba($cWhite, 0.08);\r\n\r\n .sidebar-user {\r\n display: flex;\r\n align-items: center;\r\n gap: 10px;\r\n margin-bottom: 12px;\r\n\r\n .user-avatar {\r\n width: 34px;\r\n height: 34px;\r\n border-radius: 50%;\r\n background: rgba($cPrimary, 0.2);\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n color: $cPrimary;\r\n font-size: 14px;\r\n flex-shrink: 0;\r\n }\r\n\r\n .user-info {\r\n overflow: hidden;\r\n\r\n .user-email {\r\n color: $cSidebarText;\r\n font-size: 12px;\r\n display: block;\r\n white-space: nowrap;\r\n overflow: hidden;\r\n text-overflow: ellipsis;\r\n }\r\n }\r\n }\r\n\r\n .sidebar-logout {\r\n display: flex;\r\n align-items: center;\r\n gap: 8px;\r\n color: #E53E3E;\r\n text-decoration: none;\r\n font-size: 13px;\r\n padding: 8px 10px;\r\n border-radius: 6px;\r\n transition: all 0.2s;\r\n\r\n i {\r\n font-size: 14px;\r\n }\r\n\r\n &:hover {\r\n background: rgba(#E53E3E, 0.1);\r\n }\r\n }\r\n}\r\n\r\n// --- Main wrapper ---\r\n.main-wrapper {\r\n margin-left: $sidebarWidth;\r\n flex: 1;\r\n min-height: 100vh;\r\n transition: margin-left $transitionSpeed ease;\r\n display: flex;\r\n flex-direction: column;\r\n\r\n &.expanded {\r\n margin-left: $sidebarCollapsed;\r\n }\r\n}\r\n\r\n// --- Topbar ---\r\n.topbar {\r\n height: $topbarHeight;\r\n background: $cWhite;\r\n border-bottom: 1px solid $cBorder;\r\n display: flex;\r\n align-items: center;\r\n padding: 0 25px;\r\n position: sticky;\r\n top: 0;\r\n z-index: 500;\r\n\r\n .topbar-toggle {\r\n background: none;\r\n border: none;\r\n color: $cText;\r\n cursor: pointer;\r\n padding: 8px 10px;\r\n border-radius: 6px;\r\n font-size: 16px;\r\n margin-right: 15px;\r\n transition: all 0.2s;\r\n\r\n &:hover {\r\n background: $cContentBg;\r\n }\r\n }\r\n\r\n .topbar-breadcrumb {\r\n font-size: 16px;\r\n font-weight: 600;\r\n color: $cTextDark;\r\n }\r\n}\r\n\r\n// --- Content area ---\r\n.content {\r\n flex: 1;\r\n padding: 25px;\r\n}\r\n\r\n.app-alert {\r\n background: #EBF8FF;\r\n border: 1px solid #BEE3F8;\r\n color: #2B6CB0;\r\n padding: 12px 16px;\r\n border-radius: 8px;\r\n margin-bottom: 20px;\r\n font-size: 14px;\r\n}\r\n\r\n// ===========================\r\n// KOMPONENTY WSPÓLNE\r\n// ===========================\r\n\r\n// --- Buttons ---\r\n.btn {\r\n padding: 10px 20px;\r\n transition: all 0.2s ease;\r\n color: $cWhite;\r\n border: 0;\r\n border-radius: 6px;\r\n cursor: pointer;\r\n display: inline-flex;\r\n text-decoration: none;\r\n gap: 6px;\r\n justify-content: center;\r\n align-items: center;\r\n font-size: 14px;\r\n font-family: \"Open Sans\", sans-serif;\r\n font-weight: 500;\r\n\r\n &.btn_small,\r\n &.btn-xs,\r\n &.btn-sm {\r\n padding: 5px 10px;\r\n font-size: 12px;\r\n\r\n i {\r\n font-size: 11px;\r\n }\r\n }\r\n\r\n &.btn-success {\r\n background: $cSuccess;\r\n\r\n &:hover {\r\n background: $cSuccessDark;\r\n }\r\n }\r\n\r\n &.btn-primary {\r\n background: $cPrimary;\r\n\r\n &:hover {\r\n background: $cPrimaryDark;\r\n }\r\n }\r\n\r\n &.btn-danger {\r\n background: $cDanger;\r\n\r\n &:hover {\r\n background: $cDangerDark;\r\n }\r\n }\r\n\r\n &.disabled {\r\n opacity: 0.6;\r\n pointer-events: none;\r\n }\r\n}\r\n\r\n// --- Form controls ---\r\n.form-control {\r\n border: 1px solid $cBorder;\r\n border-radius: 6px;\r\n height: 38px;\r\n width: 100%;\r\n padding: 6px 12px;\r\n font-family: \"Open Sans\", sans-serif;\r\n font-size: 14px;\r\n color: $cTextDark;\r\n transition: border-color 0.2s, box-shadow 0.2s;\r\n\r\n option {\r\n padding: 5px;\r\n }\r\n\r\n &:focus {\r\n border-color: $cPrimary;\r\n box-shadow: 0 0 0 3px rgba($cPrimary, 0.1);\r\n outline: none;\r\n }\r\n}\r\n\r\ninput[type=\"checkbox\"] {\r\n border: 1px solid $cBorder;\r\n}\r\n\r\n// --- Tables ---\r\ntable {\r\n border-collapse: collapse;\r\n font-size: 13px;\r\n}\r\n\r\n.table {\r\n width: 100%;\r\n\r\n th,\r\n td {\r\n border: 1px solid $cBorder;\r\n padding: 8px 10px;\r\n }\r\n\r\n th {\r\n background: #F7FAFC;\r\n font-weight: 600;\r\n font-size: 12px;\r\n text-transform: uppercase;\r\n letter-spacing: 0.03em;\r\n color: #718096;\r\n }\r\n\r\n td.center {\r\n text-align: center;\r\n }\r\n\r\n td.left {\r\n text-align: left;\r\n }\r\n\r\n &.table-sm td {\r\n padding: 5px !important;\r\n }\r\n\r\n input.form-control {\r\n font-size: 13px;\r\n height: 32px;\r\n }\r\n}\r\n\r\n// --- Cards ---\r\n.card {\r\n background: $cWhite;\r\n padding: 20px;\r\n border-radius: 8px;\r\n color: $cTextDark;\r\n font-size: 14px;\r\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);\r\n\r\n &.mb25 {\r\n margin-bottom: 20px;\r\n }\r\n\r\n .card-header {\r\n font-weight: 600;\r\n font-size: 15px;\r\n }\r\n\r\n .card-body {\r\n padding-top: 12px;\r\n\r\n table {\r\n\r\n th,\r\n td {\r\n font-size: 13px;\r\n\r\n &.bold {\r\n font-weight: 600;\r\n }\r\n\r\n &.text-right {\r\n text-align: right;\r\n }\r\n\r\n &.text-center {\r\n text-align: center;\r\n }\r\n }\r\n }\r\n }\r\n}\r\n\r\n// --- Action menu ---\r\n.action_menu {\r\n display: flex;\r\n margin-bottom: 20px;\r\n gap: 12px;\r\n\r\n .btn {\r\n padding: 8px 16px;\r\n\r\n &.btn_add {\r\n background: $cSuccess;\r\n\r\n &:hover {\r\n background: $cSuccessDark;\r\n }\r\n }\r\n\r\n &.btn_cancel {\r\n background: $cDanger;\r\n\r\n &:hover {\r\n background: $cDangerDark;\r\n }\r\n }\r\n }\r\n}\r\n\r\n// --- Settings page ---\r\n.settings-card {\r\n background: $cWhite;\r\n border-radius: 10px;\r\n padding: 28px;\r\n box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);\r\n\r\n .settings-card-header {\r\n display: flex;\r\n align-items: center;\r\n gap: 14px;\r\n margin-bottom: 24px;\r\n padding-bottom: 16px;\r\n border-bottom: 1px solid $cBorder;\r\n\r\n .settings-card-icon {\r\n width: 44px;\r\n height: 44px;\r\n border-radius: 10px;\r\n background: lighten($cPrimary, 26%);\r\n color: $cPrimary;\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n font-size: 18px;\r\n flex-shrink: 0;\r\n }\r\n\r\n h3 {\r\n margin: 0;\r\n font-size: 17px;\r\n font-weight: 600;\r\n color: $cTextDark;\r\n }\r\n\r\n small {\r\n color: #8899A6;\r\n font-size: 13px;\r\n }\r\n }\r\n\r\n .settings-field {\r\n margin-bottom: 18px;\r\n\r\n label {\r\n display: block;\r\n font-size: 13px;\r\n font-weight: 600;\r\n color: $cTextDark;\r\n margin-bottom: 6px;\r\n }\r\n }\r\n\r\n .settings-input-wrap {\r\n position: relative;\r\n\r\n .settings-input-icon {\r\n position: absolute;\r\n left: 12px;\r\n top: 50%;\r\n transform: translateY(-50%);\r\n color: #A0AEC0;\r\n font-size: 14px;\r\n pointer-events: none;\r\n }\r\n\r\n .form-control {\r\n padding-left: 38px;\r\n }\r\n\r\n .settings-toggle-pw {\r\n position: absolute;\r\n right: 4px;\r\n top: 50%;\r\n transform: translateY(-50%);\r\n background: none;\r\n border: none;\r\n color: #A0AEC0;\r\n cursor: pointer;\r\n padding: 6px 10px;\r\n font-size: 14px;\r\n transition: color 0.2s;\r\n\r\n &:hover {\r\n color: $cPrimary;\r\n }\r\n }\r\n }\r\n\r\n .settings-fields-grid {\r\n display: grid;\r\n grid-template-columns: 1fr 1fr;\r\n gap: 0 24px;\r\n\r\n @media (max-width: 768px) {\r\n grid-template-columns: 1fr;\r\n }\r\n }\r\n\r\n .settings-alert-error {\r\n display: flex;\r\n align-items: center;\r\n gap: 10px;\r\n background: #FFF5F5;\r\n color: $cDanger;\r\n border: 1px solid #FED7D7;\r\n border-radius: 8px;\r\n padding: 12px 16px;\r\n margin-bottom: 20px;\r\n font-size: 13px;\r\n\r\n i {\r\n font-size: 16px;\r\n flex-shrink: 0;\r\n }\r\n }\r\n}\r\n\r\n// --- Clients page ---\r\n.clients-page {\r\n .clients-header {\r\n display: flex;\r\n justify-content: space-between;\r\n align-items: center;\r\n margin-bottom: 20px;\r\n\r\n h2 {\r\n margin: 0;\r\n font-size: 20px;\r\n font-weight: 600;\r\n color: $cTextDark;\r\n\r\n i {\r\n color: $cPrimary;\r\n margin-right: 8px;\r\n }\r\n }\r\n }\r\n\r\n .clients-table-wrap {\r\n background: $cWhite;\r\n border-radius: 10px;\r\n box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);\r\n overflow: hidden;\r\n\r\n .table {\r\n margin: 0;\r\n\r\n thead th {\r\n background: #F8FAFC;\r\n border-bottom: 2px solid $cBorder;\r\n font-size: 12px;\r\n font-weight: 700;\r\n text-transform: uppercase;\r\n letter-spacing: 0.5px;\r\n color: #8899A6;\r\n padding: 14px 20px;\r\n }\r\n\r\n tbody td {\r\n padding: 14px 20px;\r\n vertical-align: middle;\r\n border-bottom: 1px solid #F1F5F9;\r\n }\r\n\r\n tbody tr:hover {\r\n background: #F8FAFC;\r\n }\r\n\r\n .client-id {\r\n color: #8899A6;\r\n font-size: 13px;\r\n font-weight: 600;\r\n }\r\n\r\n .client-name {\r\n font-weight: 600;\r\n color: $cTextDark;\r\n }\r\n }\r\n }\r\n\r\n .badge-id {\r\n display: inline-block;\r\n background: #EEF2FF;\r\n color: $cPrimary;\r\n font-size: 13px;\r\n font-weight: 600;\r\n padding: 4px 10px;\r\n border-radius: 6px;\r\n font-family: monospace;\r\n }\r\n\r\n .actions-cell {\r\n text-align: center;\r\n white-space: nowrap;\r\n }\r\n\r\n .btn-icon {\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n width: 34px;\r\n height: 34px;\r\n border-radius: 8px;\r\n border: none;\r\n cursor: pointer;\r\n font-size: 14px;\r\n transition: all 0.2s;\r\n margin: 0 2px;\r\n\r\n &.btn-icon-edit {\r\n background: #EEF2FF;\r\n color: $cPrimary;\r\n\r\n &:hover {\r\n background: $cPrimary;\r\n color: $cWhite;\r\n }\r\n }\r\n\r\n &.btn-icon-delete {\r\n background: #FFF5F5;\r\n color: $cDanger;\r\n\r\n &:hover {\r\n background: $cDanger;\r\n color: $cWhite;\r\n }\r\n }\r\n }\r\n\r\n .empty-state {\r\n text-align: center;\r\n padding: 50px 20px !important;\r\n color: #A0AEC0;\r\n\r\n i {\r\n font-size: 40px;\r\n margin-bottom: 12px;\r\n display: block;\r\n }\r\n\r\n p {\r\n margin: 0;\r\n font-size: 15px;\r\n }\r\n }\r\n}\r\n\r\n.btn-secondary {\r\n background: #E2E8F0;\r\n color: $cTextDark;\r\n border: none;\r\n padding: 8px 18px;\r\n border-radius: 6px;\r\n font-size: 14px;\r\n cursor: pointer;\r\n transition: background 0.2s;\r\n\r\n &:hover {\r\n background: #CBD5E0;\r\n }\r\n}\r\n\r\n// ===========================\r\n// CAMPAIGNS PAGE\r\n// ===========================\r\n.campaigns-page {\r\n .campaigns-header {\r\n display: flex;\r\n justify-content: space-between;\r\n align-items: center;\r\n margin-bottom: 20px;\r\n\r\n h2 {\r\n margin: 0;\r\n font-size: 20px;\r\n font-weight: 600;\r\n color: $cTextDark;\r\n\r\n i {\r\n color: $cPrimary;\r\n margin-right: 8px;\r\n }\r\n }\r\n }\r\n\r\n .campaigns-filters {\r\n display: flex;\r\n gap: 20px;\r\n margin-bottom: 20px;\r\n\r\n .filter-group {\r\n flex: 1;\r\n\r\n label {\r\n display: block;\r\n font-size: 12px;\r\n font-weight: 700;\r\n text-transform: uppercase;\r\n letter-spacing: 0.5px;\r\n color: #8899A6;\r\n margin-bottom: 6px;\r\n\r\n i {\r\n margin-right: 4px;\r\n }\r\n }\r\n\r\n .form-control {\r\n width: 100%;\r\n padding: 10px 14px;\r\n border: 1px solid $cBorder;\r\n border-radius: 8px;\r\n font-size: 14px;\r\n color: $cTextDark;\r\n background: $cWhite;\r\n transition: border-color 0.2s;\r\n appearance: none;\r\n -webkit-appearance: none;\r\n background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238899A6' d='M6 8L1 3h10z'/%3E%3C/svg%3E\");\r\n background-repeat: no-repeat;\r\n background-position: right 12px center;\r\n padding-right: 32px;\r\n\r\n &:focus {\r\n outline: none;\r\n border-color: $cPrimary;\r\n box-shadow: 0 0 0 3px rgba($cPrimary, 0.1);\r\n }\r\n }\r\n\r\n .filter-with-action {\r\n display: flex;\r\n gap: 8px;\r\n\r\n .form-control {\r\n flex: 1;\r\n }\r\n\r\n .btn-icon {\r\n flex-shrink: 0;\r\n width: 42px;\r\n height: 42px;\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n border-radius: 8px;\r\n border: none;\r\n cursor: pointer;\r\n font-size: 14px;\r\n transition: all 0.2s;\r\n\r\n &.btn-icon-delete {\r\n background: #FFF5F5;\r\n color: $cDanger;\r\n\r\n &:hover {\r\n background: $cDanger;\r\n color: $cWhite;\r\n }\r\n }\r\n }\r\n }\r\n }\r\n }\r\n\r\n .campaigns-chart-wrap {\r\n background: $cWhite;\r\n border-radius: 10px;\r\n box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);\r\n padding: 20px;\r\n margin-bottom: 20px;\r\n min-height: 350px;\r\n }\r\n\r\n .campaigns-table-wrap {\r\n background: $cWhite;\r\n border-radius: 10px;\r\n box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);\r\n overflow: hidden;\r\n\r\n .table {\r\n margin: 0;\r\n width: 100% !important;\r\n\r\n thead th {\r\n background: #F8FAFC;\r\n border-bottom: 2px solid $cBorder;\r\n font-size: 12px;\r\n font-weight: 700;\r\n text-transform: uppercase;\r\n letter-spacing: 0.5px;\r\n color: #8899A6;\r\n padding: 12px 16px;\r\n white-space: nowrap;\r\n }\r\n\r\n tbody td {\r\n padding: 10px 16px;\r\n vertical-align: middle;\r\n border-bottom: 1px solid #F1F5F9;\r\n font-size: 13px;\r\n }\r\n\r\n tbody tr:hover {\r\n background: #F8FAFC;\r\n }\r\n }\r\n\r\n // DataTables 2.x overrides\r\n .dt-layout-row {\r\n padding: 14px 20px;\r\n margin: 0 !important;\r\n border-top: 1px solid #F1F5F9;\r\n\r\n // Ukryj wiersz z search/length jeśli pusty\r\n &:first-child {\r\n display: none;\r\n }\r\n }\r\n\r\n .dt-info {\r\n font-size: 13px;\r\n color: #8899A6;\r\n }\r\n\r\n .dt-paging {\r\n .pagination {\r\n margin: 0;\r\n padding: 0;\r\n list-style: none;\r\n display: flex;\r\n align-items: center;\r\n gap: 6px;\r\n\r\n .page-item {\r\n .page-link {\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n min-width: 36px;\r\n width: fit-content;\r\n height: 36px;\r\n padding: 0 14px;\r\n border-radius: 8px;\r\n font-size: 13px;\r\n font-weight: 500;\r\n border: 1px solid $cBorder;\r\n background: $cWhite;\r\n color: $cText;\r\n cursor: pointer;\r\n transition: all 0.2s;\r\n text-decoration: none;\r\n line-height: 1;\r\n white-space: nowrap;\r\n\r\n &:hover {\r\n background: #EEF2FF;\r\n color: $cPrimary;\r\n border-color: $cPrimary;\r\n }\r\n }\r\n\r\n &.active .page-link {\r\n background: $cPrimary;\r\n color: $cWhite;\r\n border-color: $cPrimary;\r\n font-weight: 600;\r\n }\r\n\r\n &.disabled .page-link {\r\n opacity: 0.35;\r\n cursor: default;\r\n pointer-events: none;\r\n }\r\n }\r\n }\r\n }\r\n\r\n .dt-processing {\r\n background: rgba($cWhite, 0.9);\r\n color: $cText;\r\n font-size: 14px;\r\n }\r\n }\r\n\r\n .delete-history-entry {\r\n display: inline-flex;\r\n align-items: center;\r\n justify-content: center;\r\n width: 30px;\r\n height: 30px;\r\n border-radius: 6px;\r\n border: none;\r\n cursor: pointer;\r\n font-size: 12px;\r\n background: #FFF5F5;\r\n color: $cDanger;\r\n transition: all 0.2s;\r\n\r\n &:hover {\r\n background: $cDanger;\r\n color: $cWhite;\r\n }\r\n }\r\n}\r\n\r\n// --- Form container ---\r\n.form_container {\r\n background: $cWhite;\r\n padding: 25px;\r\n max-width: 1300px;\r\n border-radius: 8px;\r\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);\r\n\r\n &.full {\r\n max-width: 100%;\r\n }\r\n\r\n .form_group {\r\n margin-bottom: 12px;\r\n display: flex;\r\n\r\n >.label {\r\n width: 300px;\r\n display: inline-flex;\r\n align-items: flex-start;\r\n justify-content: right;\r\n padding-right: 12px;\r\n }\r\n\r\n .input {\r\n width: calc(100% - 300px);\r\n }\r\n }\r\n}\r\n\r\n// --- Default popup ---\r\n.default_popup {\r\n position: fixed;\r\n top: 0;\r\n left: 0;\r\n width: 100%;\r\n height: 100%;\r\n background: rgba(0, 0, 0, 0.45);\r\n display: none;\r\n z-index: 2000;\r\n\r\n .popup_content {\r\n position: absolute;\r\n top: 50%;\r\n left: 50%;\r\n transform: translate(-50%, -50%);\r\n background: $cWhite;\r\n padding: 25px;\r\n border-radius: 10px;\r\n max-width: 1140px;\r\n width: 95%;\r\n box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);\r\n\r\n .popup_header {\r\n display: flex;\r\n justify-content: space-between;\r\n align-items: center;\r\n margin-bottom: 15px;\r\n\r\n .title {\r\n font-size: 18px;\r\n font-weight: 600;\r\n }\r\n }\r\n\r\n .close {\r\n cursor: pointer;\r\n color: #A0AEC0;\r\n font-size: 18px;\r\n padding: 4px;\r\n\r\n &:hover {\r\n color: $cDanger;\r\n }\r\n }\r\n }\r\n}\r\n\r\n// --- DataTables override ---\r\n.dt-layout-table {\r\n margin-bottom: 20px;\r\n}\r\n\r\n.pagination {\r\n button {\r\n border: 1px solid $cBorder;\r\n background: $cWhite;\r\n display: inline-flex;\r\n height: 32px;\r\n width: 32px;\r\n align-items: center;\r\n justify-content: center;\r\n margin: 0 2px;\r\n border-radius: 4px;\r\n transition: all 0.2s;\r\n cursor: pointer;\r\n\r\n &:hover {\r\n background: $cContentBg;\r\n border-color: $cPrimary;\r\n }\r\n }\r\n}\r\n\r\n// ===========================\r\n// PRODUCTS specific\r\n// ===========================\r\ntable#products {\r\n .table-product-title {\r\n display: flex;\r\n justify-content: space-between;\r\n }\r\n\r\n .edit-product-title {\r\n display: flex;\r\n height: 25px;\r\n align-items: center;\r\n justify-content: center;\r\n width: 25px;\r\n cursor: pointer;\r\n background: $cWhite;\r\n border: 1px solid #CBD5E0;\r\n color: #CBD5E0;\r\n border-radius: 4px;\r\n\r\n &:hover {\r\n background: #CBD5E0;\r\n color: $cWhite;\r\n }\r\n }\r\n\r\n a.custom_name {\r\n color: $cGreenLight !important;\r\n }\r\n}\r\n\r\n// --- Chart with form ---\r\n.chart-with-form {\r\n display: flex;\r\n gap: 20px;\r\n align-items: flex-start;\r\n}\r\n\r\n.chart-area {\r\n flex: 1 1 auto;\r\n min-width: 0;\r\n}\r\n\r\n.comment-form {\r\n width: 360px;\r\n flex: 0 0 360px;\r\n\r\n .form-group {\r\n margin-bottom: 12px;\r\n }\r\n\r\n label {\r\n display: block;\r\n font-weight: 600;\r\n margin-bottom: 6px;\r\n font-size: 13px;\r\n }\r\n\r\n input[type=\"date\"],\r\n textarea {\r\n width: 100%;\r\n border: 1px solid $cBorder;\r\n border-radius: 6px;\r\n padding: 8px 12px;\r\n font-size: 14px;\r\n font-family: \"Open Sans\", sans-serif;\r\n }\r\n\r\n textarea {\r\n min-height: 120px;\r\n resize: vertical;\r\n }\r\n\r\n .btn {\r\n padding: 8px 16px;\r\n }\r\n\r\n .btn[disabled] {\r\n opacity: 0.6;\r\n cursor: not-allowed;\r\n }\r\n\r\n .hint {\r\n font-size: 12px;\r\n color: #718096;\r\n }\r\n}\r\n\r\n// --- Select2 w modalu ---\r\n.jconfirm-box .form-group .select2-container {\r\n width: 100% !important;\r\n margin-top: 8px;\r\n}\r\n\r\n.jconfirm-box .select2-container--default .select2-selection--single {\r\n background-color: $cWhite;\r\n border: 1px solid $cBorder;\r\n border-radius: 6px;\r\n min-height: 42px;\r\n display: flex;\r\n align-items: center;\r\n padding: 4px 12px;\r\n box-shadow: none;\r\n transition: border-color 0.2s, box-shadow 0.2s;\r\n font-size: 14px;\r\n}\r\n\r\n.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__rendered {\r\n padding-left: 0;\r\n line-height: 1.4;\r\n color: #495057;\r\n}\r\n\r\n.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__placeholder {\r\n color: #CBD5E0;\r\n}\r\n\r\n.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__arrow {\r\n height: 100%;\r\n right: 8px;\r\n}\r\n\r\n.jconfirm-box .select2-container--default.select2-container--focus .select2-selection--single,\r\n.jconfirm-box .select2-container--default .select2-selection--single:hover {\r\n border-color: $cPrimary;\r\n box-shadow: 0 0 0 3px rgba($cPrimary, 0.1);\r\n outline: 0;\r\n}\r\n\r\n.jconfirm-box .select2-container .select2-dropdown {\r\n border-color: $cBorder;\r\n border-radius: 0 0 6px 6px;\r\n font-size: 14px;\r\n}\r\n\r\n.jconfirm-box .select2-container .select2-search--dropdown .select2-search__field {\r\n padding: 6px 10px;\r\n border-radius: 4px;\r\n border: 1px solid $cBorder;\r\n font-size: 14px;\r\n}\r\n\r\n.jconfirm-box .select2-container--default .select2-results__option--highlighted[aria-selected] {\r\n background-color: $cPrimary;\r\n color: $cWhite;\r\n}\r\n\r\n// ===========================\r\n// RESPONSYWNOŚĆ\r\n// ===========================\r\n@media (max-width: 992px) {\r\n .sidebar {\r\n transform: translateX(-100%);\r\n\r\n &.mobile-open {\r\n transform: translateX(0);\r\n }\r\n }\r\n\r\n .main-wrapper {\r\n margin-left: 0 !important;\r\n }\r\n}"]} \ No newline at end of file +{"version":3,"sourceRoot":"","sources":["style.scss"],"names":[],"mappings":"AA4BA;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA,OAzBM;EA0BN,YA5BW;;;AA+Bb;EACE;;;AAIF;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAMF;EACE,YAxDW;EAyDX;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA,OAzFK;EA0FL;;AAGF;EACE;EACA;EACA;EACA;;AAEA;EACE;;AAIJ;EACE;EACA;EACA;EACA;;AAIA;EACE;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;;;AAMR;EACE;EACA;EACA;EACA;EACA;EACA,YA/IO;;;AAkJT;EACE;EACA;;AAEA;EACE;;AAEA;EACE;EACA;EACA,OA1JM;EA2JN;;AAGF;EACE;EACA;EACA;;AAIJ;EACE;;AAEA;EACE;EACA;EACA;EACA,OA5KM;EA6KN;;AAIJ;EACE;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;;AAIJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA,OA1MQ;EA2MR;;AAEA;EACE;;AAGF;EACE,cA3NK;EA4NL;EACA;;AAIJ;EACE,OArNM;EAsNN;EACA;;AAIA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA,cApPG;;AAyPT;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;;AAIJ;EACE;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA,OAtQI;EAuQJ;;AAGF;EACE;EACA;EACA;;;AAMN;EACE;IACE;;EAGF;IACE;;;AAOJ;EACE;EACA;EACA,YA1SW;;;AA8Sb;EACE,OAnSa;EAoSb;EACA,YArTW;EAsTX;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE,OA/Se;;AAiTf;EACE;EACA;;AAEA;EACE;;AAGF;EACE;;AAIJ;EACE;EACA;;AAEA;EACE;;AAGF;EACE;EACA;;AAKF;EACE;;AAEA;EACE;;AAIJ;EACE;;AAEA;EACE;;AAKN;EACE;;;AAKN;EACE;EACA;EACA;EACA;EACA;;AAEA;EACE,OAxXK;EAyXL;EACA;EACA;EACA;;AAEA;EACE;;AAIJ;EACE;EACA;EACA,OA1YW;EA2YX;EACA;EACA;EACA;;AAEA;EACE;EACA,OA9YG;;AAiZL;EACE;;;AAKN;EACE;EACA;EACA;;AAEA;EACE;EACA;EACA;;AAGE;EACE;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA,OAhbO;EAibP;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;;AAGF;EACE,YA7bM;EA8bN,OA3bD;;AA+bH;EACE;EACA,OAjcC;EAkcD,mBAzcG;;AA2cH;EACE,OA5cC;;;AAmdX;EACE;EACA;;AAEA;EACE;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA,OAreK;EAseL;EACA;;AAGF;EACE;;AAEA;EACE,OA3eO;EA4eP;EACA;EACA;EACA;EACA;;AAKN;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;;AAGF;EACE;;;AAMN;EACE,aA7fa;EA8fb;EACA;EACA;EACA;EACA;;AAEA;EACE,aApgBe;;;AAygBnB;EACE,QAzgBa;EA0gBb,YAvhBO;EAwhBP;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA,OAliBI;EAmiBJ;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE,YA7iBO;;AAijBX;EACE;EACA;EACA,OAjjBQ;;;AAsjBZ;EACE;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAQF;EACE;EACA;EACA,OA/kBO;EAglBP;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EAGE;EACA;;AAEA;EACE;;AAIJ;EACE,YApmBO;;AAsmBP;EACE,YAtmBS;;AA0mBb;EACE,YAvnBO;;AAynBP;EACE,YAznBS;;AA6nBb;EACE,YAlnBM;;AAonBN;EACE,YApnBQ;;AAwnBZ;EACE;EACA;;;AAKJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA,OA5oBU;EA6oBV;;AAEA;EACE;;AAGF;EACE,cA7pBO;EA8pBP;EACA;;;AAIJ;EACE;;;AAIF;EACE;EACA;;;AAGF;EACE;;AAEA;AAAA;EAEE;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;;AAGF;EACE;;AAGF;EACE;;AAGF;EACE;EACA;;;AAKJ;EACE,YA5sBO;EA6sBP;EACA;EACA,OA7sBU;EA8sBV;EACA;;AAEA;EACE;;AAGF;EACE;EACA;;AAGF;EACE;;AAIE;AAAA;EAEE;;AAEA;AAAA;EACE;;AAGF;AAAA;EACE;;AAGF;AAAA;EACE;;;AAQV;EACE;EACA;EACA;;AAEA;EACE;;AAEA;EACE,YA3vBK;;AA6vBL;EACE,YA7vBO;;AAiwBX;EACE,YAjwBI;;AAmwBJ;EACE,YAnwBM;;;AA0wBd;EACE,YAlxBO;EAmxBP;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;EACA,OA3yBK;EA4yBL;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA,OA9yBM;;AAizBR;EACE;EACA;;AAIJ;EACE;;AAEA;EACE;EACA;EACA;EACA,OA9zBM;EA+zBN;;AAIJ;EACE;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE,OA32BG;;AAg3BT;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAIJ;EACE;;AAEA;EACE;;AAKN;EACE;EACA;EACA;;AAEA;EALF;IAMI;;;AAIJ;EACE;EACA;EACA;EACA;EACA,OA95BM;EA+5BN;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;;;AAOJ;EACE;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA,OA57BM;;AA87BN;EACE,OAx8BG;EAy8BH;;AAKN;EACE,YAx8BK;EAy8BL;EACA;EACA;;AAEA;EACE;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;;AAGF;EACE;;AAGF;EACE;EACA;EACA;;AAGF;EACE;EACA,OA3+BI;;AAg/BV;EACE;EACA;EACA,OA5/BO;EA6/BP;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA,OAxhCK;;AA0hCL;EACE,YA3hCG;EA4hCH,OArhCC;;AAyhCL;EACE;EACA,OArhCI;;AAuhCJ;EACE,YAxhCE;EAyhCF,OA/hCC;;AAoiCP;EACE;EACA;EACA;;AAEA;EACE;EACA;EACA;;AAGF;EACE;EACA;;;AAKN;EACE;EACA,OAtjCU;EAujCV;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;;;AAQF;EACE;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA,OAjlCM;;AAmlCN;EACE,OA7lCG;EA8lCH;;AAKN;EACE;EACA;EACA;;AAEA;EACE;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;;AAIJ;EACE;EACA;EACA;EACA;EACA;EACA,OAtnCI;EAunCJ,YAznCC;EA0nCD;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA,cA3oCC;EA4oCD;;AAIJ;EACE;EACA;;AAEA;EACE;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA,OA1pCF;;AA4pCE;EACE,YA7pCJ;EA8pCI,OApqCL;;AA4qCP;EACE,YA7qCK;EA8qCL;EACA;EACA;EACA;EACA;;AAGF;EACE,YAtrCK;EAurCL;EACA;EACA;;AAEA;EACE;EACA;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;;AAGF;EACE;;AAKJ;EACE;EACA;EACA;;AAGA;EACE;;AAIJ;EACE;EACA;;AAIA;EACE;EACA;EACA;EACA;EACA;EACA;;AAGE;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,YA9vCH;EA+vCG,OA9vCJ;EA+vCI;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA,OA/wCH;EAgxCG,cAhxCH;;AAoxCD;EACE,YArxCD;EAsxCC,OA/wCH;EAgxCG,cAvxCD;EAwxCC;;AAGF;EACE;EACA;EACA;;AAMR;EACE;EACA,OA9xCE;EA+xCF;;AAIJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,OAzyCM;EA0yCN;;AAEA;EACE,YA7yCI;EA8yCJ,OApzCG;;;AA6zCP;EACE;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA,OAr0CM;;AAu0CN;EACE,OAj1CG;EAk1CH;;AAKN;EACE;EACA;EACA;;AAGE;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EAAI;;AAGN;EACE;EACA;EACA;EACA;EACA;EACA,OAt2CI;EAu2CJ,YAz2CC;EA02CD;;AAEA;EACE;EACA,cAr3CC;EAs3CD;;AAIJ;EACE;EACA;EACA;EACA;EACA;EACA;;AAGF;EAAwB;;AACxB;EAAsB;;AAI1B;EACE;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;;AAKN;EACE,YAp5CK;EAq5CL;EACA;EACA;;AAEA;EACE;EACA;;AAEA;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;;AAGF;EACE;;AAIF;AAAA;AAAA;AAAA;EAIE;EACA;EACA;EACA;EACA,YA77CC;;AAk8CL;EACE;EACA;EACA;;AAEA;EACE;;AAIJ;EACE;EACA;;AAIA;EACE;EACA;EACA;EACA;EACA;EACA;;AAGE;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,YAt+CH;EAu+CG,OAt+CJ;EAu+CI;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA,OAv/CH;EAw/CG,cAx/CH;;AA4/CD;EACE,YA7/CD;EA8/CC,OAv/CH;EAw/CG,cA//CD;EAggDC;;AAGF;EACE;EACA;EACA;;AAMR;EACE;EACA,OAtgDE;EAugDF;;AAKJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,OAlhDM;EAmhDN;;AAEA;EACE,YAthDI;EAuhDJ,OA7hDG;;AAkiDP;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,OApjDO;EAqjDP;;AAEA;EACE,YAxjDK;EAyjDL,OAljDG;;;AAwjDT;EACE;EACA;EACA;EACA;;AAEA;EAAQ;;;AAGV;EACE;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EAAI;;AAEJ;EACE;EACA;EACA;EACA;;AAGF;EACE;;;AAIJ;EACE;EACA;;;AAIA;EAAS;EAAe;;AACxB;EAAK;;AACL;EAAY;;;AAGd;EACE;EACA;EACA;;AAEA;EACE;;;AAIJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;;AAGF;EACE;EACA;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;EACA;;AAEA;EACE;EACA;EACA;;;AAMN;EACE,YApqDO;EAqqDP;EACA;EACA;EACA;;AAEA;EACE;;AAGF;EACE;EACA;;AAEA;EACE;EACA;EACA;EACA;EACA;;AAGF;EACE;;;AAMN;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;EACA,YAhtDK;EAitDL;EACA;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;;AAEA;EACE;EACA;;AAIJ;EACE;EACA;EACA;EACA;;AAEA;EACE,OApuDE;;;AA2uDV;EACE;;;AAIA;EACE;EACA,YAxvDK;EAyvDL;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACE,YArwDO;EAswDP,cA5wDK;;;AAqxDT;EACE;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA,YA/xDK;EAgyDL;EACA;EACA;;AAEA;EACE;EACA,OAtyDG;;AA0yDP;EACE;;;AAKJ;EACE;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;;AAEA;EACE;;AAGF;EACE;EACA;EACA;EACA;;AAGF;AAAA;EAEE;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;;AAGF;EACE;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;;AAKJ;AAAA;EAEE;EACA;;;AAGF;AAAA;EAEE,kBAj3DO;EAk3DP;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;AAAA;EAEE;EACA;EACA;;;AAGF;AAAA;EAEE;;;AAGF;AAAA;EAEE;EACA;;;AAGF;AAAA;AAAA;AAAA;EAIE,cA15DS;EA25DT;EACA;;;AAGF;AAAA;EAEE,cAv5DQ;EAw5DR;EACA;;;AAGF;AAAA;EAEE;EACA;EACA;EACA;;;AAGF;AAAA;EAEE,kBAh7DS;EAi7DT,OA16DO;;;AAg7DT;EACE;IACE;;EAEA;IACE;;EAIJ;IACE","file":"style.css"} \ No newline at end of file diff --git a/layout/style.scss b/layout/style.scss index daae7ff..d134a16 100644 --- a/layout/style.scss +++ b/layout/style.scss @@ -1906,12 +1906,14 @@ table#products { } // --- Select2 w modalu --- -.jconfirm-box .form-group .select2-container { +.jconfirm-box .form-group .select2-container, +.adspro-dialog-box .form-group .select2-container { width: 100% !important; margin-top: 8px; } -.jconfirm-box .select2-container--default .select2-selection--single { +.jconfirm-box .select2-container--default .select2-selection--single, +.adspro-dialog-box .select2-container--default .select2-selection--single { background-color: $cWhite; border: 1px solid $cBorder; border-radius: 6px; @@ -1924,42 +1926,50 @@ table#products { font-size: 14px; } -.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__rendered { +.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__rendered, +.adspro-dialog-box .select2-container--default .select2-selection--single .select2-selection__rendered { padding-left: 0; line-height: 1.4; color: #495057; } -.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__placeholder { +.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__placeholder, +.adspro-dialog-box .select2-container--default .select2-selection--single .select2-selection__placeholder { color: #CBD5E0; } -.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__arrow { +.jconfirm-box .select2-container--default .select2-selection--single .select2-selection__arrow, +.adspro-dialog-box .select2-container--default .select2-selection--single .select2-selection__arrow { height: 100%; right: 8px; } .jconfirm-box .select2-container--default.select2-container--focus .select2-selection--single, -.jconfirm-box .select2-container--default .select2-selection--single:hover { +.jconfirm-box .select2-container--default .select2-selection--single:hover, +.adspro-dialog-box .select2-container--default.select2-container--focus .select2-selection--single, +.adspro-dialog-box .select2-container--default .select2-selection--single:hover { border-color: $cPrimary; box-shadow: 0 0 0 3px rgba($cPrimary, 0.1); outline: 0; } -.jconfirm-box .select2-container .select2-dropdown { +.jconfirm-box .select2-container .select2-dropdown, +.adspro-dialog-box .select2-container .select2-dropdown { border-color: $cBorder; border-radius: 0 0 6px 6px; font-size: 14px; } -.jconfirm-box .select2-container .select2-search--dropdown .select2-search__field { +.jconfirm-box .select2-container .select2-search--dropdown .select2-search__field, +.adspro-dialog-box .select2-container .select2-search--dropdown .select2-search__field { padding: 6px 10px; border-radius: 4px; border: 1px solid $cBorder; font-size: 14px; } -.jconfirm-box .select2-container--default .select2-results__option--highlighted[aria-selected] { +.jconfirm-box .select2-container--default .select2-results__option--highlighted[aria-selected], +.adspro-dialog-box .select2-container--default .select2-results__option--highlighted[aria-selected] { background-color: $cPrimary; color: $cWhite; } diff --git a/libraries/adspro-dialog.css b/libraries/adspro-dialog.css new file mode 100644 index 0000000..706513f --- /dev/null +++ b/libraries/adspro-dialog.css @@ -0,0 +1,292 @@ +/* ============================================================= + AdsProDialog - Custom Dialog System + ============================================================= */ + +/* --- Animacje --- */ +@keyframes adspro-dialog-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* --- Główny wrapper --- */ +.adspro-dialog { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; + opacity: 0; + transition: opacity 0.25s ease; +} +.adspro-dialog.adspro-dialog-open { + opacity: 1; +} +.adspro-dialog.adspro-dialog-closing { + opacity: 0; + pointer-events: none; +} + +/* --- Backdrop --- */ +.adspro-dialog-bg { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.45); +} + +/* --- Scrollpane --- */ +.adspro-dialog-scrollpane { + position: relative; + width: 100%; + height: 100%; + overflow-y: auto; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 40px 20px; +} + +/* --- Center container --- */ +.adspro-dialog-center { + display: flex; + justify-content: center; + width: 100%; + min-height: 0; + margin: auto 0; +} + +/* Wsparcie columnClass (Bootstrap grid) */ +.adspro-dialog-center.col-md-2 { max-width: 16.666%; } +.adspro-dialog-center.col-md-3 { max-width: 25%; } +.adspro-dialog-center.col-md-4 { max-width: 33.333%; } +.adspro-dialog-center.col-md-5 { max-width: 41.666%; } +.adspro-dialog-center.col-md-6 { max-width: 50%; } +.adspro-dialog-center.col-md-7 { max-width: 58.333%; } +.adspro-dialog-center.col-md-8 { max-width: 66.666%; } +.adspro-dialog-center.col-md-9 { max-width: 75%; } +.adspro-dialog-center.col-md-10 { max-width: 83.333%; } +.adspro-dialog-center.col-md-11 { max-width: 91.666%; } +.adspro-dialog-center.col-md-12 { max-width: 100%; } +.adspro-dialog-center.col-12 { max-width: 100%; } + +/* --- Dialog box --- */ +.adspro-dialog-box { + background: #FFFFFF; + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2), 0 4px 16px rgba(0, 0, 0, 0.1); + width: 100%; + max-width: 550px; + position: relative; + overflow: hidden; + transform: scale(0.95) translateY(-20px); + opacity: 0; + transition: transform 0.3s ease, opacity 0.3s ease; +} +.adspro-dialog-open .adspro-dialog-box { + transform: scale(1) translateY(0); + opacity: 1; +} +.adspro-dialog-closing .adspro-dialog-box { + transform: scale(0.95) translateY(-10px); + opacity: 0; +} + +/* Gdy użyto columnClass lub boxWidth, box zajmuje pełną szerokość kontenera */ +.adspro-dialog-center[class*="col-"] .adspro-dialog-box { + max-width: 100%; +} +.adspro-dialog-box[style*="max-width"] { + max-width: none; +} + +/* --- Kolorowe paski (type) --- */ +.adspro-dialog-type-red { border-top: 4px solid #CC0000; } +.adspro-dialog-type-orange { border-top: 4px solid #FF8C00; } +.adspro-dialog-type-green { border-top: 4px solid #57B951; } +.adspro-dialog-type-blue { border-top: 4px solid #6690F4; } +.adspro-dialog-type-purple { border-top: 4px solid #8B5CF6; } +.adspro-dialog-type-dark { border-top: 4px solid #2D3748; } + +/* --- Close icon --- */ +.adspro-dialog-close-icon { + position: absolute; + top: 14px; + right: 14px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: #A0AEC0; + border-radius: 6px; + transition: all 0.2s; + z-index: 10; + font-size: 16px; +} +.adspro-dialog-close-icon:hover { + background: #FFF5F5; + color: #CC0000; +} + +/* --- Tytuł --- */ +.adspro-dialog-title-c { + padding: 20px 24px 0; + user-select: none; +} +.adspro-dialog-title { + font-size: 18px; + font-weight: 600; + color: #2D3748; + display: block; +} + +/* Kolor tytułu per typ */ +.adspro-dialog-type-red .adspro-dialog-title { color: #CC0000; } +.adspro-dialog-type-orange .adspro-dialog-title { color: #FF8C00; } +.adspro-dialog-type-green .adspro-dialog-title { color: #2F855A; } +.adspro-dialog-type-blue .adspro-dialog-title { color: #6690F4; } + +/* --- Content --- */ +.adspro-dialog-content-pane { + padding: 16px 24px; + transition: opacity 0.2s; +} +.adspro-dialog-content { + font-size: 14px; + line-height: 1.6; + color: #4E5E6A; +} +.adspro-dialog-content .form-group { + margin-bottom: 14px; +} +.adspro-dialog-content .form-control { + width: 100%; +} + +/* --- Przyciski --- */ +.adspro-dialog-buttons { + padding: 16px 24px 20px; + display: flex; + justify-content: flex-end; + gap: 10px; + border-top: 1px solid #F1F5F9; + transition: opacity 0.2s; +} +.adspro-dialog-btn { + padding: 9px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border: none; + transition: all 0.2s; + font-family: inherit; + line-height: 1.4; +} +.adspro-dialog-btn:focus { + outline: none; +} + +/* Klasy przycisków */ +.adspro-dialog-btn.btn-blue { + background: #6690F4; + color: #fff; +} +.adspro-dialog-btn.btn-blue:hover { + background: #3164db; +} + +.adspro-dialog-btn.btn-red { + background: #CC0000; + color: #fff; +} +.adspro-dialog-btn.btn-red:hover { + background: #b30000; +} + +.adspro-dialog-btn.btn-green { + background: #57B951; + color: #fff; +} +.adspro-dialog-btn.btn-green:hover { + background: #4a9c3b; +} + +.adspro-dialog-btn.btn-orange { + background: #FF8C00; + color: #fff; +} +.adspro-dialog-btn.btn-orange:hover { + background: #e07800; +} + +.adspro-dialog-btn.btn-success { + background: #57B951; + color: #fff; +} +.adspro-dialog-btn.btn-success:hover { + background: #4a9c3b; +} + +.adspro-dialog-btn.btn-danger { + background: #CC0000; + color: #fff; +} +.adspro-dialog-btn.btn-danger:hover { + background: #b30000; +} + +.adspro-dialog-btn.btn-default { + background: #E2E8F0; + color: #4E5E6A; +} +.adspro-dialog-btn.btn-default:hover { + background: #CBD5E0; +} + +/* --- Loading overlay --- */ +.adspro-dialog-loading { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 20; + border-radius: 12px; +} +.adspro-dialog-spinner { + width: 40px; + height: 40px; + border: 3px solid #E2E8F0; + border-top-color: #6690F4; + border-radius: 50%; + animation: adspro-dialog-spin 0.7s infinite linear; +} + +/* --- Responsywność --- */ +@media (max-width: 768px) { + .adspro-dialog-scrollpane { + padding: 20px 12px; + } + .adspro-dialog-box { + max-width: 100% !important; + } + .adspro-dialog-center[class*="col-"] { + max-width: 100% !important; + } + .adspro-dialog-buttons { + flex-direction: column; + } + .adspro-dialog-btn { + width: 100%; + text-align: center; + } +} diff --git a/libraries/adspro-dialog.js b/libraries/adspro-dialog.js new file mode 100644 index 0000000..a54d376 --- /dev/null +++ b/libraries/adspro-dialog.js @@ -0,0 +1,351 @@ +/** + * AdsProDialog - własny system dialogów (zamiennik jquery-confirm) + * API kompatybilne z jquery-confirm: $.confirm(options), $.alert(options) + */ +(function( $ ) +{ + 'use strict'; + + var dialogCounter = 0; + var activeDialogs = []; + + function AdsProDialog( options ) + { + this.id = ++dialogCounter; + this.options = $.extend( {}, AdsProDialog.defaults, options ); + this.$el = null; + this.$box = null; + this.$content = null; + this._closed = false; + this._autoCloseTimer = null; + this._init(); + } + + AdsProDialog.defaults = { + title: '', + content: '', + type: '', + theme: '', + buttons: {}, + closeIcon: false, + closeIconClass: 'fa-solid fa-xmark', + columnClass: '', + boxWidth: '', + useBootstrap: true, + draggable: false, + autoClose: '', + onContentReady: null, + onClose: null, + onOpen: null + }; + + AdsProDialog.prototype = { + + _init: function() + { + this._buildDOM(); + this._bindEvents(); + this._appendToBody(); + this._applyAutoClose(); + this._triggerContentReady(); + activeDialogs.push( this ); + }, + + _buildDOM: function() + { + var o = this.options; + var typeClass = o.type ? ' adspro-dialog-type-' + o.type : ''; + + var sizeStyle = ''; + var sizeClass = ''; + if ( !o.useBootstrap && o.boxWidth ) + { + sizeStyle = 'max-width:' + o.boxWidth + ';width:100%;'; + } + else if ( o.columnClass ) + { + sizeClass = ' ' + o.columnClass.replace( /col-md-offset-\d+/g, '' ).trim(); + } + + var html = + '
' + + '
' + + '
' + + '
' + + '
' + + this._buildCloseIcon( o ) + + this._buildHeader( o ) + + '
' + + '
' + o.content + '
' + + '
' + + this._buildButtons( o ) + + '' + + '
' + + '
' + + '
' + + '
'; + + this.$el = $( html ); + this.$box = this.$el.find( '.adspro-dialog-box' ); + this.$content = this.$el.find( '.adspro-dialog-content' ); + }, + + _buildCloseIcon: function( o ) + { + if ( !o.closeIcon ) return ''; + var iconClass = o.closeIconClass || 'fa-solid fa-xmark'; + return '
'; + }, + + _buildHeader: function( o ) + { + if ( !o.title ) return ''; + return '
' + + '' + o.title + '' + + '
'; + }, + + _buildButtons: function( o ) + { + if ( !o.buttons || $.isEmptyObject( o.buttons ) ) return ''; + + var html = '
'; + var self = this; + + $.each( o.buttons, function( key, btnDef ) + { + if ( typeof btnDef === 'function' ) + { + btnDef = { action: btnDef }; + o.buttons[ key ] = btnDef; + } + + var text = btnDef.text || key.charAt( 0 ).toUpperCase() + key.slice( 1 ); + var btnClass = btnDef.btnClass || 'btn-default'; + var isEnter = ( btnDef.keys && btnDef.keys.indexOf( 'enter' ) !== -1 ); + + html += ''; + }); + + html += '
'; + return html; + }, + + _bindEvents: function() + { + var self = this; + var o = this.options; + + // Backdrop click + this.$el.find( '.adspro-dialog-bg' ).on( 'click', function() + { + self.close(); + }); + + // Close icon + this.$el.find( '.adspro-dialog-close-icon' ).on( 'click', function() + { + self.close(); + }); + + // Buttons + this.$el.find( '.adspro-dialog-btn' ).each( function() + { + var $btn = $( this ); + var key = $btn.data( 'btn-key' ); + var btnDef = o.buttons[ key ]; + + // Referencje do buttonów (kompatybilność z $$formSubmit itp.) + self[ '$$' + key ] = $btn; + + $btn.on( 'click', function() + { + if ( typeof btnDef === 'function' ) + { + btnDef.call( self ); + self.close(); + return; + } + if ( btnDef && typeof btnDef.action === 'function' ) + { + var result = btnDef.action.call( self ); + if ( result !== false ) + { + self.close(); + } + } + else + { + self.close(); + } + }); + }); + + // Keyboard + $( document ).on( 'keydown.adspro-dialog-' + this.id, function( e ) + { + if ( self._closed ) return; + if ( activeDialogs[ activeDialogs.length - 1 ] !== self ) return; + + if ( e.key === 'Escape' ) + { + e.preventDefault(); + self.close(); + } + if ( e.key === 'Enter' ) + { + if ( $( e.target ).is( 'textarea, select' ) ) return; + var $enterBtn = self.$el.find( '.adspro-dialog-btn[data-enter-key="true"]' ); + if ( $enterBtn.length ) + { + e.preventDefault(); + $enterBtn.trigger( 'click' ); + } + } + }); + + // Draggable + if ( o.draggable && $.fn.draggable ) + { + this.$box.draggable({ + handle: '.adspro-dialog-title-c', + cursor: 'move' + }); + this.$el.find( '.adspro-dialog-title-c' ).css( 'cursor', 'move' ); + } + }, + + _appendToBody: function() + { + var baseZIndex = 99000 + ( this.id * 10 ); + this.$el.css( 'z-index', baseZIndex ); + $( 'body' ).append( this.$el ); + + var self = this; + requestAnimationFrame( function() + { + self.$el.addClass( 'adspro-dialog-open' ); + }); + + if ( typeof this.options.onOpen === 'function' ) + { + this.options.onOpen.call( this ); + } + }, + + _applyAutoClose: function() + { + var ac = this.options.autoClose; + if ( !ac ) return; + + var parts = ac.split( '|' ); + if ( parts.length !== 2 ) return; + + var ms = parseInt( parts[ 1 ], 10 ); + var self = this; + this._autoCloseTimer = setTimeout( function() + { + if ( !self._closed ) self.close(); + }, ms ); + }, + + _triggerContentReady: function() + { + if ( typeof this.options.onContentReady === 'function' ) + { + this.options.onContentReady.call( this ); + } + }, + + // --- Metody publiczne --- + + close: function() + { + if ( this._closed ) return; + this._closed = true; + + if ( this._autoCloseTimer ) clearTimeout( this._autoCloseTimer ); + $( document ).off( 'keydown.adspro-dialog-' + this.id ); + + var self = this; + this.$el.removeClass( 'adspro-dialog-open' ); + this.$el.addClass( 'adspro-dialog-closing' ); + + setTimeout( function() + { + self.$el.remove(); + var idx = activeDialogs.indexOf( self ); + if ( idx > -1 ) activeDialogs.splice( idx, 1 ); + if ( typeof self.options.onClose === 'function' ) + { + self.options.onClose.call( self ); + } + }, 250 ); + }, + + showLoading: function( showContent ) + { + this.$el.find( '.adspro-dialog-loading' ).show(); + if ( !showContent ) + { + this.$el.find( '.adspro-dialog-content-pane' ).css( 'opacity', '0.3' ); + } + this.$el.find( '.adspro-dialog-buttons' ).css({ + 'pointer-events': 'none', + 'opacity': '0.5' + }); + }, + + hideLoading: function() + { + this.$el.find( '.adspro-dialog-loading' ).hide(); + this.$el.find( '.adspro-dialog-content-pane' ).css( 'opacity', '' ); + this.$el.find( '.adspro-dialog-buttons' ).css({ + 'pointer-events': '', + 'opacity': '' + }); + }, + + setContent: function( html ) + { + this.$content.html( html ); + }, + + setTitle: function( title ) + { + this.$el.find( '.adspro-dialog-title' ).html( title ); + } + }; + + // --- Rejestracja globalna --- + + $.confirm = function( options ) + { + return new AdsProDialog( options ); + }; + + $.alert = function( options ) + { + if ( typeof options === 'string' ) + { + options = { + title: '', + content: options, + buttons: { ok: { text: 'OK', btnClass: 'btn-blue' } } + }; + } + + if ( !options.buttons ) + { + options.buttons = { ok: { text: 'OK', btnClass: 'btn-blue' } }; + } + + return new AdsProDialog( options ); + }; + +})( jQuery ); diff --git a/migrations/001_google_ads_settings.sql b/migrations/001_google_ads_settings.sql index 5f414fd..0c333a6 100644 --- a/migrations/001_google_ads_settings.sql +++ b/migrations/001_google_ads_settings.sql @@ -1,6 +1,6 @@ --- Migracja: Tabela settings + kolumna google_ads_customer_id +-- Migracja: Tabela settings + kolumny Google Ads w clients -- Data: 2026-02-15 --- Opis: Dodaje globalną tabelę ustawień key-value oraz kolumnę Google Ads Customer ID do tabeli clients +-- Opis: Idempotentna migracja (bez bledow i bez duplikatow przy ponownym uruchomieniu) -- 1. Tabela settings (globalne ustawienia aplikacji) CREATE TABLE IF NOT EXISTS `settings` ( @@ -11,8 +11,34 @@ CREATE TABLE IF NOT EXISTS `settings` ( 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`; +-- 2. Kolumna google_ads_customer_id w tabeli clients (tylko jesli nie istnieje) +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'clients' + AND COLUMN_NAME = 'google_ads_customer_id' + ), + 'DO 1', + 'ALTER TABLE `clients` ADD COLUMN `google_ads_customer_id` VARCHAR(20) NULL DEFAULT NULL AFTER `name`' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; --- 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`; +-- 3. Kolumna google_ads_start_date w tabeli clients (tylko jesli nie istnieje) +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'clients' + AND COLUMN_NAME = 'google_ads_start_date' + ), + 'DO 1', + 'ALTER TABLE `clients` ADD COLUMN `google_ads_start_date` DATE NULL DEFAULT NULL AFTER `google_ads_customer_id`' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/migrations/002_products_data_url.sql b/migrations/002_products_data_url.sql index bb4cea5..8fe29d3 100644 --- a/migrations/002_products_data_url.sql +++ b/migrations/002_products_data_url.sql @@ -1 +1,17 @@ -ALTER TABLE `products_data` ADD COLUMN `product_url` VARCHAR(500) NULL DEFAULT NULL; +-- Migracja: products_data.product_url +-- Opis: Idempotentna migracja (bez bledu przy ponownym uruchomieniu) + +SET @sql = IF( + EXISTS ( + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'products_data' + AND COLUMN_NAME = 'product_url' + ), + 'DO 1', + 'ALTER TABLE `products_data` ADD COLUMN `product_url` VARCHAR(500) NULL DEFAULT NULL' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/migrations/003_campaign_ad_groups_and_terms.sql b/migrations/003_campaign_ad_groups_and_terms.sql new file mode 100644 index 0000000..71669c5 --- /dev/null +++ b/migrations/003_campaign_ad_groups_and_terms.sql @@ -0,0 +1,77 @@ +-- Migracja: grupy reklam + frazy wyszukiwane i wykluczajace dla kampanii +-- Opis: struktura pod import z Google Ads API (30 dni + all time) + +CREATE TABLE IF NOT EXISTS `campaign_ad_groups` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `campaign_id` INT(11) NOT NULL, + `ad_group_id` BIGINT(20) NOT NULL, + `ad_group_name` VARCHAR(255) NOT NULL DEFAULT '', + `impressions_30` INT(11) NOT NULL DEFAULT 0, + `clicks_30` INT(11) NOT NULL DEFAULT 0, + `cost_30` DECIMAL(20,6) NOT NULL DEFAULT 0.000000, + `conversions_30` DECIMAL(20,6) NOT NULL DEFAULT 0.000000, + `conversion_value_30` DECIMAL(20,6) NOT NULL DEFAULT 0.000000, + `roas_30` DECIMAL(20,6) NOT NULL DEFAULT 0.000000, + `impressions_all_time` INT(11) NOT NULL DEFAULT 0, + `clicks_all_time` INT(11) NOT NULL DEFAULT 0, + `cost_all_time` DECIMAL(20,6) NOT NULL DEFAULT 0.000000, + `conversions_all_time` DECIMAL(20,6) NOT NULL DEFAULT 0.000000, + `conversion_value_all_time` DECIMAL(20,6) NOT NULL DEFAULT 0.000000, + `roas_all_time` DECIMAL(20,6) NOT NULL DEFAULT 0.000000, + `date_sync` DATE DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_campaign_ad_groups_campaign_ad_group` (`campaign_id`, `ad_group_id`), + KEY `idx_campaign_ad_groups_campaign_id` (`campaign_id`), + CONSTRAINT `FK_campaign_ad_groups_campaigns` + FOREIGN KEY (`campaign_id`) REFERENCES `campaigns` (`id`) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `campaign_search_terms` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `campaign_id` INT(11) NOT NULL, + `ad_group_id` INT(11) NOT NULL, + `search_term` VARCHAR(255) NOT NULL, + `impressions_30` INT(11) NOT NULL DEFAULT 0, + `clicks_30` INT(11) NOT NULL DEFAULT 0, + `cost_30` DECIMAL(20,6) NOT NULL DEFAULT 0.000000, + `conversions_30` DECIMAL(20,6) NOT NULL DEFAULT 0.000000, + `conversion_value_30` DECIMAL(20,6) NOT NULL DEFAULT 0.000000, + `roas_30` DECIMAL(20,6) NOT NULL DEFAULT 0.000000, + `impressions_all_time` INT(11) NOT NULL DEFAULT 0, + `clicks_all_time` INT(11) NOT NULL DEFAULT 0, + `cost_all_time` DECIMAL(20,6) NOT NULL DEFAULT 0.000000, + `conversions_all_time` DECIMAL(20,6) NOT NULL DEFAULT 0.000000, + `conversion_value_all_time` DECIMAL(20,6) NOT NULL DEFAULT 0.000000, + `roas_all_time` DECIMAL(20,6) NOT NULL DEFAULT 0.000000, + `date_sync` DATE DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_campaign_search_terms` (`campaign_id`, `ad_group_id`, `search_term`), + KEY `idx_campaign_search_terms_campaign_id` (`campaign_id`), + KEY `idx_campaign_search_terms_ad_group_id` (`ad_group_id`), + CONSTRAINT `FK_campaign_search_terms_campaigns` + FOREIGN KEY (`campaign_id`) REFERENCES `campaigns` (`id`) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `FK_campaign_search_terms_ad_groups` + FOREIGN KEY (`ad_group_id`) REFERENCES `campaign_ad_groups` (`id`) + ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `campaign_negative_keywords` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `campaign_id` INT(11) NOT NULL, + `ad_group_id` INT(11) DEFAULT NULL, + `scope` VARCHAR(20) NOT NULL DEFAULT 'campaign', + `keyword_text` VARCHAR(255) NOT NULL, + `match_type` VARCHAR(40) DEFAULT NULL, + `date_sync` DATE DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_campaign_negative_keywords_campaign_id` (`campaign_id`), + KEY `idx_campaign_negative_keywords_ad_group_id` (`ad_group_id`), + CONSTRAINT `FK_campaign_negative_keywords_campaigns` + FOREIGN KEY (`campaign_id`) REFERENCES `campaigns` (`id`) + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `FK_campaign_negative_keywords_ad_groups` + FOREIGN KEY (`ad_group_id`) REFERENCES `campaign_ad_groups` (`id`) + ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/migrations/demo_data.sql b/migrations/demo_data.sql index 58e2b6c..c31c0bf 100644 --- a/migrations/demo_data.sql +++ b/migrations/demo_data.sql @@ -11,6 +11,23 @@ -- 1. KAMPANIE -- ============================================================ +-- Idempotencja: usuń poprzedni zestaw danych demo kampanii przed ponownym wstawieniem +DELETE ch +FROM campaigns_history ch +JOIN campaigns c ON c.id = ch.campaign_id +WHERE c.client_id = 2 + AND c.campaign_id IN ( + 20845671001, 20845671002, 20845671003, 20845671004, + 20845671005, 20845671006, 20845671007, 20845671008 + ); + +DELETE FROM campaigns +WHERE client_id = 2 + AND campaign_id IN ( + 20845671001, 20845671002, 20845671003, 20845671004, + 20845671005, 20845671006, 20845671007, 20845671008 + ); + INSERT INTO `campaigns` (`client_id`, `campaign_id`, `campaign_name`) VALUES (2, 20845671001, 'PMAX | Prezenty personalizowane'), (2, 20845671002, 'PMAX | Bestsellery'), @@ -152,6 +169,53 @@ DROP PROCEDURE IF EXISTS generate_campaign_history; -- 3. PRODUKTY (25 produktów - realistyczne nazwy sklepu z prezentami) -- ============================================================ +-- Idempotencja: usuń poprzedni zestaw danych demo produktów przed ponownym wstawieniem +DELETE pt +FROM products_temp pt +JOIN products p ON p.id = pt.product_id +WHERE p.client_id = 2 + AND p.offer_id IN ( + 'shopify_PL_8901001','shopify_PL_8901002','shopify_PL_8901003','shopify_PL_8901004','shopify_PL_8901005', + 'shopify_PL_8901006','shopify_PL_8901007','shopify_PL_8901008','shopify_PL_8901009','shopify_PL_8901010', + 'shopify_PL_8901011','shopify_PL_8901012','shopify_PL_8901013','shopify_PL_8901014','shopify_PL_8901015', + 'shopify_PL_8901016','shopify_PL_8901017','shopify_PL_8901018','shopify_PL_8901019','shopify_PL_8901020', + 'shopify_PL_8901021','shopify_PL_8901022','shopify_PL_8901023','shopify_PL_8901024','shopify_PL_8901025' + ); + +DELETE pd +FROM products_data pd +JOIN products p ON p.id = pd.product_id +WHERE p.client_id = 2 + AND p.offer_id IN ( + 'shopify_PL_8901001','shopify_PL_8901002','shopify_PL_8901003','shopify_PL_8901004','shopify_PL_8901005', + 'shopify_PL_8901006','shopify_PL_8901007','shopify_PL_8901008','shopify_PL_8901009','shopify_PL_8901010', + 'shopify_PL_8901011','shopify_PL_8901012','shopify_PL_8901013','shopify_PL_8901014','shopify_PL_8901015', + 'shopify_PL_8901016','shopify_PL_8901017','shopify_PL_8901018','shopify_PL_8901019','shopify_PL_8901020', + 'shopify_PL_8901021','shopify_PL_8901022','shopify_PL_8901023','shopify_PL_8901024','shopify_PL_8901025' + ); + +DELETE ph +FROM products_history ph +JOIN products p ON p.id = ph.product_id +WHERE p.client_id = 2 + AND p.offer_id IN ( + 'shopify_PL_8901001','shopify_PL_8901002','shopify_PL_8901003','shopify_PL_8901004','shopify_PL_8901005', + 'shopify_PL_8901006','shopify_PL_8901007','shopify_PL_8901008','shopify_PL_8901009','shopify_PL_8901010', + 'shopify_PL_8901011','shopify_PL_8901012','shopify_PL_8901013','shopify_PL_8901014','shopify_PL_8901015', + 'shopify_PL_8901016','shopify_PL_8901017','shopify_PL_8901018','shopify_PL_8901019','shopify_PL_8901020', + 'shopify_PL_8901021','shopify_PL_8901022','shopify_PL_8901023','shopify_PL_8901024','shopify_PL_8901025' + ); + +DELETE FROM products +WHERE client_id = 2 + AND offer_id IN ( + 'shopify_PL_8901001','shopify_PL_8901002','shopify_PL_8901003','shopify_PL_8901004','shopify_PL_8901005', + 'shopify_PL_8901006','shopify_PL_8901007','shopify_PL_8901008','shopify_PL_8901009','shopify_PL_8901010', + 'shopify_PL_8901011','shopify_PL_8901012','shopify_PL_8901013','shopify_PL_8901014','shopify_PL_8901015', + 'shopify_PL_8901016','shopify_PL_8901017','shopify_PL_8901018','shopify_PL_8901019','shopify_PL_8901020', + 'shopify_PL_8901021','shopify_PL_8901022','shopify_PL_8901023','shopify_PL_8901024','shopify_PL_8901025' + ); + 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'), @@ -296,6 +360,12 @@ DROP PROCEDURE IF EXISTS generate_product_history; -- 5. PRODUCTS_TEMP (zagregowane dane - jak po cron_products) -- ============================================================ +-- Idempotencja: wyczyść bieżące agregaty klienta przed ponownym przeliczeniem +DELETE pt +FROM products_temp pt +JOIN products p ON p.id = pt.product_id +WHERE p.client_id = 2; + INSERT INTO products_temp (product_id, name, impressions, impressions_30, clicks, clicks_30, ctr, cost, conversions, conversions_value, cpc, roas) SELECT p.id, diff --git a/templates/campaign_terms/main_view.php b/templates/campaign_terms/main_view.php new file mode 100644 index 0000000..2747020 --- /dev/null +++ b/templates/campaign_terms/main_view.php @@ -0,0 +1,762 @@ +
+
+

Grupy i frazy

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ Grupy reklam +
+
+ + + + + + + + + + + + + + + + + +
Grupa reklamKlik. 30dKoszt 30dWartosc 30dROAS 30dKlik. allKoszt allWartosc allROAS all
Wybierz klienta i kampanie.
+
+
+ +
+
+ Frazy wyszukiwane (klikniecia >= 1) +
+
+ + + + + + + + + + + + + + + + + + + +
FrazaGrupa reklamKlik. 30dKoszt 30dWartosc 30dROAS 30dKlik. allKoszt allWartosc allROAS allAkcja
Brak danych.
+
+
+ +
+
+ Frazy wykluczajace +
+
+ + + + + + + + + + + +
PoziomFrazaMatch type
Brak danych.
+
+
+
+
+ + + + diff --git a/templates/campaigns/main_view.php b/templates/campaigns/main_view.php index 0846ae9..d5867e7 100644 --- a/templates/campaigns/main_view.php +++ b/templates/campaigns/main_view.php @@ -3,12 +3,11 @@

Kampanie

-
- + -
-
-
@@ -40,22 +37,47 @@ - + - + - - +
Data ROAS (30 dni) ROAS (all time)Wartość konwersji (30 dni)Wartosc konwersji (30 dni) Wydatki (30 dni) Komentarz Strategia ustalania stawekBudżetBudzet Akcje
diff --git a/templates/site/layout-cron.php b/templates/site/layout-cron.php index d6e94d6..324f1a0 100644 --- a/templates/site/layout-cron.php +++ b/templates/site/layout-cron.php @@ -18,7 +18,7 @@ - + @@ -26,7 +26,7 @@ - + diff --git a/templates/site/layout-logged.php b/templates/site/layout-logged.php index ca06b05..5a6c7ac 100644 --- a/templates/site/layout-logged.php +++ b/templates/site/layout-logged.php @@ -21,14 +21,14 @@ - + - + @@ -58,6 +58,12 @@ Kampanie +
  • + + + Grupy i frazy + +
  • @@ -111,6 +117,7 @@ 'Kampanie', + 'campaign_terms' => 'Grupy i frazy', 'products' => 'Produkty', 'clients' => 'Klienci', 'allegro' => 'Allegro import',