feat: Enhance campaign terms management with keyword addition and match type change functionality

- Added a toolbar for adding keywords in the campaign terms view.
- Implemented match type change functionality with a confirmation dialog.
- Added delete functionality for keywords with confirmation.
- Updated styles for new buttons and icons in the campaign terms view.
- Enhanced product view with warning icons for product alerts and corresponding modal display.
- Updated product table to include a new column for warnings and adjusted column visibility settings.
- Documented project overview, code style conventions, suggested commands, and task completion checklist.
This commit is contained in:
2026-02-24 01:30:13 +01:00
parent 63857639ff
commit 4aefa5f445
18 changed files with 1087 additions and 59 deletions

View File

@@ -36,7 +36,11 @@
"mcp__serena__read_file",
"mcp__serena__replace_content",
"mcp__serena__replace_symbol_body",
"mcp__serena__check_onboarding_performed"
"mcp__serena__check_onboarding_performed",
"mcp__serena__find_file",
"mcp__serena__onboarding",
"mcp__serena__write_memory",
"mcp__serena__list_dir"
]
},
"statusLine": {

View File

@@ -0,0 +1,26 @@
# adsPRO - Project Overview
## Purpose
PHP SaaS application for managing Google Ads campaigns, products, and clients. Integrates with Google Ads API, OpenAI, and Claude AI for AI-powered ad optimization. UI language: Polish.
## Tech Stack
- PHP (custom MVC framework, no Composer)
- MySQL via Medoo ORM (`global $mdb`)
- Frontend: jQuery 3.6, DataTables 2.1, Bootstrap 4, Select2, Highcharts, Font Awesome 6.5
- SASS for styles (compiled by VS Code Live Sass Compiler)
- Deployment: FTP auto-upload via VS Code FTP-Kr
## Architecture
- Controllers: `autoload/controls/class.*.php` (namespace `\controls`)
- Factories: `autoload/factory/class.*.php` (namespace `\factory`)
- Views: `autoload/view/class.*.php` (namespace `\view`)
- Services: `autoload/services/class.*.php` (namespace `\services`)
- Templates: `templates/` (variables via `$this->varName`)
- Routing: `index.php`, URL `/module/action/``\controls\Module::action()`
## Entry Points
- `index.php` - Main app (routing + auth)
- `ajax.php` - AJAX requests (authenticated)
- `api.php` - Public API
- `cron.php` - Background jobs
- `install.php` - Database migration runner

View File

@@ -0,0 +1,9 @@
# Code Style & Conventions
- Spaces inside parentheses: `if ( $x )`, `function( $a, $b )`
- Braces on new line for classes/functions
- 4-space indent in classes, 2-space in templates
- All controller/factory methods: `static public function`
- JSON endpoints: `echo json_encode([...]); exit;`
- Classes PascalCase, methods/variables/columns snake_case
- Commit messages in Polish, prefixed with `feat:`, `fix:`, etc.

View File

@@ -0,0 +1,19 @@
# Suggested Commands
## Database Migrations
```bash
php install.php # Run migrations
php install.php --force # Force re-run all
```
## System Utils (Windows/Git Bash)
- `git` - version control
- `ls` - list files (Git Bash)
- `grep` - search (Git Bash)
- `find` - find files (Git Bash)
## No build/test/lint commands
- No Composer, no package.json
- SASS compiled by VS Code Live Sass Compiler
- Deployment via FTP-Kr extension (auto-upload)
- No automated test suite

View File

@@ -0,0 +1,7 @@
# Task Completion Checklist
1. Verify PHP syntax (no parse errors)
2. Test in browser if possible
3. Check SQL migrations if DB changes needed (add to `migrations/`)
4. Files auto-deploy via FTP-Kr on save
5. No automated tests to run

View File

@@ -27,6 +27,7 @@ project_name: "adsPRO"
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- typescript
- php
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings

3
.vscode/ftp-kr.json vendored
View File

@@ -16,6 +16,7 @@
"/.serena",
"/.claude",
"CLAUDE.md",
"AGENTS.md"
"AGENTS.md",
".gitignore"
]
}

View File

@@ -101,8 +101,8 @@
},
"class.Cron.php": {
"type": "-",
"size": 182399,
"lmtime": 1771755214561,
"size": 182635,
"lmtime": 1771851157171,
"modified": false
},
"class.FacebookAds.php": {
@@ -201,7 +201,7 @@
"services": {
"class.ClaudeApi.php": {
"type": "-",
"size": 12510,
"size": 12408,
"lmtime": 1771198088093,
"modified": true
},
@@ -211,15 +211,21 @@
"lmtime": 1771619153702,
"modified": false
},
"class.GeminiApi.php": {
"type": "-",
"size": 13361,
"lmtime": 0,
"modified": false
},
"class.GoogleAdsApi.php": {
"type": "-",
"size": 114140,
"lmtime": 1771444236566,
"modified": true
"size": 116731,
"lmtime": 1771851152230,
"modified": false
},
"class.OpenAiApi.php": {
"type": "-",
"size": 18739,
"size": 29408,
"lmtime": 1771171891986,
"modified": true
}
@@ -251,20 +257,6 @@
}
}
},
".claude": {
"settings.local.json": {
"type": "-",
"size": 1421,
"lmtime": 1771755894778,
"modified": false
}
},
"CLAUDE.md": {
"type": "-",
"size": 4139,
"lmtime": 1771368527045,
"modified": false
},
"config.php": {
"type": "-",
"size": 921,
@@ -606,24 +598,6 @@
"lmtime": 1744488227849,
"modified": false
},
".serena": {
"cache": {
"typescript": {
"raw_document_symbols.pkl": {
"type": "-",
"size": 23480,
"lmtime": 1771755819379,
"modified": false
},
"document_symbols.pkl": {
"type": "-",
"size": 142667,
"lmtime": 1771755819382,
"modified": false
}
}
}
},
"templates": {
"products": {
"main_view.php": {
@@ -714,7 +688,80 @@
}
}
},
"tmp": {},
"tmp": {
"campaign_alerts_debug.log": {
"type": "-",
"size": 15260,
"lmtime": 0,
"modified": false
},
"__check_adgroups.php": {
"type": "-",
"size": 1579,
"lmtime": 0,
"modified": false
},
"check_db.php": {
"type": "-",
"size": 1274,
"lmtime": 1771850655635,
"modified": false
},
"check_db_remote.php": {
"type": "-",
"size": 450,
"lmtime": 1771850670102,
"modified": false
},
"check_patterns.php": {
"type": "-",
"size": 2250,
"lmtime": 1771850693804,
"modified": false
},
"check_schema.php": {
"type": "-",
"size": 530,
"lmtime": 1771850676855,
"modified": false
},
"debug_clients.php": {
"type": "-",
"size": 1274,
"lmtime": 0,
"modified": false
},
"debug_clients_remote2.php": {
"type": "-",
"size": 1106,
"lmtime": 0,
"modified": false
},
"debug_clients_remote.php": {
"type": "-",
"size": 1344,
"lmtime": 0,
"modified": false
},
"debug_eligible_remote.php": {
"type": "-",
"size": 567,
"lmtime": 0,
"modified": false
},
"meta_active_last30d.json": {
"type": "-",
"size": 168510,
"lmtime": 0,
"modified": false
},
"check_active_date_logs.php": {
"type": "-",
"size": 928,
"lmtime": 1771850708365,
"modified": false
}
},
"tools": {},
"upload": {},
"xml": {}

