File: /home/xedaptot/be.naniguide.com/app/Http/Controllers/Refactor/Ads/AdDashboardController.php
<?php
namespace App\Http\Controllers\Refactor\Ads;
use App\Http\Controllers\Controller;
use App\Model\AdActivityLog;
use App\Model\AdAudience;
use App\Model\AdCampaign;
use App\Model\AdMetric;
use App\Model\AdPixel;
use App\Model\AdPlatform;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class AdDashboardController extends Controller
{
/**
* Ads dashboard — unified analytics for the current customer.
*/
public function index(Request $request)
{
$customer = $request->user()->customer;
$local = $customer->local();
$platformCount = $local->adPlatforms()->active()->count();
$onboarding = $this->computeOnboarding($customer->id, $local, $platformCount);
if ($platformCount === 0) {
return view('refactor.pages.ads.dashboard.index', [
'hasPlatforms' => false,
'onboarding' => $onboarding,
]);
}
$period = $request->input('period', '30d');
$days = match ($period) {
'7d' => 7,
'30d' => 30,
'90d' => 90,
default => 30,
};
$startDate = now()->startOfDay()->subDays($days - 1);
$prevStart = (clone $startDate)->subDays($days);
$prevEnd = (clone $startDate)->subDay();
$campaignIds = $local->adCampaigns()->pluck('id');
// Current period totals
$current = AdMetric::whereIn('ad_campaign_id', $campaignIds)
->where('date', '>=', $startDate)
->selectRaw('COALESCE(SUM(impressions),0) as impressions, COALESCE(SUM(clicks),0) as clicks, COALESCE(SUM(spend),0) as spend, COALESCE(SUM(conversions),0) as conversions')
->first();
// Previous period totals — for delta computation
$previous = AdMetric::whereIn('ad_campaign_id', $campaignIds)
->whereBetween('date', [$prevStart, $prevEnd])
->selectRaw('COALESCE(SUM(impressions),0) as impressions, COALESCE(SUM(clicks),0) as clicks, COALESCE(SUM(spend),0) as spend, COALESCE(SUM(conversions),0) as conversions')
->first();
$ctr = $current->impressions > 0 ? round(($current->clicks / $current->impressions) * 100, 2) : 0;
$roas = $current->spend > 0 ? round(($current->conversions * 50) / $current->spend, 2) : 0;
$pctDelta = function ($a, $b) {
if ($b == 0) {
return $a > 0 ? 100 : 0;
}
return round((($a - $b) / $b) * 100, 1);
};
$deltas = [
'spend' => $pctDelta((float) $current->spend, (float) $previous->spend),
'impressions' => $pctDelta((int) $current->impressions, (int) $previous->impressions),
'clicks' => $pctDelta((int) $current->clicks, (int) $previous->clicks),
'conversions' => $pctDelta((int) $current->conversions, (int) $previous->conversions),
];
// Daily series for chart + sparklines — fill zeroes for missing days
$rawDaily = AdMetric::whereIn('ad_campaign_id', $campaignIds)
->where('date', '>=', $startDate)
->selectRaw('date, SUM(impressions) as impressions, SUM(clicks) as clicks, SUM(spend) as spend, SUM(conversions) as conversions')
->groupBy('date')
->orderBy('date')
->get()
->keyBy(fn ($row) => (string) $row->date->format('Y-m-d'));
$daily = [];
for ($i = 0; $i < $days; $i++) {
$d = (clone $startDate)->addDays($i)->format('Y-m-d');
$row = $rawDaily[$d] ?? null;
$daily[] = [
'date' => $d,
'impressions' => (int) ($row->impressions ?? 0),
'clicks' => (int) ($row->clicks ?? 0),
'spend' => (float) ($row->spend ?? 0),
'conversions' => (int) ($row->conversions ?? 0),
];
}
// Sparkline arrays for each metric
$sparklines = [
'spend' => array_map(fn ($r) => $r['spend'], $daily),
'impressions' => array_map(fn ($r) => $r['impressions'], $daily),
'clicks' => array_map(fn ($r) => $r['clicks'], $daily),
'conversions' => array_map(fn ($r) => $r['conversions'], $daily),
];
// Per-platform breakdown
$platformBreakdown = AdMetric::whereIn('ad_campaign_id', $campaignIds)
->where('date', '>=', $startDate)
->selectRaw('platform, SUM(spend) as spend, SUM(impressions) as impressions, SUM(clicks) as clicks, SUM(conversions) as conversions')
->groupBy('platform')
->get();
$totalSpendAll = max(0.01, (float) $current->spend);
$platformBreakdown = $platformBreakdown->map(function ($row) use ($totalSpendAll) {
return [
'platform' => $row->platform,
'label' => AdPlatform::PLATFORM_LABELS[$row->platform] ?? ucfirst((string) $row->platform),
'spend' => (float) $row->spend,
'impressions' => (int) $row->impressions,
'clicks' => (int) $row->clicks,
'conversions' => (int) $row->conversions,
'share' => round(((float) $row->spend / $totalSpendAll) * 100, 1),
];
})->sortByDesc('spend')->values();
// Top campaigns by spend in period
$topCampaignStats = AdMetric::whereIn('ad_campaign_id', $campaignIds)
->where('date', '>=', $startDate)
->selectRaw('ad_campaign_id, SUM(spend) as spend, SUM(impressions) as impressions, SUM(clicks) as clicks, SUM(conversions) as conversions')
->groupBy('ad_campaign_id')
->orderByDesc('spend')
->limit(5)
->get();
$topCampaignModels = AdCampaign::whereIn('id', $topCampaignStats->pluck('ad_campaign_id'))->get()->keyBy('id');
$topCampaigns = $topCampaignStats->map(function ($row) use ($topCampaignModels) {
$campaign = $topCampaignModels[$row->ad_campaign_id] ?? null;
if (!$campaign) return null;
$ctrLocal = $row->impressions > 0 ? round(($row->clicks / $row->impressions) * 100, 2) : 0;
return [
'model' => $campaign,
'spend' => (float) $row->spend,
'impressions' => (int) $row->impressions,
'clicks' => (int) $row->clicks,
'conversions' => (int) $row->conversions,
'ctr' => $ctrLocal,
];
})->filter()->values();
// Recent campaigns list (for the main table)
$campaigns = $local->adCampaigns()
->orderByDesc('updated_at')
->limit(10)
->get();
// Campaign status counts
$statusCounts = $local->adCampaigns()
->selectRaw('status, COUNT(*) as cnt')
->groupBy('status')
->pluck('cnt', 'status')
->toArray();
$statusSummary = [
'active' => (int) ($statusCounts[AdCampaign::STATUS_ACTIVE] ?? 0),
'draft' => (int) ($statusCounts[AdCampaign::STATUS_DRAFT] ?? 0),
'paused' => (int) ($statusCounts[AdCampaign::STATUS_PAUSED] ?? 0),
'completed' => (int) ($statusCounts[AdCampaign::STATUS_COMPLETED] ?? 0),
'error' => (int) ($statusCounts[AdCampaign::STATUS_ERROR] ?? 0),
];
// Recent activity — last 6 events
$recentActivity = AdActivityLog::ofCustomer($local)
->orderByDesc('created_at')
->limit(6)
->get();
// Alerts — platforms with unhealthy status or token expiry soon
$platforms = $local->adPlatforms()->active()->get();
$alerts = [];
foreach ($platforms as $p) {
if (!$p->isHealthy() && $p->health_status === 'error') {
$alerts[] = [
'severity' => 'error',
'title' => $p->name . ' — connection error',
'desc' => 'Reconnect this platform to resume publishing.',
'action_label' => 'Reconnect',
'action_url' => route('refactor.ads.platforms.index'),
];
} elseif ($p->health_status === 'degraded') {
$alerts[] = [
'severity' => 'warning',
'title' => $p->name . ' — degraded',
'desc' => 'Platform is responding slowly. We will retry automatically.',
'action_label' => 'View',
'action_url' => route('refactor.ads.platforms.index'),
];
}
}
if ($statusSummary['error'] > 0) {
$alerts[] = [
'severity' => 'error',
'title' => $statusSummary['error'] . ' campaign(s) in error state',
'desc' => 'Publishing failed. Review the publish logs for details.',
'action_label' => 'Review',
'action_url' => route('refactor.ads.campaigns.index'),
];
}
return view('refactor.pages.ads.dashboard.index', [
'hasPlatforms' => true,
'onboarding' => $onboarding,
'period' => $period,
'days' => $days,
'totals' => [
'impressions' => (int) $current->impressions,
'clicks' => (int) $current->clicks,
'spend' => (float) $current->spend,
'conversions' => (int) $current->conversions,
'ctr' => $ctr,
'roas' => $roas,
],
'deltas' => $deltas,
'daily' => $daily,
'sparklines' => $sparklines,
'platformBreakdown' => $platformBreakdown,
'topCampaigns' => $topCampaigns,
'campaigns' => $campaigns,
'statusSummary' => $statusSummary,
'recentActivity' => $recentActivity,
'alerts' => $alerts,
'platformCount' => $platformCount,
'campaignCount' => $local->adCampaigns()->count(),
'platforms' => $platforms,
]);
}
/**
* Compute the 4-step ads onboarding checklist state for the customer.
*
* Ordering reflects USE_CASES.md §1-5: connect platform → create audience →
* launch first campaign → review results. Each step unlocks the next — a
* step is "locked" until its prerequisite is done, "current" when it's the
* next actionable step, "done" when the detection query succeeds.
*/
protected function computeOnboarding(int $customerId, $local, int $platformCount): array
{
$hasPlatform = $platformCount > 0;
$hasAudience = AdAudience::where('customer_id', $customerId)->exists();
$hasPublishedCampaign = AdCampaign::where('customer_id', $customerId)
->whereIn('status', [AdCampaign::STATUS_ACTIVE, AdCampaign::STATUS_PAUSED, AdCampaign::STATUS_COMPLETED])
->exists();
$campaignIds = $local->adCampaigns()->pluck('id');
$hasMetrics = AdMetric::whereIn('ad_campaign_id', $campaignIds)->exists();
// Live stats for the left-rail step list — computed once, used for the
// second line of each row (see _onboarding.blade.php). Stays cheap:
// all queries are indexed + scoped to this customer.
$platformNames = $local->adPlatforms()->active()
->pluck('platform')
->map(fn ($p) => AdPlatform::PLATFORM_LABELS[$p] ?? ucfirst($p))
->take(3)
->implode(', ');
$audienceCount = AdAudience::where('customer_id', $customerId)->count();
$activeCampaignCount = AdCampaign::where('customer_id', $customerId)
->where('status', AdCampaign::STATUS_ACTIVE)
->count();
$completedCampaignCount = AdCampaign::where('customer_id', $customerId)
->where('status', AdCampaign::STATUS_COMPLETED)
->count();
$totalCampaignCount = AdCampaign::where('customer_id', $customerId)->count();
$metricTotals = AdMetric::whereIn('ad_campaign_id', $campaignIds)
->where('date', '>=', now()->subDays(30)->startOfDay())
->selectRaw('COALESCE(SUM(spend),0) as spend, COALESCE(SUM(clicks),0) as clicks, COALESCE(SUM(impressions),0) as impressions')
->first();
$ctr30d = ($metricTotals && $metricTotals->impressions > 0)
? round(($metricTotals->clicks / $metricTotals->impressions) * 100, 2)
: 0;
$platformStat = $hasPlatform
? trans('refactor/ads.onboarding.stat_platform_done', [
'count' => $platformCount,
'names' => $platformNames ?: trans('refactor/ads.onboarding.stat_platform_none'),
])
: trans('refactor/ads.onboarding.stat_platform_pending', ['time' => '2 min']);
if (!$hasPlatform) {
$audienceStat = trans('refactor/ads.onboarding.stat_audience_locked');
} elseif ($hasAudience) {
$audienceStat = trans('refactor/ads.onboarding.stat_audience_done', ['count' => $audienceCount]);
} else {
$audienceStat = trans('refactor/ads.onboarding.stat_audience_pending', ['time' => '3 min']);
}
if (!$hasAudience) {
$campaignStat = trans('refactor/ads.onboarding.stat_campaign_locked');
} elseif ($hasPublishedCampaign) {
$campaignStat = trans('refactor/ads.onboarding.stat_campaign_done', [
'total' => $totalCampaignCount,
'active' => $activeCampaignCount,
'completed' => $completedCampaignCount,
]);
} else {
$campaignStat = trans('refactor/ads.onboarding.stat_campaign_pending', ['time' => '10 min']);
}
if (!$hasPublishedCampaign) {
$resultsStat = trans('refactor/ads.onboarding.stat_results_locked');
} elseif ($hasMetrics) {
$resultsStat = trans('refactor/ads.onboarding.stat_results_done', [
'spend' => number_format((float) ($metricTotals->spend ?? 0), 2),
'clicks' => number_format((int) ($metricTotals->clicks ?? 0)),
'ctr' => $ctr30d,
]);
} else {
$resultsStat = trans('refactor/ads.onboarding.stat_results_pending', ['time' => '1 min']);
}
$steps = [
[
'key' => 'connect_platform',
'done' => $hasPlatform,
'locked' => false,
'time' => '2 min',
'stat' => $platformStat,
'cta_route' => 'refactor.ads.platforms.index',
],
[
'key' => 'create_audience',
'done' => $hasAudience,
'locked' => !$hasPlatform,
'time' => '3 min',
'stat' => $audienceStat,
'cta_route' => 'refactor.ads.audiences.index',
],
[
'key' => 'launch_campaign',
'done' => $hasPublishedCampaign,
'locked' => !$hasAudience,
'time' => '10 min',
'stat' => $campaignStat,
'cta_route' => 'refactor.ads.campaigns.create',
],
[
'key' => 'review_results',
'done' => $hasMetrics,
'locked' => !$hasPublishedCampaign,
'time' => '1 min',
'stat' => $resultsStat,
'cta_route' => 'refactor.ads.dashboard.index',
],
];
$total = count($steps);
$completed = count(array_filter($steps, fn ($s) => $s['done']));
$currentIndex = null;
foreach ($steps as $i => $s) {
if (!$s['done'] && !$s['locked']) {
$currentIndex = $i;
break;
}
}
if ($currentIndex === null) {
// All done or all locked — fall back to first incomplete, else last
foreach ($steps as $i => $s) {
if (!$s['done']) {
$currentIndex = $i;
break;
}
}
}
if ($currentIndex === null) {
$currentIndex = $total - 1;
}
return [
'steps' => $steps,
'completed' => $completed,
'total' => $total,
'percent' => (int) round(($completed / $total) * 100),
'current_index' => $currentIndex,
'all_done' => $completed === $total,
];
}
}