Implement academy analytics, billing, and web stories updates
This commit is contained in:
@@ -0,0 +1,470 @@
|
||||
<?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'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user