View File

@@ -669,4 +669,277 @@ class CampaignTerms
echo json_encode( $response );
exit;
}
static public function update_keyword_match_type()
{
$keyword_id = (int) \S::get( 'keyword_id' );
$new_match_type = strtoupper( trim( (string) \S::get( 'new_match_type' ) ) );
if ( $keyword_id <= 0 )
{
echo json_encode( [ 'success' => false, 'message' => 'Nie podano frazy.' ] );
exit;
}
if ( !in_array( $new_match_type, [ 'PHRASE', 'EXACT', 'BROAD' ], true ) )
{
echo json_encode( [ 'success' => false, 'message' => 'Nieprawidlowy typ dopasowania.' ] );
exit;
}
$context = \factory\Campaigns::get_keyword_context( $keyword_id );
if ( !$context )
{
echo json_encode( [ 'success' => false, 'message' => 'Nie znaleziono danych frazy.' ] );
exit;
}
$old_match_type = strtoupper( trim( (string) ( $context['match_type'] ?? '' ) ) );
if ( $old_match_type === $new_match_type )
{
echo json_encode( [ 'success' => true, 'message' => 'Dopasowanie jest juz ustawione na ' . $new_match_type . '.' ] );
exit;
}
$customer_id = trim( (string) ( $context['google_ads_customer_id'] ?? '' ) );
$ad_group_external_id = trim( (string) ( $context['external_ad_group_id'] ?? '' ) );
$keyword_text = trim( (string) ( $context['keyword_text'] ?? '' ) );
if ( $customer_id === '' || $ad_group_external_id === '' || $keyword_text === '' )
{
echo json_encode( self::with_optional_debug( [
'success' => false,
'message' => 'Brak wymaganych danych Google Ads dla tej frazy.',
], [ 'context' => $context ] ) );
exit;
}
$api = new \services\GoogleAdsApi();
if ( !$api -> is_configured() )
{
echo json_encode( [ 'success' => false, 'message' => 'Google Ads API nie jest skonfigurowane.' ] );
exit;
}
$api_result = $api -> update_keyword_match_type( $customer_id, $ad_group_external_id, $keyword_text, $old_match_type, $new_match_type );
if ( !( $api_result['success'] ?? false ) )
{
$last_error = \services\GoogleAdsApi::get_setting( 'google_ads_last_error' );
echo json_encode( self::with_optional_debug( [
'success' => false,
'message' => 'Nie udalo sie zmienic dopasowania frazy w Google Ads.',
'error' => $last_error
], [
'customer_id' => $customer_id,
'ad_group_external_id' => $ad_group_external_id,
'keyword_text' => $keyword_text,
'old_match_type' => $old_match_type,
'new_match_type' => $new_match_type,
'api_result' => $api_result
] ) );
exit;
}
\factory\Campaigns::update_keyword_match_type( $keyword_id, $new_match_type );
echo json_encode( self::with_optional_debug( [
'success' => true,
'message' => 'Dopasowanie zmienione z ' . $old_match_type . ' na ' . $new_match_type . '.'
], [
'customer_id' => $customer_id,
'ad_group_external_id' => $ad_group_external_id,
'keyword_text' => $keyword_text,
'old_match_type' => $old_match_type,
'new_match_type' => $new_match_type,
'api_result' => $api_result
] ) );
exit;
}
static public function add_keyword()
{
$campaign_id = (int) \S::get( 'campaign_id' );
$ad_group_id = (int) \S::get( 'ad_group_id' );
$keyword_text = trim( (string) \S::get( 'keyword_text' ) );
$match_type = strtoupper( trim( (string) \S::get( 'match_type' ) ) );
if ( $campaign_id <= 0 || $ad_group_id <= 0 )
{
echo json_encode( [ 'success' => false, 'message' => 'Wybierz kampanie i grupe reklam.' ] );
exit;
}
if ( $keyword_text === '' )
{
echo json_encode( [ 'success' => false, 'message' => 'Wpisz fraze do dodania.' ] );
exit;
}
if ( !in_array( $match_type, [ 'PHRASE', 'EXACT', 'BROAD' ], true ) )
{
$match_type = 'BROAD';
}
global $mdb;
$campaign_data = $mdb -> query(
'SELECT c.campaign_id AS external_campaign_id, cl.google_ads_customer_id
FROM campaigns AS c
INNER JOIN clients AS cl ON cl.id = c.client_id
WHERE c.id = :campaign_id
LIMIT 1',
[ ':campaign_id' => $campaign_id ]
) -> fetch( \PDO::FETCH_ASSOC );
if ( !$campaign_data )
{
echo json_encode( [ 'success' => false, 'message' => 'Nie znaleziono kampanii.' ] );
exit;
}
$ad_group_data = $mdb -> query(
'SELECT ad_group_id AS external_ad_group_id FROM campaign_ad_groups WHERE id = :ad_group_id LIMIT 1',
[ ':ad_group_id' => $ad_group_id ]
) -> fetch( \PDO::FETCH_ASSOC );
if ( !$ad_group_data )
{
echo json_encode( [ 'success' => false, 'message' => 'Nie znaleziono grupy reklam.' ] );
exit;
}
$customer_id = trim( (string) ( $campaign_data['google_ads_customer_id'] ?? '' ) );
$ad_group_external_id = trim( (string) ( $ad_group_data['external_ad_group_id'] ?? '' ) );
if ( $customer_id === '' || $ad_group_external_id === '' )
{
echo json_encode( [ 'success' => false, 'message' => 'Brak wymaganych danych Google Ads.' ] );
exit;
}
$api = new \services\GoogleAdsApi();
if ( !$api -> is_configured() )
{
echo json_encode( [ 'success' => false, 'message' => 'Google Ads API nie jest skonfigurowane.' ] );
exit;
}
$api_result = $api -> add_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( self::with_optional_debug( [
'success' => false,
'message' => 'Nie udalo sie dodac frazy w Google Ads.',
'error' => $last_error
], [
'customer_id' => $customer_id,
'ad_group_external_id' => $ad_group_external_id,
'keyword_text' => $keyword_text,
'match_type' => $match_type,
'api_result' => $api_result
] ) );
exit;
}
\factory\Campaigns::insert_campaign_keyword( $campaign_id, $ad_group_id, $keyword_text, $match_type );
$match_labels = [ 'PHRASE' => 'do wyrazenia', 'EXACT' => 'scisle', 'BROAD' => 'przyblizone' ];
$match_label = $match_labels[$match_type] ?? $match_type;
echo json_encode( self::with_optional_debug( [
'success' => true,
'message' => ( $api_result['duplicate'] ?? false )
? 'Fraza juz istnieje w Google Ads (dopasowanie ' . $match_label . ').'
: 'Fraza zostala dodana (dopasowanie ' . $match_label . ').',
'duplicate' => (bool) ( $api_result['duplicate'] ?? false )
], [
'customer_id' => $customer_id,
'ad_group_external_id' => $ad_group_external_id,
'keyword_text' => $keyword_text,
'match_type' => $match_type,
'api_result' => $api_result
] ) );
exit;
}
static public function delete_keyword()
{
$keyword_id = (int) \S::get( 'keyword_id' );
if ( $keyword_id <= 0 )
{
echo json_encode( [ 'success' => false, 'message' => 'Nie podano frazy do usuniecia.' ] );
exit;
}
$context = \factory\Campaigns::get_keyword_context( $keyword_id );
if ( !$context )
{
echo json_encode( [ 'success' => false, 'message' => 'Nie znaleziono danych frazy.' ] );
exit;
}
$customer_id = trim( (string) ( $context['google_ads_customer_id'] ?? '' ) );
$ad_group_external_id = trim( (string) ( $context['external_ad_group_id'] ?? '' ) );
$keyword_text = trim( (string) ( $context['keyword_text'] ?? '' ) );
$match_type = strtoupper( trim( (string) ( $context['match_type'] ?? 'BROAD' ) ) );
if ( $customer_id === '' || $ad_group_external_id === '' || $keyword_text === '' )
{
echo json_encode( self::with_optional_debug( [
'success' => false,
'message' => 'Brak wymaganych danych Google Ads dla tej frazy.',
], [ 'context' => $context ] ) );
exit;
}
$api = new \services\GoogleAdsApi();
if ( !$api -> is_configured() )
{
echo json_encode( [ 'success' => false, 'message' => 'Google Ads API nie jest skonfigurowane.' ] );
exit;
}
$api_result = $api -> remove_keyword_from_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( self::with_optional_debug( [
'success' => false,
'message' => 'Nie udalo sie usunac frazy w Google Ads.',
'error' => $last_error
], [
'customer_id' => $customer_id,
'ad_group_external_id' => $ad_group_external_id,
'keyword_text' => $keyword_text,
'match_type' => $match_type,
'api_result' => $api_result
] ) );
exit;
}
\factory\Campaigns::delete_campaign_keyword( $keyword_id );
$removed = (int) ( $api_result['removed'] ?? 0 );
$message = $removed > 0
? 'Fraza zostala usunieta z Google Ads.'
: 'Fraza nie byla juz obecna w Google Ads. Usunieto lokalny wpis.';
echo json_encode( self::with_optional_debug( [
'success' => true,
'message' => $message,
'removed' => $removed
], [
'customer_id' => $customer_id,
'ad_group_external_id' => $ad_group_external_id,
'keyword_text' => $keyword_text,
'match_type' => $match_type,
'api_result' => $api_result
] ) );
exit;
}
}

