| Server IP : 5.129.245.98 / Your IP : 216.73.216.246 Web Server : Apache/2.4.66 (Debian) System : Linux 1b8c17c336b9 6.8.0-111-generic #111-Ubuntu SMP PREEMPT_DYNAMIC Sat Apr 11 23:16:02 UTC 2026 x86_64 User : www-data ( 33) PHP Version : 8.3.30 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : OFF | Perl : ON | Python : OFF | Sudo : OFF | Pkexec : OFF Directory : /var/www/html/wp-content/plugins/content-egg/application/admin/ |
Upload File : |
<?php
namespace ContentEgg\application\admin;
defined('\ABSPATH') || exit;
use ContentEgg\application\Plugin;
use ContentEgg\application\components\ContentManager;
use ContentEgg\application\components\ModuleManager;
use ContentEgg\application\helpers\ClickStatsHelper;
/**
* ClicksStatsController — admin dashboard for affiliate clicks statistics
*
* @author keywordrush.com <support@keywordrush.com>
* @link https://www.keywordrush.com
* @copyright Copyright © 2025 keywordrush.com
*
* Range-first: all stats reflect ONLY the selected date range.
*/
class ClicksStatsController
{
public const slug = 'content-egg-stats';
public function __construct()
{
if (!ClickStatsHelper::isEnabled())
{
return;
}
\add_action('admin_menu', [$this, 'add_admin_menu']);
\add_action('admin_init', [$this, 'remove_http_referer']);
}
/** Prevent nested _wp_http_referer on refresh */
public function remove_http_referer(): void
{
global $pagenow;
if (
$pagenow === 'admin.php'
&& isset($_GET['page'])
&& $_GET['page'] === self::slug
&& !empty($_GET['_wp_http_referer'])
&& isset($_SERVER['REQUEST_URI'])
)
{
\wp_safe_redirect(\remove_query_arg(['_wp_http_referer', '_wpnonce'], esc_url_raw(\wp_unslash($_SERVER['REQUEST_URI']))));
exit;
}
}
public function add_admin_menu(): void
{
$menu_title = esc_html__('Clicks Statistics', 'content-egg');
\add_submenu_page(
Plugin::slug,
__('Clicks Statistics', 'content-egg') . ' ‹ Content Egg',
$menu_title,
'publish_posts',
self::slug,
[$this, 'actionIndex']
);
}
/** Dashboard (Range-first) */
public function actionIndex(): void
{
if (Plugin::isInactiveEnvato())
{
echo '<div class="wrap">';
echo '<h1>' . esc_html__('Clicks Statistics', 'content-egg') . '</h1>';
echo '<div class="notice notice-error"><p>'
. esc_html__('You need to activate the plugin first to view Clicks Statistics.', 'content-egg')
. '</p></div>';
$activate_url = admin_url('admin.php?page=content-egg-lic');
echo '<p><a href="' . esc_url($activate_url) . '" class="button button-primary">'
. esc_html__('Activate now', 'content-egg') . '</a></p>';
echo '</div></div>';
return;
}
\wp_enqueue_style('cegg-bootstrap5-full');
\wp_enqueue_style('cegg-bootstrap-icons', PluginAdmin::res('/admin/bootstrap/css/bootstrap-icons.min.css'), [], Plugin::version());
// Filters
$filters = $this->readFilters(); // range/module/date_from/date_to/range_label
// Summary for selected range + previous equal-length range
$rangeSummary = $this->getRangeSummary($filters['date_from'], $filters['date_to'], $filters['module_id']);
$rangeComparison = $this->buildRangeComparison($filters['date_from'], $filters['date_to'], $filters['module_id'], $rangeSummary['range_total'], $rangeSummary['prev_total']);
// Chart (selected range only)
$chart = $this->buildChartOptions($filters['date_from'], $filters['date_to'], $filters['module_id']);
// Top lists (selected range only)
$limit = (int) apply_filters('cegg_clicks_stats_top_items_limit', 20);
$top_links = $this->getTopLinks($filters['date_from'], $filters['date_to'], $filters['module_id'], $limit);
$top_posts = $this->getTopPosts($filters['date_from'], $filters['date_to'], $filters['module_id'], $limit);
// Zero-click lists (selected range)
$no_click_links = $this->getNoClickLinks($filters['date_from'], $filters['date_to'], $filters['module_id'], $limit);
$no_click_posts = $this->getNoClickPosts($filters['date_from'], $filters['date_to'], $filters['module_id'], $limit);
// Modules for dropdown (already filtered by tracking mode)
$modules = ClickStatsHelper::modulesForStatsFilter();
// Tracking overview
$overview = $this->buildTrackingOverview();
// Donut: clicks share by module (selected range)
$moduleShareChart = $this->buildModuleShareChartOptions($filters['date_from'], $filters['date_to'], $filters['module_id']);
PluginAdmin::getInstance()->render('stats_index', [
'filters' => $filters,
'range_summary' => $rangeSummary, // ['range_total','prev_total','days','range_label','date_from','date_to','today_in_range']
'range_comparison' => $rangeComparison, // ['label','class','icon','pct','abs','sign']
'modules' => $modules,
'clicksChartOptions' => $chart,
'top_links' => $top_links, // rows with 'clicks' (range) and 'prev_clicks'
'top_posts' => $top_posts, // rows with 'clicks' (range) and 'prev_clicks'
'no_click_links' => $no_click_links,
'no_click_posts' => $no_click_posts,
'track_redirect_enabled' => $overview['track_redirect_enabled'],
'track_direct_enabled' => $overview['track_direct_enabled'],
'mods_redirect' => $overview['mods_redirect'],
'mods_direct' => $overview['mods_direct'],
'mods_none' => $overview['mods_none'],
'moduleShareChartOptions' => $moduleShareChart,
]);
}
// =================
// Range-first core
// =================
/**
* Range summary:
* - range_total: clicks in [date_from, date_to]
* - prev_total : clicks in previous range of the same length
* - days : number of days in range
* - today_in_range: bool (whether range end is today)
*/
private function getRangeSummary(string $date_from, string $date_to, string $module_id = ''): array
{
$days = max(1, $this->daysInRange($date_from, $date_to));
$range_total = $this->sumClicksBetween($date_from, $date_to, $module_id);
$prev_from = $this->ymdShift($date_from, -$days);
$prev_to = $this->ymdShift($date_to, -$days);
$prev_total = $this->sumClicksBetween($prev_from, $prev_to, $module_id);
// Is range end "today" (site-local)?
$now_ts = \current_time('timestamp');
$tzshift = (int) get_option('gmt_offset') * HOUR_IN_SECONDS;
$todayYmd = gmdate('Y-m-d', $now_ts + $tzshift);
$today_in_range = ($date_to === $todayYmd);
return [
'range_total' => (int) $range_total,
'prev_total' => (int) $prev_total,
'days' => $days,
'date_from' => $date_from,
'date_to' => $date_to,
'range_label' => $date_from . ' → ' . $date_to,
'today_in_range' => $today_in_range,
];
}
/**
* Build range-vs-previous comparison descriptor for the UI.
*/
private function buildRangeComparison(string $date_from, string $date_to, string $module_id, int $current, int $previous): array
{
// Reuse delta formatter
$delta = $this->makeDelta($current, $previous, __('previous period', 'content-egg'), false);
// Add raw numbers for optional tooltips / progress bars
$sign = 0;
if ($current > $previous) $sign = 1;
elseif ($current < $previous) $sign = -1;
$abs = $current - $previous;
$pct = ($previous > 0) ? round(($abs / $previous) * 100) : null;
return [
'label' => $delta['label'],
'class' => $delta['class'],
'icon' => $delta['icon'],
'pct' => $pct,
'abs' => $abs,
'sign' => $sign,
];
}
// ===========
// Data reads
// ===========
/**
* Sum clicks for a date window (inclusive), optionally filtered by module_id.
* NOTE: This aggregates from the daily table; it intentionally includes all posts,
* regardless of post_status. (Per-item views exclude trashed where relevant.)
*/
private function sumClicksBetween(string $fromYmd, string $toYmd, string $module_id = ''): int
{
global $wpdb;
$lc = $wpdb->prefix . 'cegg_link_clicks_daily';
$idx = $wpdb->prefix . 'cegg_link_index';
if ($module_id !== '')
{
$sql = $wpdb->prepare(
"SELECT COALESCE(SUM(lc.clicks),0)
FROM {$lc} lc
INNER JOIN {$idx} li ON li.id = lc.link_id
WHERE lc.ymd BETWEEN %s AND %s
AND li.module_id = %s",
$fromYmd,
$toYmd,
$module_id
);
}
else
{
$sql = $wpdb->prepare(
"SELECT COALESCE(SUM(clicks),0)
FROM {$lc}
WHERE ymd BETWEEN %s AND %s",
$fromYmd,
$toYmd
);
}
$sum = (int) $wpdb->get_var($sql);
return $sum > 0 ? $sum : 0;
}
/** One-day sum helper */
private function sumDay(string $ymd, string $module_id = ''): int
{
return $this->sumClicksBetween($ymd, $ymd, $module_id);
}
/** Days in an inclusive Y-m-d range. */
private function daysInRange(string $fromYmd, string $toYmd): int
{
$a = strtotime($fromYmd . ' 00:00:00');
$b = strtotime($toYmd . ' 00:00:00');
if ($a === false || $b === false) return 0;
return (int) (abs(($b - $a) / DAY_IN_SECONDS)) + 1;
}
/** Build a single delta descriptor (shared formatter) */
private function makeDelta(int $current, int $previous, string $refLabel, bool $suppressNew = false): array
{
if ($previous <= 0)
{
if ($suppressNew)
{
return ['label' => '', 'class' => 'text-muted', 'icon' => 'bi-dash'];
}
if ($current <= 0)
{
return ['label' => __('0% from ', 'content-egg') . $refLabel, 'class' => 'text-muted', 'icon' => 'bi-dash'];
}
return ['label' => __('new from ', 'content-egg') . $refLabel, 'class' => 'text-success', 'icon' => 'bi-arrow-up-right'];
}
$diff = $current - $previous;
if ($diff === 0)
{
return ['label' => __('0% from ', 'content-egg') . $refLabel, 'class' => 'text-muted', 'icon' => 'bi-dash'];
}
$pct = round(($diff / $previous) * 100);
$sign = $pct > 0 ? '+' : '';
return [
'label' => sprintf(__('%s%d%% from ', 'content-egg'), $sign, $pct) . $refLabel,
'class' => $pct > 0 ? 'text-success' : 'text-danger',
'icon' => $pct > 0 ? 'bi-arrow-up-right' : 'bi-arrow-down-right',
];
}
/** Clicks chart data (daily series) for Morris — range only */
private function buildChartOptions(string $date_from, string $date_to, string $module_id = ''): array
{
global $wpdb;
$lc = $wpdb->prefix . 'cegg_link_clicks_daily';
$idx = $wpdb->prefix . 'cegg_link_index';
if ($module_id !== '')
{
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT lc.ymd AS d, SUM(lc.clicks) AS c
FROM {$lc} lc INNER JOIN {$idx} li ON li.id = lc.link_id
WHERE lc.ymd BETWEEN %s AND %s AND li.module_id = %s
GROUP BY lc.ymd ORDER BY lc.ymd ASC",
$date_from,
$date_to,
$module_id
),
ARRAY_A
);
}
else
{
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT ymd AS d, SUM(clicks) AS c
FROM {$lc}
WHERE ymd BETWEEN %s AND %s
GROUP BY ymd ORDER BY ymd ASC",
$date_from,
$date_to
),
ARRAY_A
);
}
// Index by date for fast fill
$byDate = [];
foreach ((array) $rows as $r)
{
$byDate[(string) $r['d']] = (int) $r['c'];
}
// Fill all days in range with zeros if missing
$series = [];
$day = $date_from;
while ($day <= $date_to)
{
$series[] = [
'date' => $day,
'clicks' => (int) ($byDate[$day] ?? 0),
];
$day = $this->ymdShift($day, 1);
}
return [
'chartType' => 'Area',
'data' => $series,
'xkey' => 'date',
'ykeys' => ['clicks'],
'labels' => [__('Clicks', 'content-egg')],
'lineColors' => ['#1a73e8'],
'fillOpacity' => 0.15,
'pointFillColors' => ['#ffffff'],
'pointStrokeColors' => ['#1a73e8'],
'lineWidth' => 2,
];
}
/**
* Top links (products) — range only.
* Also includes prev_clicks for the previous equal-length range (for deltas in UI).
*/
private function getTopLinks(string $date_from, string $date_to, string $module_id = '', int $limit = 15): array
{
global $wpdb;
$lc = $wpdb->prefix . 'cegg_link_clicks_daily';
$idx = $wpdb->prefix . 'cegg_link_index';
$post = $wpdb->posts;
// Current range
if ($module_id !== '')
{
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT li.id AS link_id, li.post_id, li.slug, li.module_id, li.unique_id,
SUM(lc.clicks) AS clicks
FROM {$idx} li
INNER JOIN {$post} p ON p.ID = li.post_id AND p.post_status <> 'trash'
INNER JOIN {$lc} lc ON lc.link_id = li.id AND lc.ymd BETWEEN %s AND %s
WHERE li.module_id = %s
GROUP BY li.id
ORDER BY clicks DESC
LIMIT %d",
$date_from,
$date_to,
$module_id,
$limit
),
ARRAY_A
);
}
else
{
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT li.id AS link_id, li.post_id, li.slug, li.module_id, li.unique_id,
SUM(lc.clicks) AS clicks
FROM {$idx} li
INNER JOIN {$post} p ON p.ID = li.post_id AND p.post_status <> 'trash'
INNER JOIN {$lc} lc ON lc.link_id = li.id AND lc.ymd BETWEEN %s AND %s
GROUP BY li.id
ORDER BY clicks DESC
LIMIT %d",
$date_from,
$date_to,
$limit
),
ARRAY_A
);
}
// Previous equal-length range for the same link set (to compute deltas if needed)
$days = max(1, $this->daysInRange($date_from, $date_to));
$prev_from = $this->ymdShift($date_from, -$days);
$prev_to = $this->ymdShift($date_to, -$days);
$linkIds = array_map(static fn($r) => (int)$r['link_id'], (array) $rows);
$prevClicksByLink = [];
if ($linkIds)
{
$in = implode(',', array_fill(0, count($linkIds), '%d'));
$sqlPrev = $wpdb->prepare(
"SELECT lc.link_id, SUM(lc.clicks) AS clicks
FROM {$lc} lc
WHERE lc.link_id IN ($in)
AND lc.ymd BETWEEN %s AND %s
GROUP BY lc.link_id",
array_merge($linkIds, [$prev_from, $prev_to])
);
$prevRows = $wpdb->get_results($sqlPrev, ARRAY_A);
foreach ((array)$prevRows as $pr)
{
$prevClicksByLink[(int)$pr['link_id']] = (int)$pr['clicks'];
}
}
// Enrich with product title + edit URL anchored to unique_id; fallback to humanized slug
$out = [];
foreach ((array) $rows as $r)
{
$pid = (int) $r['post_id'];
$mid = (string) $r['module_id'];
$uid = (string) $r['unique_id'];
$slugLabel = ucwords(str_replace('-', ' ', (string) $r['slug']));
$product = ContentManager::getProductbyUniqueId($uid, $mid, $pid);
$title = isset($product['title']) && $product['title'] !== '' ? (string) $product['title'] : $slugLabel;
$lid = (int) $r['link_id'];
$prev = (int) ($prevClicksByLink[$lid] ?? 0);
$out[] = [
'link_id' => $lid,
'title' => $title,
'post_edit_url' => $pid > 0 ? admin_url('post.php?post=' . $pid . '&action=edit#' . rawurlencode($mid . '-' . $uid)) : '',
'clicks' => (int) $r['clicks'], // range clicks
'prev_clicks' => $prev, // previous equal-length range
];
}
return $out;
}
/**
* Top posts — range only.
* Also includes prev_clicks for the previous equal-length range.
*/
private function getTopPosts(string $date_from, string $date_to, string $module_id = '', int $limit = 15): array
{
global $wpdb;
$lc = $wpdb->prefix . 'cegg_link_clicks_daily';
$idx = $wpdb->prefix . 'cegg_link_index';
$post = $wpdb->posts;
if ($module_id !== '')
{
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT li.post_id, SUM(lc.clicks) AS clicks
FROM {$idx} li
INNER JOIN {$post} p ON p.ID = li.post_id AND p.post_status <> 'trash'
INNER JOIN {$lc} lc ON lc.link_id = li.id AND lc.ymd BETWEEN %s AND %s
WHERE li.module_id = %s
GROUP BY li.post_id
ORDER BY clicks DESC
LIMIT %d",
$date_from,
$date_to,
$module_id,
$limit
),
ARRAY_A
);
}
else
{
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT li.post_id, SUM(lc.clicks) AS clicks
FROM {$idx} li
INNER JOIN {$post} p ON p.ID = li.post_id AND p.post_status <> 'trash'
INNER JOIN {$lc} lc ON lc.link_id = li.id AND lc.ymd BETWEEN %s AND %s
GROUP BY li.post_id
ORDER BY clicks DESC
LIMIT %d",
$date_from,
$date_to,
$limit
),
ARRAY_A
);
}
// Previous equal-length range for these posts
$days = max(1, $this->daysInRange($date_from, $date_to));
$prev_from = $this->ymdShift($date_from, -$days);
$prev_to = $this->ymdShift($date_to, -$days);
$postIds = array_map(static fn($r) => (int)$r['post_id'], (array) $rows);
$prevByPost = [];
if ($postIds)
{
$in = implode(',', array_fill(0, count($postIds), '%d'));
// Sum clicks across links that belong to these posts during the previous range
$sqlPrev = $wpdb->prepare(
"SELECT li.post_id, SUM(lc.clicks) AS clicks
FROM {$idx} li
INNER JOIN {$lc} lc ON lc.link_id = li.id
WHERE li.post_id IN ($in)
AND lc.ymd BETWEEN %s AND %s
GROUP BY li.post_id",
array_merge($postIds, [$prev_from, $prev_to])
);
$prevRows = $wpdb->get_results($sqlPrev, ARRAY_A);
foreach ((array)$prevRows as $pr)
{
$prevByPost[(int)$pr['post_id']] = (int)$pr['clicks'];
}
}
$out = [];
foreach ((array) $rows as $r)
{
$pid = (int) $r['post_id'];
if ($pid <= 0) continue;
$title = get_the_title($pid);
if ($title === '') $title = __('(no title)', 'content-egg');
$out[] = [
'post_id' => $pid,
'post_title' => $title,
'post_edit_url' => admin_url('post.php?post=' . $pid . '&action=edit'),
'clicks' => (int) $r['clicks'], // range clicks
'prev_clicks' => (int) ($prevByPost[$pid] ?? 0), // previous equal-length range
];
}
return $out;
}
// ======================
// Zero-click diagnostics
// ======================
/**
* Products (links) with ZERO clicks in the selected window.
* Excludes trashed posts.
*
* @return array<int,array{link_id:int,post_id:int,module_id:string,unique_id:string,title:string,post_edit_url:string}>
*/
private function getNoClickLinks(string $date_from, string $date_to, string $module_id = '', int $limit = 25): array
{
global $wpdb;
$lc = $wpdb->prefix . 'cegg_link_clicks_daily';
$idx = $wpdb->prefix . 'cegg_link_index';
$post = $wpdb->posts;
if ($module_id !== '')
{
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT li.id AS link_id, li.post_id, li.module_id, li.unique_id, li.slug,
COALESCE(SUM(lc.clicks),0) AS clicks
FROM {$idx} li
INNER JOIN {$post} p ON p.ID = li.post_id AND p.post_status <> 'trash'
LEFT JOIN {$lc} lc
ON lc.link_id = li.id
AND lc.ymd BETWEEN %s AND %s
WHERE li.module_id = %s
GROUP BY li.id
HAVING clicks = 0
ORDER BY li.updated_at DESC
LIMIT %d",
$date_from,
$date_to,
$module_id,
$limit
),
ARRAY_A
);
}
else
{
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT li.id AS link_id, li.post_id, li.module_id, li.unique_id, li.slug,
COALESCE(SUM(lc.clicks),0) AS clicks
FROM {$idx} li
INNER JOIN {$post} p ON p.ID = li.post_id AND p.post_status <> 'trash'
LEFT JOIN {$lc} lc
ON lc.link_id = li.id
AND lc.ymd BETWEEN %s AND %s
GROUP BY li.id
HAVING clicks = 0
ORDER BY li.updated_at DESC
LIMIT %d",
$date_from,
$date_to,
$limit
),
ARRAY_A
);
}
$out = [];
foreach ((array)$rows as $r)
{
$pid = (int)$r['post_id'];
$mid = (string)$r['module_id'];
$uid = (string)$r['unique_id'];
$slug = (string)$r['slug'];
// Resolve product title from Content Egg meta; fallback to humanized slug
$title = '';
try
{
$product = \ContentEgg\application\components\ContentManager::getProductbyUniqueId($uid, $mid, $pid);
if (is_array($product) && !empty($product['title']))
{
$title = (string)$product['title'];
}
}
catch (\Throwable $e)
{
// ignore, fallback below
}
if ($title === '')
{
$title = ucwords(str_replace('-', ' ', $slug));
}
$out[] = [
'link_id' => (int)$r['link_id'],
'post_id' => $pid,
'module_id' => $mid,
'unique_id' => $uid,
'title' => $title,
'post_edit_url' => $pid > 0 ? \admin_url('post.php?post=' . $pid . '&action=edit#' . rawurlencode($mid . '-' . $uid)) : '',
];
}
return $out;
}
/**
* Posts where ALL links recorded ZERO clicks in the selected window.
* Excludes trashed posts.
*
* @return array<int,array{post_id:int,post_title:string,post_edit_url:string,links_count:int}>
*/
private function getNoClickPosts(string $date_from, string $date_to, string $module_id = '', int $limit = 25): array
{
global $wpdb;
$lc = $wpdb->prefix . 'cegg_link_clicks_daily';
$idx = $wpdb->prefix . 'cegg_link_index';
$post = $wpdb->posts;
if ($module_id !== '')
{
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT p.ID AS post_id,
p.post_title,
COUNT(DISTINCT li.id) AS links_count,
COALESCE(SUM(lc.clicks),0) AS total_clicks
FROM {$idx} li
INNER JOIN {$post} p ON p.ID = li.post_id
LEFT JOIN {$lc} lc
ON lc.link_id = li.id
AND lc.ymd BETWEEN %s AND %s
WHERE li.module_id = %s
AND p.post_status <> 'trash'
GROUP BY p.ID
HAVING total_clicks = 0
ORDER BY p.post_date DESC
LIMIT %d",
$date_from,
$date_to,
$module_id,
$limit
),
ARRAY_A
);
}
else
{
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT p.ID AS post_id,
p.post_title,
COUNT(DISTINCT li.id) AS links_count,
COALESCE(SUM(lc.clicks),0) AS total_clicks
FROM {$idx} li
INNER JOIN {$post} p ON p.ID = li.post_id
LEFT JOIN {$lc} lc
ON lc.link_id = li.id
AND lc.ymd BETWEEN %s AND %s
WHERE p.post_status <> 'trash'
GROUP BY p.ID
HAVING total_clicks = 0
ORDER BY p.post_date DESC
LIMIT %d",
$date_from,
$date_to,
$limit
),
ARRAY_A
);
}
$out = [];
foreach ((array)$rows as $r)
{
$pid = (int)$r['post_id'];
$out[] = [
'post_id' => $pid,
'post_title' => isset($r['post_title']) ? (string)$r['post_title'] : __('(no title)', 'content-egg'),
'post_edit_url' => $pid > 0 ? \admin_url('post.php?post=' . $pid . '&action=edit') : '',
'links_count' => isset($r['links_count']) ? (int)$r['links_count'] : 0,
];
}
return $out;
}
// ===============
// Misc utilities
// ===============
/** Shift a Y-m-d string by N days (can be negative). */
private function ymdShift(string $ymd, int $deltaDays): string
{
$ts = strtotime($ymd . ' 00:00:00');
$shift = $ts + ($deltaDays * DAY_IN_SECONDS);
return gmdate('Y-m-d', $shift);
}
/** Return the later (max) of two Y-m-d strings */
private function maxYmd(string $a, string $b): string
{
return ($a > $b) ? $a : $b;
}
/**
* Build Morris Donut options for module share in the selected period.
*/
private function buildModuleShareChartOptions(string $date_from, string $date_to, string $module_id = ''): array
{
global $wpdb;
$lc = $wpdb->prefix . 'cegg_link_clicks_daily';
$idx = $wpdb->prefix . 'cegg_link_index';
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT li.module_id, SUM(lc.clicks) AS clicks
FROM {$lc} lc
INNER JOIN {$idx} li ON li.id = lc.link_id
WHERE lc.ymd BETWEEN %s AND %s
GROUP BY li.module_id
ORDER BY clicks DESC",
$date_from,
$date_to
),
ARRAY_A
);
if (!$rows) return [];
// Map module_id -> readable name
$nameMap = ModuleManager::getInstance()->getAffiliteModulesList(true);
// Top N + others
$N = 7;
$top = array_slice($rows, 0, $N);
$rest = array_slice($rows, $N);
$data = [];
foreach ($top as $r)
{
$mid = (string) $r['module_id'];
$val = (int) $r['clicks'];
if ($val <= 0) continue;
$label = $nameMap[$mid] ?? $mid;
$data[] = ['label' => $label, 'value' => $val];
}
if ($rest)
{
$others = array_sum(array_map(static function ($r)
{
return (int)$r['clicks'];
}, $rest));
if ($others > 0)
{
$data[] = ['label' => __('Others', 'content-egg'), 'value' => $others];
}
}
if (!$data) return [];
return [
'chartType' => 'Donut',
'colors' => ['#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f', '#edc949', '#af7aa1', '#ff9da7', '#9c755f', '#bab0ab'],
'data' => $data, // [{label,value}, ...]
'_js' => [
'formatter' => 'function (y, data) {
var total = 0;
for (var i = 0; i < this.data.length; i++) total += this.data[i].value;
var pct = total ? Math.round((y / total) * 100) : 0;
return y + " (" + pct + "%)";
}'
],
];
}
// ==============
// Filters / UX
// ==============
/**
* Filters:
* - range: today|7d|30d|custom (default 30d)
* - from, to (YYYY-MM-DD) if custom
* - module_id (optional)
*/
private function readFilters(): array
{
$range = isset($_GET['range']) ? sanitize_text_field(\wp_unslash($_GET['range'])) : '30d';
$allowed = ['today', '7d', '30d', '90d', 'this_month', 'last_month', 'custom'];
if (!in_array($range, $allowed, true))
{
$range = '30d';
}
$module_id = '';
if (isset($_GET['module_id']))
{
$module_id = sanitize_text_field(\wp_unslash($_GET['module_id']));
}
[$date_from, $date_to, $label] = $this->computeDateRange($range);
if ($range === 'custom')
{
$from = isset($_GET['from']) ? sanitize_text_field(\wp_unslash($_GET['from'])) : '';
$to = isset($_GET['to']) ? sanitize_text_field(\wp_unslash($_GET['to'])) : '';
if ($this->isValidYmd($from) && $this->isValidYmd($to) && $from <= $to)
{
$date_from = $from;
$date_to = $to;
$label = $from . ' → ' . $to;
}
}
return [
'range' => $range,
'date_from' => $date_from,
'date_to' => $date_to,
'range_label' => $label,
'module_id' => $module_id,
];
}
/** Inclusive date range in site-local terms */
private function computeDateRange(string $range): array
{
$now_ts = \current_time('timestamp'); // local TZ
$tzshift = (int) get_option('gmt_offset') * HOUR_IN_SECONDS;
// Helpers in local (by shifting into “local” before gmdate)
$local_ts = $now_ts + $tzshift;
$todayYmd = gmdate('Y-m-d', $local_ts);
switch ($range)
{
case 'today':
return [$todayYmd, $todayYmd, __('Today', 'content-egg')];
case '7d':
$from = gmdate('Y-m-d', $local_ts - 6 * DAY_IN_SECONDS);
return [$from, $todayYmd, __('Last 7 days', 'content-egg')];
case '30d':
$from = gmdate('Y-m-d', $local_ts - 29 * DAY_IN_SECONDS);
return [$from, $todayYmd, __('Last 30 days', 'content-egg')];
case '90d':
$from = gmdate('Y-m-d', $now_ts - 89 * DAY_IN_SECONDS + $tzshift);
return [$from, $todayYmd, __('Last 90 days', 'content-egg')];
case 'this_month':
// Month-to-date: first day of current month through today
$from = gmdate('Y-m-01', $local_ts);
return [$from, $todayYmd, __('This month', 'content-egg')];
case 'last_month':
// Full previous month (1st through last day)
$y = (int) gmdate('Y', $local_ts);
$m = (int) gmdate('n', $local_ts); // 1–12
$py = ($m === 1) ? ($y - 1) : $y;
$pm = ($m === 1) ? 12 : ($m - 1);
$days = cal_days_in_month(CAL_GREGORIAN, $pm, $py);
$from = sprintf('%04d-%02d-01', $py, $pm);
$to = sprintf('%04d-%02d-%02d', $py, $pm, $days);
return [$from, $to, __('Last month', 'content-egg')];
case 'custom':
// Placeholder; caller may override if valid
$from = gmdate('Y-m-d', $local_ts - 29 * DAY_IN_SECONDS);
return [$from, $todayYmd, __('Custom range', 'content-egg')];
default:
// Fallback to 30d
$from = gmdate('Y-m-d', $local_ts - 29 * DAY_IN_SECONDS);
return [$from, $todayYmd, __('Last 30 days', 'content-egg')];
}
}
/** YYYY-MM-DD validator */
private function isValidYmd(string $v): bool
{
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $v))
{
return false;
}
[$y, $m, $d] = array_map('intval', explode('-', $v));
return checkdate($m, $d, $y);
}
/**
* Build tracking overview using ClickStatsHelper convenience methods.
*
* @return array{
* track_redirect_enabled:bool,
* track_direct_enabled:bool,
* mods_redirect:array<string,string>,
* mods_direct:array<string,string>,
* mods_none:array<string,string>
* }
*/
private function buildTrackingOverview(): array
{
// Global toggles
$gc = GeneralConfig::getInstance();
$trackRedirect = ($gc->option('clicks_track_redirect') === 'enabled');
$trackDirect = ($gc->option('clicks_track_direct') === 'enabled');
// All affiliate modules (id => name)
$allModules = ModuleManager::getInstance()->getAffiliteModulesList(true);
if (!is_array($allModules))
{
$allModules = [];
}
// Modules tracked by each mode
$mods_redirect = $trackRedirect ? ClickStatsHelper::modulesWithLocalRedirectEnabled() : [];
$mods_direct = $trackDirect ? ClickStatsHelper::modulesWithLocalRedirectDisabled() : [];
// Modules without tracking = all − (redirect ∪ direct)
$trackedIds = array_unique(array_merge(array_keys($mods_redirect), array_keys($mods_direct)));
$mods_none = $allModules;
foreach ($trackedIds as $mid)
{
unset($mods_none[$mid]);
}
return [
'track_redirect_enabled' => $trackRedirect,
'track_direct_enabled' => $trackDirect,
'mods_redirect' => $mods_redirect,
'mods_direct' => $mods_direct,
'mods_none' => $mods_none,
];
}
/** Render a small delta badge HTML for current vs previous period (with title showing previous value). */
public static function renderDeltaBadge(int $current, int $previous): string
{
$prevLabel = sprintf(
/* translators: %s is the previous period clicks number */
__('Previous: %s', 'content-egg'),
number_format_i18n($previous)
);
$titleAttr = ' title="' . esc_attr($prevLabel) . '" aria-label="' . esc_attr($prevLabel) . '"';
if ($previous <= 0)
{
if ($current <= 0)
{
return '<span class="small text-muted ms-2"' . $titleAttr . '><i class="bi bi-dash"></i> 0%</span>';
}
// New vs no previous data
return '<span class="small text-success ms-2"' . $titleAttr . '><i class="bi bi-arrow-up-right"></i> ' .
esc_html__('new', 'content-egg') . '</span>';
}
$diff = $current - $previous;
if ($diff === 0)
{
return '<span class="small text-muted ms-2"' . $titleAttr . '><i class="bi bi-dash"></i> 0%</span>';
}
$pct = (int) round(($diff / $previous) * 100);
$up = $pct > 0;
$cls = $up ? 'text-success' : 'text-danger';
$icon = $up ? 'bi-arrow-up-right' : 'bi-arrow-down-right';
$display = abs($pct) . '%'; // no +/– sign, direction is conveyed by color and icon
return '<span class="small ' . esc_attr($cls) . ' ms-2"' . $titleAttr . '><i class="bi ' . esc_attr($icon) .
'"></i> ' . esc_html($display) . '</span>';
}
}