Files
SkinbaseNova/app/Http/Controllers/Settings/AcademyAdminAnalyticsController.php

471 lines
23 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Models\AcademyContentMetricDaily;
use App\Models\AcademySearchLog;
use App\Services\Academy\AcademyAnalyticsContentResolver;
use App\Services\Academy\AcademyContentIntelligenceService;
use App\Services\Academy\AcademyPopularityService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyAdminAnalyticsController extends Controller
{
public function __construct(
private readonly AcademyPopularityService $popularity,
private readonly AcademyAnalyticsContentResolver $resolver,
private readonly AcademyContentIntelligenceService $intelligence,
) {}
public function overview(Request $request): Response
{
[$from, $to, $range] = $this->resolveDateRange($request);
$summary = $this->metricsQuery($from, $to)
->selectRaw('sum(views) as views, sum(unique_visitors) as unique_visitors, sum(user_views) as user_views, sum(guest_views) as guest_views, sum(subscriber_views) as subscriber_views, sum(prompt_copies) as prompt_copies, sum(likes) as likes, sum(saves) as saves, sum(completions) as completions, sum(starts) as starts, sum(upgrade_clicks) as upgrade_clicks')
->first();
return Inertia::render('Admin/Academy/AnalyticsOverview', [
'nav' => $this->nav(),
'range' => $this->rangePayload($range, $from, $to),
'stats' => [
'views' => (int) ($summary?->views ?? 0),
'uniqueVisitors' => (int) ($summary?->unique_visitors ?? 0),
'userViews' => (int) ($summary?->user_views ?? 0),
'guestViews' => (int) ($summary?->guest_views ?? 0),
'subscriberViews' => (int) ($summary?->subscriber_views ?? 0),
'promptCopies' => (int) ($summary?->prompt_copies ?? 0),
'likes' => (int) ($summary?->likes ?? 0),
'saves' => (int) ($summary?->saves ?? 0),
'lessonCompletions' => (int) ($summary?->completions ?? 0),
'courseStarts' => (int) ($summary?->starts ?? 0),
'upgradeClicks' => (int) ($summary?->upgrade_clicks ?? 0),
],
'topContent' => $this->serializeContentRows($this->popularity->topContent($from, $to, 8)),
'topWeek' => $this->serializeContentRows($this->popularity->topContent(now()->subDays(6)->startOfDay(), now()->endOfDay(), 8)),
]);
}
public function content(Request $request): Response
{
return $this->renderContentPage($request, null, 'Content performance', 'Cross-module performance across prompts, lessons, courses, packs, and challenges.');
}
public function prompts(Request $request): Response
{
return $this->renderContentPage($request, AcademyAnalyticsContentType::PROMPT, 'Prompt analytics', 'Copy-heavy prompt performance, save rates, and upgrade interest.');
}
public function lessons(Request $request): Response
{
return $this->renderContentPage($request, AcademyAnalyticsContentType::LESSON, 'Lesson analytics', 'Lesson engagement, starts, completions, and drop-off signals.');
}
public function courses(Request $request): Response
{
return $this->renderContentPage($request, AcademyAnalyticsContentType::COURSE, 'Course analytics', 'Course views, starts, completion progress, and upgrade intent.');
}
public function search(Request $request): Response
{
[$from, $to, $range] = $this->resolveDateRange($request);
$searchQuery = AcademySearchLog::query()->whereBetween('created_at', [$from, $to]);
$searchLogs = (clone $searchQuery)->latest('created_at')->limit(500)->get();
return Inertia::render('Admin/Academy/AnalyticsSearch', [
'nav' => $this->nav(),
'range' => $this->rangePayload($range, $from, $to),
'summary' => [
'searches' => (int) (clone $searchQuery)->count(),
'zeroResultSearches' => (int) (clone $searchQuery)->where('results_count', 0)->count(),
'loggedInSearches' => (int) (clone $searchQuery)->where('is_logged_in', true)->count(),
'subscriberSearches' => (int) (clone $searchQuery)->where('is_subscriber', true)->count(),
'searchesWithClicks' => (int) (clone $searchQuery)->whereNotNull('clicked_content_id')->count(),
],
'topSearches' => (clone $searchQuery)
->selectRaw('normalized_query, max(query) as query, count(*) as searches, sum(results_count = 0) as zero_result_hits, avg(results_count) as avg_results, sum(case when clicked_content_id is not null then 1 else 0 end) as clicks')
->groupBy('normalized_query')
->orderByDesc('searches')
->limit(20)
->get()
->map(fn ($row): array => [
'query' => (string) ($row->query ?: $row->normalized_query),
'normalized_query' => (string) $row->normalized_query,
'searches' => (int) $row->searches,
'zero_result_hits' => (int) $row->zero_result_hits,
'avg_results' => round((float) $row->avg_results, 1),
'clicks' => (int) ($row->clicks ?? 0),
'click_through_rate' => (int) $row->searches > 0 ? round((((int) ($row->clicks ?? 0)) / (int) $row->searches) * 100, 1) : 0,
])
->all(),
'zeroResults' => (clone $searchQuery)
->selectRaw('normalized_query, max(query) as query, count(*) as searches')
->where('results_count', 0)
->groupBy('normalized_query')
->orderByDesc('searches')
->limit(20)
->get()
->map(fn ($row): array => [
'query' => (string) ($row->query ?: $row->normalized_query),
'searches' => (int) $row->searches,
])
->all(),
'lowClickThroughSearches' => (clone $searchQuery)
->selectRaw('normalized_query, max(query) as query, count(*) as searches, sum(case when clicked_content_id is not null then 1 else 0 end) as clicks, avg(results_count) as avg_results')
->groupBy('normalized_query')
->havingRaw('count(*) >= 2')
->orderByRaw('case when count(*) = 0 then 1 else (sum(case when clicked_content_id is not null then 1 else 0 end) * 1.0 / count(*)) end asc')
->limit(20)
->get()
->map(fn ($row): array => [
'query' => (string) ($row->query ?: $row->normalized_query),
'searches' => (int) $row->searches,
'clicks' => (int) ($row->clicks ?? 0),
'avg_results' => round((float) $row->avg_results, 1),
'click_through_rate' => (int) $row->searches > 0 ? round((((int) ($row->clicks ?? 0)) / (int) $row->searches) * 100, 1) : 0,
])
->all(),
'highestClickThroughSearches' => (clone $searchQuery)
->selectRaw('normalized_query, max(query) as query, count(*) as searches, sum(case when clicked_content_id is not null then 1 else 0 end) as clicks, avg(results_count) as avg_results')
->groupBy('normalized_query')
->havingRaw('count(*) >= 2')
->orderByRaw('(sum(case when clicked_content_id is not null then 1 else 0 end) * 1.0 / count(*)) desc')
->limit(20)
->get()
->map(fn ($row): array => [
'query' => (string) ($row->query ?: $row->normalized_query),
'searches' => (int) $row->searches,
'clicks' => (int) ($row->clicks ?? 0),
'avg_results' => round((float) $row->avg_results, 1),
'click_through_rate' => (int) $row->searches > 0 ? round((((int) ($row->clicks ?? 0)) / (int) $row->searches) * 100, 1) : 0,
])
->all(),
'searchesWithResultsNoClicks' => (clone $searchQuery)
->selectRaw('normalized_query, max(query) as query, count(*) as searches, avg(results_count) as avg_results')
->where('results_count', '>', 0)
->whereNull('clicked_content_id')
->groupBy('normalized_query')
->orderByDesc('searches')
->limit(20)
->get()
->map(fn ($row): array => [
'query' => (string) ($row->query ?: $row->normalized_query),
'searches' => (int) $row->searches,
'avg_results' => round((float) $row->avg_results, 1),
'clicks' => 0,
'click_through_rate' => 0,
])
->all(),
'topClickedResults' => (clone $searchQuery)
->selectRaw('clicked_content_type, clicked_content_id, count(*) as clicks')
->whereNotNull('clicked_content_type')
->whereNotNull('clicked_content_id')
->groupBy('clicked_content_type', 'clicked_content_id')
->orderByDesc('clicks')
->limit(20)
->get()
->map(fn ($row): array => [
'title' => $this->resolver->title((string) $row->clicked_content_type, (int) $row->clicked_content_id),
'content_type' => (string) $row->clicked_content_type,
'content_id' => (int) $row->clicked_content_id,
'clicks' => (int) $row->clicks,
])
->all(),
'filterUsage' => $this->summarizeSearchFilters($searchLogs),
'recentSearches' => (clone $searchQuery)
->latest('created_at')
->limit(25)
->get()
->map(fn (AcademySearchLog $log): array => [
'query' => (string) $log->query,
'results_count' => (int) $log->results_count,
'logged_in' => (bool) $log->is_logged_in,
'subscriber' => (bool) $log->is_subscriber,
'clicked_content_type' => $log->clicked_content_type,
'has_click' => $log->clicked_content_id !== null,
'created_at' => $log->created_at?->toISOString(),
])
->all(),
]);
}
public function intelligence(Request $request): Response
{
[$from, $to, $range] = $this->resolveDateRange($request, '30d');
$filters = [
'from' => $from,
'to' => $to,
'limit' => 25,
];
return Inertia::render('Admin/Academy/AnalyticsIntelligence', [
'nav' => $this->nav(),
'range' => $this->rangePayload($range, $from, $to),
'contentOpportunities' => $this->intelligence->getContentOpportunities($filters),
'searchGaps' => $this->intelligence->getSearchGaps($filters),
'promptInsights' => $this->intelligence->getPromptInsights($filters),
'lessonDropoffs' => $this->intelligence->getLessonDropoffs($filters),
'courseHealth' => $this->intelligence->getCourseHealth($filters),
'premiumInterest' => $this->intelligence->getPremiumInterest($filters),
'editorialRecommendations' => $this->intelligence->getEditorialRecommendations($filters),
]);
}
/**
* @param Collection<int, AcademySearchLog> $logs
* @return list<array<string, int|string>>
*/
private function summarizeSearchFilters(Collection $logs): array
{
$counts = [];
foreach ($logs as $log) {
$filters = is_array($log->filters) ? $log->filters : [];
foreach ($filters as $key => $value) {
if ($value === null || $value === '' || $key === 'q') {
continue;
}
$values = is_array($value) ? $value : [$value];
foreach ($values as $rawValue) {
$label = trim((string) $rawValue);
if ($label === '') {
continue;
}
$bucket = sprintf('%s:%s', $key, $label);
$counts[$bucket] = [
'filter' => (string) $key,
'value' => $label,
'uses' => (int) (($counts[$bucket]['uses'] ?? 0) + 1),
];
}
}
}
usort($counts, static fn (array $left, array $right): int => $right['uses'] <=> $left['uses']);
return array_slice(array_values($counts), 0, 20);
}
public function funnel(Request $request): Response
{
[$from, $to, $range] = $this->resolveDateRange($request);
$summary = $this->metricsQuery($from, $to)
->selectRaw('sum(unique_visitors) as unique_visitors, sum(premium_preview_views) as premium_preview_views, sum(upgrade_clicks) as upgrade_clicks, sum(starts) as starts, sum(completions) as completions')
->first();
$bestConverters = $this->metricsQuery($from, $to)
->selectRaw('content_type, content_id, sum(unique_visitors) as unique_visitors, sum(premium_preview_views) as premium_preview_views, sum(upgrade_clicks) as upgrade_clicks, sum(conversion_score) as conversion_score')
->groupBy('content_type', 'content_id')
->havingRaw('sum(upgrade_clicks) > 0')
->orderByDesc('conversion_score')
->limit(12)
->get();
return Inertia::render('Admin/Academy/AnalyticsFunnel', [
'nav' => $this->nav(),
'range' => $this->rangePayload($range, $from, $to),
'summary' => [
'academyVisitors' => (int) ($summary?->unique_visitors ?? 0),
'premiumPreviewViews' => (int) ($summary?->premium_preview_views ?? 0),
'upgradeClicks' => (int) ($summary?->upgrade_clicks ?? 0),
'starts' => (int) ($summary?->starts ?? 0),
'completions' => (int) ($summary?->completions ?? 0),
'checkoutStarts' => 0,
'subscriptions' => 0,
],
'bestConverters' => $this->serializeContentRows($bestConverters, includeConversion: true),
]);
}
private function renderContentPage(Request $request, ?string $forcedContentType, string $title, string $subtitle): Response
{
[$from, $to, $range] = $this->resolveDateRange($request);
$sort = (string) $request->query('sort', 'popularity_score');
$direction = strtolower((string) $request->query('direction', 'desc')) === 'asc' ? 'asc' : 'desc';
$access = trim((string) $request->query('access', ''));
$contentType = $forcedContentType ?: (trim((string) $request->query('content_type', '')) ?: null);
$query = $this->metricsQuery($from, $to)
->selectRaw('content_type, content_id, sum(views) as views, sum(unique_visitors) as unique_visitors, sum(engaged_views) as engaged_views, sum(likes) as likes, sum(saves) as saves, sum(prompt_copies) as prompt_copies, sum(starts) as starts, sum(completions) as completions, sum(upgrade_clicks) as upgrade_clicks, sum(popularity_score) as popularity_score, sum(conversion_score) as conversion_score')
->groupBy('content_type', 'content_id');
if ($contentType !== null) {
$query->where('content_type', $contentType);
}
$rows = $query->get();
$serializedRows = $this->serializeContentRows($rows, includeConversion: true)
->filter(function (array $row) use ($access): bool {
if ($access === '') {
return true;
}
return strtolower((string) ($row['access_level'] ?? '')) === strtolower($access);
})
->sortBy($sort, SORT_REGULAR, $direction === 'desc')
->values()
->all();
return Inertia::render('Admin/Academy/AnalyticsContent', [
'nav' => $this->nav(),
'range' => $this->rangePayload($range, $from, $to),
'title' => $title,
'subtitle' => $subtitle,
'filters' => [
'sort' => $sort,
'direction' => $direction,
'access' => $access,
'content_type' => $contentType,
],
'rows' => $serializedRows,
'contentTypeOptions' => [
['value' => '', 'label' => 'All content'],
['value' => AcademyAnalyticsContentType::PROMPT, 'label' => 'Prompts'],
['value' => AcademyAnalyticsContentType::LESSON, 'label' => 'Lessons'],
['value' => AcademyAnalyticsContentType::COURSE, 'label' => 'Courses'],
['value' => AcademyAnalyticsContentType::PROMPT_PACK, 'label' => 'Prompt packs'],
['value' => AcademyAnalyticsContentType::CHALLENGE, 'label' => 'Challenges'],
],
'sortOptions' => [
['value' => 'views', 'label' => 'Views'],
['value' => 'unique_visitors', 'label' => 'Unique visitors'],
['value' => 'likes', 'label' => 'Likes'],
['value' => 'saves', 'label' => 'Saves'],
['value' => 'prompt_copies', 'label' => 'Copies'],
['value' => 'completions', 'label' => 'Completions'],
['value' => 'upgrade_clicks', 'label' => 'Upgrade clicks'],
['value' => 'popularity_score', 'label' => 'Popularity score'],
['value' => 'conversion_score', 'label' => 'Conversion score'],
],
]);
}
private function metricsQuery(Carbon $from, Carbon $to)
{
return AcademyContentMetricDaily::query()
->whereBetween('date', [$from->toDateString(), $to->toDateString()]);
}
/**
* @param Collection<int, mixed> $rows
* @return Collection<int, array<string, mixed>>
*/
private function serializeContentRows(Collection $rows, bool $includeConversion = false): Collection
{
return $rows->map(function ($row) use ($includeConversion): array {
$contentType = (string) $row->content_type;
$contentId = $row->content_id ? (int) $row->content_id : null;
$title = $this->resolver->title($contentType, $contentId);
$accessLevel = $this->resolver->accessLevel($contentType, $contentId);
$uniqueVisitors = max(0, (int) ($row->unique_visitors ?? 0));
$promptCopies = max(0, (int) ($row->prompt_copies ?? 0));
$likes = max(0, (int) ($row->likes ?? 0));
$saves = max(0, (int) ($row->saves ?? 0));
$starts = max(0, (int) ($row->starts ?? 0));
$completions = max(0, (int) ($row->completions ?? 0));
$premiumPreviewViews = max(0, (int) ($row->premium_preview_views ?? 0));
$upgradeClicks = max(0, (int) ($row->upgrade_clicks ?? 0));
return [
'content_type' => $contentType,
'content_type_label' => (string) Str::of(str_replace('academy_', '', $contentType))->replace('_', ' ')->headline(),
'content_id' => $contentId,
'title' => $title,
'access_level' => $accessLevel,
'views' => (int) ($row->views ?? 0),
'unique_visitors' => $uniqueVisitors,
'engaged_views' => (int) ($row->engaged_views ?? 0),
'likes' => $likes,
'saves' => $saves,
'prompt_copies' => $promptCopies,
'starts' => $starts,
'completions' => $completions,
'upgrade_clicks' => $upgradeClicks,
'popularity_score' => round((float) ($row->popularity_score ?? 0), 2),
'conversion_score' => round((float) ($row->conversion_score ?? 0), 2),
'copy_rate' => $uniqueVisitors > 0 ? round(($promptCopies / $uniqueVisitors) * 100, 1) : 0,
'save_rate' => $uniqueVisitors > 0 ? round(($saves / $uniqueVisitors) * 100, 1) : 0,
'like_rate' => $uniqueVisitors > 0 ? round(($likes / $uniqueVisitors) * 100, 1) : 0,
'completion_rate' => $starts > 0 ? round(($completions / $starts) * 100, 1) : 0,
'upgrade_rate' => max(1, $premiumPreviewViews) > 0 ? round(($upgradeClicks / max(1, $premiumPreviewViews)) * 100, 1) : 0,
'trend' => ((float) ($row->popularity_score ?? 0)) >= 100 ? 'High momentum' : (((float) ($row->popularity_score ?? 0)) >= 25 ? 'Building' : 'Early'),
'include_conversion' => $includeConversion,
];
});
}
/**
* @return array{0: Carbon, 1: Carbon, 2: string}
*/
private function resolveDateRange(Request $request, string $defaultRange = '7d'): array
{
$range = trim((string) $request->query('range', $defaultRange));
return match ($range) {
'today' => [now()->startOfDay(), now()->endOfDay(), 'today'],
'yesterday' => [now()->subDay()->startOfDay(), now()->subDay()->endOfDay(), 'yesterday'],
'30d' => [now()->subDays(29)->startOfDay(), now()->endOfDay(), '30d'],
'90d' => [now()->subDays(89)->startOfDay(), now()->endOfDay(), '90d'],
'custom' => [
Carbon::parse((string) $request->query('from', now()->subDays(6)->toDateString()))->startOfDay(),
Carbon::parse((string) $request->query('to', now()->toDateString()))->endOfDay(),
'custom',
],
default => [now()->subDays(6)->startOfDay(), now()->endOfDay(), '7d'],
};
}
/**
* @return list<array<string, string|bool>>
*/
private function nav(): array
{
return [
['label' => 'Overview', 'href' => route('admin.academy.analytics.overview')],
['label' => 'Intelligence', 'href' => route('admin.academy.analytics.intelligence')],
['label' => 'Content', 'href' => route('admin.academy.analytics.content')],
['label' => 'Prompts', 'href' => route('admin.academy.analytics.prompts')],
['label' => 'Lessons', 'href' => route('admin.academy.analytics.lessons')],
['label' => 'Courses', 'href' => route('admin.academy.analytics.courses')],
['label' => 'Search', 'href' => route('admin.academy.analytics.search')],
['label' => 'Funnel', 'href' => route('admin.academy.analytics.funnel')],
];
}
/**
* @return array<string, mixed>
*/
private function rangePayload(string $activeRange, Carbon $from, Carbon $to): array
{
return [
'active' => $activeRange,
'from' => $from->toDateString(),
'to' => $to->toDateString(),
'options' => [
['value' => 'today', 'label' => 'Today'],
['value' => 'yesterday', 'label' => 'Yesterday'],
['value' => '7d', 'label' => 'Last 7 days'],
['value' => '30d', 'label' => 'Last 30 days'],
['value' => '90d', 'label' => 'Last 90 days'],
['value' => 'custom', 'label' => 'Custom range'],
],
];
}
}