View File

@@ -872,6 +872,10 @@ class Products
$db_results = \factory\Products::get_products( $client_id, $search, $limit, $start, $order_name, $order_dir, $campaign_id, $ad_group_id, $filter_cl4 );
$recordsTotal = \factory\Products::get_records_total_products( $client_id, $search, $campaign_id, $ad_group_id, $filter_cl4 );
// Sredni CR konta — do obliczenia progu klikniec
$account_cr = \factory\Products::get_account_conversion_rate( (int) $client_id );
if ( $account_cr <= 0 ) $account_cr = 0.02; // fallback 2%
$data['draw'] = \S::get( 'draw' );
$data['recordsTotal'] = $recordsTotal;
$data['recordsFiltered'] = $recordsTotal;
@@ -920,6 +924,45 @@ class Products
? '<a href="' . htmlspecialchars( $product_url ) . '" target="_blank" rel="noopener noreferrer" title="' . htmlspecialchars( $product_url ) . '"><i class="fa-solid fa-up-right-from-square"></i> Otworz</a>'
: '';
// Algorytm ostrzezen produktowych
$warnings = [];
$clicks = (int) $row['clicks'];
$conversions = (float) $row['conversions'];
$ctr = (float) $row['ctr'];
$min_roas_val = (float) $row['min_roas'];
$click_threshold = (int) ceil( 3 * ( 1 / $account_cr ) );
// 1. Niski CTR po 30+ klikniec
if ( $clicks >= 30 && $ctr < 0.5 )
{
$warnings[] = 'Niski CTR (' . round( $ctr, 2 ) . '%) po ' . $clicks . ' kliknieciach — prawdopodobny problem z tytulem, zdjeciem lub cena.';
}
// 2. Zero konwersji po progu klikniec
if ( $clicks >= $click_threshold && $conversions == 0 )
{
$warnings[] = 'Brak konwersji po ' . $clicks . ' kliknieciach (prog: ' . $click_threshold . '). Produkt prawdopodobnie nie sprzedaje sie z reklam.';
}
// 3. ROAS ponizej progu rentownosci
if ( $clicks >= $click_threshold && $conversions > 0 && $min_roas_val > 0 && $roasValue < $min_roas_val )
{
$warnings[] = 'ROAS ' . $roasDisplay . '% ponizej progu rentownosci (' . (int) $min_roas_val . '%). Rozważ optymalizacje lub wylaczenie.';
}
// 4. Strefa zolta — ROAS bliski progu (80-100% min_roas)
if ( $clicks >= $click_threshold && $conversions > 0 && $min_roas_val > 0 && $roasValue >= $min_roas_val && $roasValue < $min_roas_val * 1.5 )
{
$warnings[] = 'ROAS ' . $roasDisplay . '% — blisko progu rentownosci (' . (int) $min_roas_val . '%). Daj jeszcze 50-100 klikniec lub zoptymalizuj listing.';
}
$warningHtml = '';
if ( !empty( $warnings ) )
{
$warningTitle = htmlspecialchars( implode( "\n", $warnings ), ENT_QUOTES );
$warningHtml = '<span class="product-warning-icon" data-warnings="' . $warningTitle . '" title="Produkt ma problemy"><i class="fa-solid fa-triangle-exclamation"></i></span>';
}
$data['data'][] = [
'', // checkbox column
$row['product_id'],
@@ -935,9 +978,10 @@ class Products
<i class="fa fa-pencil"></i>
</span>
</div>',
$warningHtml,
$row['impressions'],
$row['impressions_30'],
'<span style="color: ' . ( $row['clicks'] > 200 ? ( $row['clicks'] > 400 ? '#0047ccff' : '#57b951' ) : '' ) . '">' . $row['clicks'] . '</span>',
$row['clicks'],
$row['clicks_30'],
round( $row['ctr'], 2 ) . '%',
\S::number_display( $row['cost'] ),

View File

@@ -319,6 +319,60 @@ class Campaigns
return $mdb -> delete( 'campaign_negative_keywords', [ 'id' => (int) $negative_keyword_row_id ] );
}
static public function get_keyword_context( $keyword_id )
{
global $mdb;
return $mdb -> query(
'SELECT
kw.id AS keyword_row_id,
kw.keyword_text,
kw.match_type,
kw.campaign_id AS db_campaign_id,
kw.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_keywords AS kw
INNER JOIN campaigns AS c ON c.id = kw.campaign_id
INNER JOIN clients AS cl ON cl.id = c.client_id
LEFT JOIN campaign_ad_groups AS ag ON ag.id = kw.ad_group_id
WHERE kw.id = :keyword_id
LIMIT 1',
[ ':keyword_id' => (int) $keyword_id ]
) -> fetch( \PDO::FETCH_ASSOC );
}
static public function update_keyword_match_type( $keyword_id, $new_match_type )
{
global $mdb;
return $mdb -> update( 'campaign_keywords', [
'match_type' => strtoupper( trim( (string) $new_match_type ) )
], [ 'id' => (int) $keyword_id ] );
}
static public function insert_campaign_keyword( $campaign_id, $ad_group_id, $keyword_text, $match_type )
{
global $mdb;
$mdb -> insert( 'campaign_keywords', [
'campaign_id' => (int) $campaign_id,
'ad_group_id' => (int) $ad_group_id,
'keyword_text' => trim( (string) $keyword_text ),
'match_type' => strtoupper( trim( (string) $match_type ) ),
'date_sync' => date( 'Y-m-d' )
] );
return (int) $mdb -> id();
}
static public function delete_campaign_keyword( $keyword_id )
{
global $mdb;
return $mdb -> delete( 'campaign_keywords', [ 'id' => (int) $keyword_id ] );
}
static public function delete_campaign( $campaign_id )
{
global $mdb;

View File

@@ -373,6 +373,28 @@ class Products
];
}
static public function get_account_conversion_rate( $client_id )
{
global $mdb;
$row = $mdb -> query(
'SELECT SUM( pa.clicks_all_time ) AS total_clicks,
SUM( pa.conversions_all_time ) AS total_conversions
FROM products_aggregate AS pa
INNER JOIN products AS p ON p.id = pa.product_id
WHERE p.client_id = :client_id
AND pa.clicks_all_time > 0',
[ ':client_id' => (int) $client_id ]
) -> fetch( \PDO::FETCH_ASSOC );
$total_clicks = (float) ( $row['total_clicks'] ?? 0 );
$total_conversions = (float) ( $row['total_conversions'] ?? 0 );
if ( $total_clicks <= 0 ) return 0;
return $total_conversions / $total_clicks;
}
static public function get_records_total_products( $client_id, $search, $campaign_id = 0, $ad_group_id = 0, $custom_label_4 = '' )
{
global $mdb;

View File

@@ -1987,6 +1987,162 @@ class GoogleAdsApi
return str_replace( [ '\\', '\'' ], [ '\\\\', '\\\'' ], (string) $value );
}
public function add_keyword_to_ad_group( $customer_id, $ad_group_id, $keyword_text, $match_type = 'BROAD' )
{
$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.' );
return [ 'success' => false, 'duplicate' => false ];
}
if ( !in_array( $match_type, [ 'PHRASE', 'EXACT', 'BROAD' ], true ) )
{
$match_type = 'BROAD';
}
$operation = [
'adGroupCriterionOperation' => [
'create' => [
'adGroup' => 'customers/' . $customer_id . '/adGroups/' . $ad_group_id,
'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 ];
}
return [ 'success' => false, 'duplicate' => false, 'sent_operation' => $operation ];
}
return [
'success' => true,
'duplicate' => false,
'response' => $result,
'sent_operation' => $operation
];
}
public function remove_keyword_from_ad_group( $customer_id, $ad_group_id, $keyword_text, $match_type = 'BROAD' )
{
$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 usuniecia frazy.' );
return [ 'success' => false, 'removed' => 0 ];
}
if ( !in_array( $match_type, [ 'PHRASE', 'EXACT', 'BROAD' ], true ) )
{
$match_type = 'BROAD';
}
$keyword_text_escaped = $this -> gaql_escape( $keyword_text );
$gaql = "SELECT "
. "ad_group_criterion.resource_name "
. "FROM ad_group_criterion "
. "WHERE ad_group.id = " . $ad_group_id . " "
. "AND ad_group_criterion.type = 'KEYWORD' "
. "AND ad_group_criterion.negative = FALSE "
. "AND ad_group_criterion.keyword.text = '" . $keyword_text_escaped . "' "
. "AND ad_group_criterion.keyword.match_type = " . $match_type . " "
. "LIMIT 50";
$results = $this -> search_stream( $customer_id, $gaql );
if ( $results === false )
{
return [ 'success' => false, 'removed' => 0 ];
}
$resource_names = [];
foreach ( (array) $results as $row )
{
$resource_name = (string) ( $row['adGroupCriterion']['resourceName'] ?? '' );
if ( $resource_name === '' )
{
$resource_name = (string) ( $row['ad_group_criterion']['resource_name'] ?? '' );
}
if ( $resource_name !== '' )
{
$resource_names[] = $resource_name;
}
}
$resource_names = array_values( array_unique( $resource_names ) );
if ( empty( $resource_names ) )
{
return [ 'success' => true, 'removed' => 0, 'not_found' => true ];
}
$operations = [];
foreach ( $resource_names as $resource_name )
{
$operations[] = [
'adGroupCriterionOperation' => [
'remove' => $resource_name
]
];
}
$mutate_result = $this -> mutate( $customer_id, $operations );
if ( $mutate_result === false )
{
return [ 'success' => false, 'removed' => 0, 'sent_operations' => $operations ];
}
return [
'success' => true,
'removed' => count( $resource_names ),
'response' => $mutate_result,
'sent_operations' => $operations
];
}
public function update_keyword_match_type( $customer_id, $ad_group_id, $keyword_text, $old_match_type, $new_match_type )
{
$remove_result = $this -> remove_keyword_from_ad_group( $customer_id, $ad_group_id, $keyword_text, $old_match_type );
if ( !( $remove_result['success'] ?? false ) && !( $remove_result['not_found'] ?? false ) )
{
return [ 'success' => false, 'step' => 'remove', 'remove_result' => $remove_result ];
}
$add_result = $this -> add_keyword_to_ad_group( $customer_id, $ad_group_id, $keyword_text, $new_match_type );
if ( !( $add_result['success'] ?? false ) )
{
return [ 'success' => false, 'step' => 'add', 'add_result' => $add_result, 'remove_result' => $remove_result ];
}
return [
'success' => true,
'remove_result' => $remove_result,
'add_result' => $add_result
];
}
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 ) );

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2816,7 +2816,8 @@ table#products {
}
}
.terms-negative-toolbar {
.terms-negative-toolbar,
.terms-keywords-toolbar {
display: flex;
align-items: center;
gap: 10px;
@@ -3136,7 +3137,8 @@ table#products {
}
.terms-add-negative-btn,
.terms-remove-negative-btn {
.terms-remove-negative-btn,
.terms-delete-keyword-btn {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -3159,7 +3161,8 @@ table#products {
}
}
.terms-remove-negative-btn {
.terms-remove-negative-btn,
.terms-delete-keyword-btn {
border: 1px solid #FECACA;
background: #FEF2F2;
color: #DC2626;
@@ -3171,6 +3174,26 @@ table#products {
}
}
.terms-change-match-type-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid #E2E8F0;
background: #EEF2FF;
color: #3B82F6;
&:hover {
background: #3B82F6;
color: #FFFFFF;
border-color: #3B82F6;
}
}
tbody tr:hover {
background: #F8FAFC;
}
@@ -3353,6 +3376,19 @@ table#products {
}
}
.product-warning-icon {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #DC2626;
font-size: 16px;
&:hover {
color: #B91C1C;
}
}
.products-row-actions {
display: inline-flex;
align-items: center;

View File

@@ -160,6 +160,11 @@
<div class="campaigns-extra-card-title">
<i class="fa-solid fa-key"></i> Frazy dodane do kampanii/grup reklam
</div>
<div class="terms-keywords-toolbar">
<button type="button" id="terms_add_keyword_btn" class="terms-negative-bulk-btn">
<i class="fa-solid fa-plus"></i> Dodaj fraze
</button>
</div>
<div class="campaigns-extra-table-wrap">
<table class="table table-sm campaigns-extra-table" id="terms_keywords_table">
<thead>
@@ -167,6 +172,7 @@
<th>Fraza</th>
<th>Match type</th>
<th>Grupa reklam</th>
<th>Akcja</th>
<th>Klik. all</th>
<th>Koszt all</th>
<th>Wartosc all</th>
@@ -180,7 +186,7 @@
</tr>
</thead>
<tbody>
<tr><td colspan="13" class="campaigns-empty-row">Brak danych.</td></tr>
<tr><td colspan="14" class="campaigns-empty-row">Brak danych.</td></tr>
</tbody>
</table>
</div>
@@ -1364,6 +1370,13 @@ function terms_clear_negative_selection()
}
var MATCH_TYPE_LABELS = { 'BROAD': 'Przyblizone', 'PHRASE': 'Do wyrazenia', 'EXACT': 'Scisle' };
function render_match_type_label( data, type ) {
if ( type !== 'display' ) return data || '';
var val = String( data || '' ).toUpperCase();
return MATCH_TYPE_LABELS[val] || val;
}
function build_ad_groups_table( rows )
{
destroy_table_if_exists( '#terms_ad_groups_table' );
@@ -1530,7 +1543,7 @@ function build_negative_terms_table( rows )
return '<input type="checkbox" class="terms-negative-select-row" data-negative-keyword-id="' + negative_keyword_id + '" aria-label="Zaznacz fraze do usuniecia">';
} },
{ data: 'keyword_text', defaultContent: '', width: '520px' },
{ data: 'match_type', defaultContent: '' },
{ data: 'match_type', defaultContent: '', render: render_match_type_label },
{ data: 'id', orderable: false, searchable: false, render: function( data, type ) {
if ( type !== 'display' ) return data;
return '<button type="button" class="terms-remove-negative-btn" data-negative-keyword-id="' + data + '" title="Usun z wykluczajacych"><i class="fa-solid fa-trash"></i></button>';
@@ -1597,11 +1610,16 @@ function build_keywords_table( rows )
lengthChange: false,
pageLength: 15,
pagingType: 'simple_numbers',
order: [[ 3, 'desc' ]],
order: [[ 4, 'desc' ]],
columns: [
{ data: 'keyword_text', defaultContent: '' },
{ data: 'match_type', defaultContent: '' },
{ data: 'match_type', defaultContent: '', render: render_match_type_label },
{ data: 'ad_group_name', defaultContent: '-' },
{ data: 'id', orderable: false, searchable: false, render: function( data, type, row ) {
if ( type !== 'display' ) return data;
return '<button type="button" class="terms-change-match-type-btn" data-keyword-id="' + data + '" title="Zmien dopasowanie"><i class="fa-solid fa-pen-to-square"></i></button>' +
' <button type="button" class="terms-delete-keyword-btn" data-keyword-id="' + data + '" title="Usun fraze"><i class="fa-solid fa-trash"></i></button>';
} },
{ data: 'clicks_all_time', render: function( data, type ){ return type === 'display' ? format_num( data, 0 ) : Number( data || 0 ); } },
{ data: 'cost_all_time', render: function( data, type ){ return type === 'display' ? format_num( data, 2 ) : Number( data || 0 ); } },
{ data: 'conversion_value_all_time', render: function( data, type ){ return type === 'display' ? format_num( data, 2 ) : Number( data || 0 ); } },
@@ -1619,10 +1637,11 @@ function build_keywords_table( rows )
columnDefs: [
{ targets: 0, className: 'text-cell phrase-nowrap', width: '220px' },
{ targets: [1,2], className: 'text-cell', width: '140px' },
{ targets: [3,8], className: 'num-cell', width: '70px' },
{ targets: [4,5,9,10], className: 'num-cell', width: '85px' },
{ targets: [6,11], className: 'num-cell', width: '80px' },
{ targets: [7,12], className: 'num-cell', width: '130px' }
{ targets: 3, className: 'dt-center', width: '90px' },
{ targets: [4,9], className: 'num-cell', width: '70px' },
{ targets: [5,6,10,11], className: 'num-cell', width: '85px' },
{ targets: [7,12], className: 'num-cell', width: '80px' },
{ targets: [8,13], className: 'num-cell', width: '130px' }
],
language: {
emptyTable: 'Brak danych do wyswietlenia',
@@ -2338,6 +2357,292 @@ $( function()
terms_ad_groups_table.columns.adjust().draw( false );
});
$( 'body' ).on( 'click', '.terms-change-match-type-btn', function()
{
var keyword_id = parseInt( $( this ).data( 'keyword-id' ), 10 );
var row_data = terms_keywords_table ? terms_keywords_table.row( $( this ).closest( 'tr' ) ).data() : null;
var keyword_text = row_data ? String( row_data.keyword_text || '' ) : '';
var current_match = row_data ? String( row_data.match_type || 'BROAD' ).toUpperCase() : 'BROAD';
if ( !keyword_id ) return;
$.confirm({
title: 'Zmien dopasowanie frazy',
columnClass: 'col-md-4 col-md-offset-4',
content:
'<div style="margin-bottom:10px;"><strong>' + $('<span>').text( keyword_text ).html() + '</strong></div>' +
'<div class="form-group" style="margin-bottom:0;">' +
'<label for="kw_new_match_type" style="display:block;margin-bottom:6px;">Nowy typ dopasowania</label>' +
'<select id="kw_new_match_type" class="form-control">' +
'<option value="BROAD"' + ( current_match === 'BROAD' ? ' selected' : '' ) + '>Dopasowanie przyblizone</option>' +
'<option value="PHRASE"' + ( current_match === 'PHRASE' ? ' selected' : '' ) + '>Dopasowanie do wyrazenia</option>' +
'<option value="EXACT"' + ( current_match === 'EXACT' ? ' selected' : '' ) + '>Dopasowanie scisle</option>' +
'</select>' +
'<small style="display:block;margin-top:6px;color:#64748B;">Obecne: ' + current_match + '. Zmiana usunie stary keyword i doda nowy w Google Ads.</small>' +
'</div>',
type: 'blue',
buttons: {
confirm: {
text: 'Zmien',
btnClass: 'btn-blue',
action: function()
{
var new_match_type = this.$content.find( '#kw_new_match_type' ).val() || 'BROAD';
var modal = this;
if ( new_match_type === current_match )
{
$.alert({ title: 'Info', columnClass: 'col-md-4 col-md-offset-4', content: 'Wybrany typ dopasowania jest taki sam jak obecny.', type: 'blue' });
return;
}
modal.showLoading( true );
$.ajax({
url: '/campaign_terms/update_keyword_match_type/',
type: 'POST',
data: { keyword_id: keyword_id, new_match_type: new_match_type },
success: function( response )
{
var data = JSON.parse( response );
modal.close();
if ( data.success )
{
var debugHtml = terms_build_debug_details_html( data.debug || null );
var successDialog = $.alert({
title: 'Sukces',
columnClass: 'col-md-4 col-md-offset-4',
content: ( data.message || 'Dopasowanie zmienione.' ) + debugHtml,
type: 'green'
});
setTimeout( function() { successDialog.close(); }, 8000 );
load_phrase_tables();
}
else
{
var debugHtml = terms_build_debug_details_html( data.debug || null );
$.alert({
title: 'Blad',
columnClass: 'col-md-4 col-md-offset-4',
content: ( data.message || 'Nie udalo sie zmienic dopasowania.' ) + ( data.error ? '<br><small>' + data.error + '</small>' : '' ) + debugHtml,
type: 'red'
});
}
},
error: function()
{
modal.close();
$.alert({ title: 'Blad', columnClass: 'col-md-4 col-md-offset-4', content: 'Blad komunikacji z serwerem.', type: 'red' });
}
});
return false;
}
},
cancel: { text: 'Anuluj' }
}
});
});
$( 'body' ).on( 'click', '.terms-delete-keyword-btn', function()
{
var keyword_id = parseInt( $( this ).data( 'keyword-id' ), 10 );
var row_data = terms_keywords_table ? terms_keywords_table.row( $( this ).closest( 'tr' ) ).data() : null;
var keyword_text = row_data ? String( row_data.keyword_text || '' ) : '';
if ( !keyword_id ) return;
$.confirm({
title: 'Usun fraze',
columnClass: 'col-md-4 col-md-offset-4',
content: 'Czy na pewno usunac fraze <strong>' + $('<span>').text( keyword_text ).html() + '</strong>? Operacja zostanie wyslana do Google Ads API.',
type: 'red',
buttons: {
confirm: {
text: 'Usun',
btnClass: 'btn-red',
keys: [ 'enter' ],
action: function()
{
var modal = this;
modal.showLoading( true );
$.ajax({
url: '/campaign_terms/delete_keyword/',
type: 'POST',
data: { keyword_id: keyword_id },
success: function( response )
{
var data = JSON.parse( response );
modal.close();
var debugHtml = terms_build_debug_details_html( data.debug || null );
if ( data.success )
{
$.alert({
title: 'Sukces',
columnClass: 'col-md-4 col-md-offset-4',
content: ( data.message || 'Fraza zostala usunieta.' ) + debugHtml,
type: 'green'
});
load_phrase_tables();
}
else
{
$.alert({
title: 'Blad',
columnClass: 'col-md-4 col-md-offset-4',
content: ( data.message || 'Nie udalo sie usunac frazy.' ) + ( data.error ? '<br><small>' + data.error + '</small>' : '' ) + debugHtml,
type: 'red'
});
}
},
error: function()
{
modal.close();
$.alert({ title: 'Blad', columnClass: 'col-md-4 col-md-offset-4', content: 'Blad komunikacji z serwerem.', type: 'red' });
}
});
return false;
}
},
cancel: { text: 'Anuluj' }
}
});
});
$( 'body' ).on( 'click', '#terms_add_keyword_btn', function()
{
var campaign_id = $( '#terms_campaign_id' ).val();
if ( !campaign_id )
{
$.alert({ title: 'Uwaga', columnClass: 'col-md-4 col-md-offset-4', content: 'Wybierz kampanie przed dodaniem frazy.', type: 'orange' });
return;
}
var ad_group_options = '';
$( '#terms_ad_group_id option' ).each( function()
{
var val = $( this ).val();
if ( val )
ad_group_options += '<option value="' + val + '">' + $('<span>').text( $( this ).text() ).html() + '</option>';
});
if ( !ad_group_options )
{
$.alert({ title: 'Uwaga', columnClass: 'col-md-4 col-md-offset-4', content: 'Brak grup reklam. Wybierz kampanie z grupami.', type: 'orange' });
return;
}
var selected_ad_group = $( '#terms_ad_group_id' ).val() || '';
$.confirm({
title: 'Dodaj fraze do grupy reklam',
columnClass: 'col-md-4 col-md-offset-4',
content:
'<div class="form-group" style="margin-bottom:10px;">' +
'<label for="add_kw_text" style="display:block;margin-bottom:6px;">Fraza</label>' +
'<input id="add_kw_text" type="text" class="form-control" placeholder="Wpisz fraze..." />' +
'</div>' +
'<div class="form-group" style="margin-bottom:10px;">' +
'<label for="add_kw_match_type" style="display:block;margin-bottom:6px;">Typ dopasowania</label>' +
'<select id="add_kw_match_type" class="form-control">' +
'<option value="BROAD" selected>Dopasowanie przyblizone</option>' +
'<option value="PHRASE">Dopasowanie do wyrazenia</option>' +
'<option value="EXACT">Dopasowanie scisle</option>' +
'</select>' +
'</div>' +
'<div class="form-group" style="margin-bottom:0;">' +
'<label for="add_kw_ad_group" style="display:block;margin-bottom:6px;">Grupa reklam</label>' +
'<select id="add_kw_ad_group" class="form-control">' + ad_group_options + '</select>' +
'</div>',
type: 'blue',
onContentReady: function()
{
if ( selected_ad_group )
this.$content.find( '#add_kw_ad_group' ).val( selected_ad_group );
},
buttons: {
confirm: {
text: 'Dodaj',
btnClass: 'btn-blue',
action: function()
{
var keyword_text = $.trim( this.$content.find( '#add_kw_text' ).val() || '' );
var match_type = this.$content.find( '#add_kw_match_type' ).val() || 'BROAD';
var ad_group_id = this.$content.find( '#add_kw_ad_group' ).val() || '';
var modal = this;
if ( keyword_text === '' )
{
$.alert({ title: 'Uwaga', columnClass: 'col-md-4 col-md-offset-4', content: 'Wpisz fraze do dodania.', type: 'orange' });
return false;
}
if ( !ad_group_id )
{
$.alert({ title: 'Uwaga', columnClass: 'col-md-4 col-md-offset-4', content: 'Wybierz grupe reklam.', type: 'orange' });
return false;
}
modal.showLoading( true );
$.ajax({
url: '/campaign_terms/add_keyword/',
type: 'POST',
data: {
campaign_id: campaign_id,
ad_group_id: ad_group_id,
keyword_text: keyword_text,
match_type: match_type
},
success: function( response )
{
var data = JSON.parse( response );
modal.close();
if ( data.success )
{
var debugHtml = terms_build_debug_details_html( data.debug || null );
var successDialog = $.alert({
title: 'Sukces',
columnClass: 'col-md-4 col-md-offset-4',
content: ( data.message || 'Fraza zostala dodana.' )
+ '<br><small style="display:block;margin-top:8px;color:#64748B;">Zmiana moze byc widoczna w panelu Google Ads po 1-3 minutach.</small>'
+ debugHtml,
type: 'green'
});
setTimeout( function() { successDialog.close(); }, 10000 );
load_phrase_tables();
}
else
{
var debugHtml = terms_build_debug_details_html( data.debug || null );
$.alert({
title: 'Blad',
columnClass: 'col-md-4 col-md-offset-4',
content: ( data.message || 'Nie udalo sie dodac frazy.' ) + ( data.error ? '<br><small>' + data.error + '</small>' : '' ) + debugHtml,
type: 'red'
});
}
},
error: function()
{
modal.close();
$.alert({ title: 'Blad', columnClass: 'col-md-4 col-md-offset-4', content: 'Blad komunikacji z serwerem.', type: 'red' });
}
});
return false;
}
},
cancel: { text: 'Anuluj' }
}
});
});
terms_restore_ad_groups_collapsed();
reset_all_tables();

View File

@@ -87,6 +87,7 @@
<th>Grupa reklam</th>
<th>URL</th>
<th>Nazwa produktu</th>
<th title="Ostrzezenia produktowe"><i class="fa-solid fa-triangle-exclamation"></i></th>
<th>Wyśw.</th>
<th>Wyśw. (30d)</th>
<th>Klik.</th>
@@ -119,7 +120,7 @@ var AI_OPENAI_ENABLED = <?= $openai_enabled ? 'true' : 'false'; ?>;
var AI_CLAUDE_ENABLED = <?= $claude_enabled ? 'true' : 'false'; ?>;
var AI_GEMINI_ENABLED = <?= $gemini_enabled ? 'true' : 'false'; ?>;
var PRODUCTS_COLUMNS_STORAGE_KEY = 'products.columns.visibility';
var PRODUCTS_LOCKED_COLUMNS = [ 0, 20 ];
var PRODUCTS_LOCKED_COLUMNS = [ 0, 21 ];
function show_toast( message, type )
{
@@ -347,6 +348,7 @@ $( function()
{ width: '200px', name: 'ad_group_name' },
{ width: '120px', orderable: false, searchable: false },
{ name: 'name' },
{ width: '35px', orderable: false, searchable: false, className: 'dt-center' },
{ width: '50px', name: 'impressions' },
{ width: '80px', name: 'impressions_30' },
{ width: '50px', name: 'clicks' },
@@ -363,12 +365,12 @@ $( function()
{ width: '190px', orderable: false, className: 'dt-center' }
],
createdRow: function( row, data ) {
var cl4Val = $( data[19] ).val();
var cl4Val = $( data[20] ).val();
if ( cl4Val && cl4Val.toLowerCase() === 'niedostępny' ) {
$( row ).addClass( 'product-row-unavailable' );
}
},
order: [ [ 9, 'desc' ] ],
order: [ [ 10, 'desc' ] ],
language: {
processing: 'Ładowanie...',
emptyTable: 'Brak produktów do wyświetlenia',
@@ -1826,6 +1828,28 @@ $( function()
updateSelectedCount();
});
$( 'body' ).on( 'click', '.product-warning-icon', function()
{
var warnings = $( this ).data( 'warnings' ) || '';
if ( !warnings ) return;
var lines = String( warnings ).split( '\n' );
var html = '<ul style="text-align:left;padding-left:18px;margin:0;">';
for ( var i = 0; i < lines.length; i++ )
{
if ( $.trim( lines[i] ) !== '' )
html += '<li style="margin-bottom:6px;">' + $('<span>').text( lines[i] ).html() + '</li>';
}
html += '</ul>';
$.alert({
title: 'Ostrzezenia produktu',
columnClass: 'col-md-5 col-md-offset-4',
content: html,
type: 'orange'
});
});
$( 'body' ).on( 'change', '.products-col-toggle', function()
{
var col_index = Number( $( this ).data( 'col-index' ) );