322 lines
14 KiB
PHP
322 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Studio;
|
|
|
|
use App\Models\CollectionComment;
|
|
use App\Models\NovaCardComment;
|
|
use App\Models\StoryComment;
|
|
use App\Models\User;
|
|
use App\Support\AvatarUrl;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
final class CreatorStudioOverviewService
|
|
{
|
|
public function __construct(
|
|
private readonly CreatorStudioContentService $content,
|
|
private readonly CreatorStudioAnalyticsService $analytics,
|
|
private readonly CreatorStudioScheduledService $scheduled,
|
|
private readonly CreatorStudioActivityService $activity,
|
|
private readonly CreatorStudioPreferenceService $preferences,
|
|
private readonly CreatorStudioChallengeService $challenges,
|
|
private readonly CreatorStudioGrowthService $growth,
|
|
) {
|
|
}
|
|
|
|
public function build(User $user): array
|
|
{
|
|
$analytics = $this->analytics->overview($user);
|
|
$moduleSummaries = $this->content->moduleSummaries($user);
|
|
$preferences = $this->preferences->forUser($user);
|
|
$challengeData = $this->challenges->build($user);
|
|
$growthData = $this->growth->build($user, $preferences['analytics_range_days']);
|
|
$featuredContent = $this->content->selectedItems($user, $preferences['featured_content']);
|
|
|
|
return [
|
|
'kpis' => [
|
|
'total_content' => $analytics['totals']['content_count'],
|
|
'views_30d' => $analytics['totals']['views'],
|
|
'appreciation_30d' => $analytics['totals']['appreciation'],
|
|
'shares_30d' => $analytics['totals']['shares'],
|
|
'comments_30d' => $analytics['totals']['comments'],
|
|
'followers' => $analytics['totals']['followers'],
|
|
],
|
|
'module_summaries' => $moduleSummaries,
|
|
'quick_create' => $this->content->quickCreate(),
|
|
'continue_working' => $this->content->continueWorking($user, $preferences['draft_behavior']),
|
|
'scheduled_items' => $this->scheduled->upcoming($user, 5),
|
|
'recent_activity' => $this->activity->recent($user, 6),
|
|
'top_performers' => $analytics['top_content'],
|
|
'recent_comments' => $this->recentComments($user),
|
|
'recent_followers' => $this->recentFollowers($user),
|
|
'draft_reminders' => $this->content->draftReminders($user),
|
|
'stale_drafts' => $this->content->staleDrafts($user),
|
|
'recent_publishes' => $this->content->recentPublished($user, 6),
|
|
'growth_hints' => $this->growthHints($user, $moduleSummaries),
|
|
'active_challenges' => [
|
|
'summary' => $challengeData['summary'],
|
|
'spotlight' => $challengeData['spotlight'],
|
|
'items' => collect($challengeData['active_challenges'] ?? [])->take(3)->values()->all(),
|
|
],
|
|
'creator_health' => [
|
|
'score' => (int) round(collect($growthData['checkpoints'] ?? [])->avg('score') ?? 0),
|
|
'summary' => $growthData['summary'],
|
|
'checkpoints' => collect($growthData['checkpoints'] ?? [])->take(3)->values()->all(),
|
|
],
|
|
'featured_status' => $this->featuredStatus($preferences, $featuredContent),
|
|
'workflow_focus' => $this->workflowFocus($user),
|
|
'command_center' => $this->commandCenter($user),
|
|
'insight_blocks' => $analytics['insight_blocks'] ?? [],
|
|
'preferences' => [
|
|
'widget_visibility' => $preferences['widget_visibility'],
|
|
'widget_order' => $preferences['widget_order'],
|
|
'card_density' => $preferences['card_density'],
|
|
],
|
|
];
|
|
}
|
|
|
|
private function featuredStatus(array $preferences, array $featuredContent): array
|
|
{
|
|
$modules = ['artworks', 'cards', 'collections', 'stories'];
|
|
$selectedModules = array_values(array_filter($modules, fn (string $module): bool => isset($featuredContent[$module]) && is_array($featuredContent[$module])));
|
|
$missingModules = array_values(array_diff($modules, $selectedModules));
|
|
|
|
return [
|
|
'selected_count' => count($selectedModules),
|
|
'target_count' => count($modules),
|
|
'featured_modules' => $preferences['featured_modules'],
|
|
'missing_modules' => $missingModules,
|
|
'items' => collect($featuredContent)
|
|
->filter(fn ($item): bool => is_array($item))
|
|
->values()
|
|
->take(4)
|
|
->all(),
|
|
];
|
|
}
|
|
|
|
private function workflowFocus(User $user): array
|
|
{
|
|
$continue = collect($this->content->continueWorking($user, 'resume-last', 6));
|
|
|
|
return [
|
|
'priority_drafts' => $continue
|
|
->filter(fn (array $item): bool => (bool) ($item['workflow']['is_stale_draft'] ?? false) || ! (bool) ($item['workflow']['readiness']['can_publish'] ?? false))
|
|
->take(3)
|
|
->values()
|
|
->all(),
|
|
'ready_to_schedule' => $continue
|
|
->filter(fn (array $item): bool => (bool) ($item['workflow']['readiness']['can_publish'] ?? false))
|
|
->take(3)
|
|
->values()
|
|
->all(),
|
|
];
|
|
}
|
|
|
|
private function commandCenter(User $user): array
|
|
{
|
|
$scheduled = collect($this->scheduled->upcoming($user, 16));
|
|
$inbox = collect($this->activity->recent($user, 16));
|
|
$todayStart = now()->startOfDay();
|
|
$todayEnd = now()->endOfDay();
|
|
|
|
return [
|
|
'publishing_today' => $scheduled->filter(function (array $item) use ($todayStart, $todayEnd): bool {
|
|
$timestamp = strtotime((string) ($item['scheduled_at'] ?? $item['published_at'] ?? '')) ?: 0;
|
|
|
|
return $timestamp >= $todayStart->getTimestamp() && $timestamp <= $todayEnd->getTimestamp();
|
|
})->values()->all(),
|
|
'attention_now' => $inbox->filter(fn (array $item): bool => in_array((string) ($item['type'] ?? ''), ['comment', 'notification'], true))->take(4)->values()->all(),
|
|
];
|
|
}
|
|
|
|
private function growthHints(User $user, array $moduleSummaries): array
|
|
{
|
|
$user->loadMissing('profile');
|
|
$summaries = collect($moduleSummaries)->keyBy('key');
|
|
$hints = [];
|
|
|
|
if (blank($user->profile?->bio) || blank($user->profile?->tagline)) {
|
|
$hints[] = [
|
|
'title' => 'Complete your creator profile',
|
|
'body' => 'Add a tagline and bio so your public presence matches the work you are publishing.',
|
|
'url' => route('studio.profile'),
|
|
'label' => 'Update profile',
|
|
];
|
|
}
|
|
|
|
if (((int) ($summaries->get('cards')['count'] ?? 0)) === 0) {
|
|
$hints[] = [
|
|
'title' => 'Publish your first card',
|
|
'body' => 'Cards now live inside Creator Studio, making short-form publishing a first-class workflow.',
|
|
'url' => route('studio.cards.create'),
|
|
'label' => 'Create card',
|
|
];
|
|
}
|
|
|
|
if (((int) ($summaries->get('collections')['count'] ?? 0)) === 0) {
|
|
$hints[] = [
|
|
'title' => 'Create a featured collection',
|
|
'body' => 'Curated collections give your profile a stronger editorial shape and a better publishing shelf.',
|
|
'url' => route('settings.collections.create'),
|
|
'label' => 'Start collection',
|
|
];
|
|
}
|
|
|
|
if (((int) ($summaries->get('artworks')['count'] ?? 0)) === 0) {
|
|
$hints[] = [
|
|
'title' => 'Upload your first artwork',
|
|
'body' => 'Seed the workspace with a first long-form piece so analytics, drafts, and collections have something to build on.',
|
|
'url' => '/upload',
|
|
'label' => 'Upload artwork',
|
|
];
|
|
}
|
|
|
|
return collect($hints)->take(3)->values()->all();
|
|
}
|
|
|
|
public function recentComments(User $user, int $limit = 12): array
|
|
{
|
|
$artworkComments = DB::table('artwork_comments')
|
|
->join('artworks', 'artworks.id', '=', 'artwork_comments.artwork_id')
|
|
->join('users', 'users.id', '=', 'artwork_comments.user_id')
|
|
->where('artworks.user_id', $user->id)
|
|
->whereNull('artwork_comments.deleted_at')
|
|
->select([
|
|
'artwork_comments.id',
|
|
'artwork_comments.content as body',
|
|
'artwork_comments.created_at',
|
|
'users.name as author_name',
|
|
'artworks.title as item_title',
|
|
'artworks.slug as item_slug',
|
|
'artworks.id as item_id',
|
|
])
|
|
->orderByDesc('artwork_comments.created_at')
|
|
->limit($limit)
|
|
->get()
|
|
->map(fn ($row): array => [
|
|
'id' => sprintf('artworks:%d', (int) $row->id),
|
|
'module' => 'artworks',
|
|
'module_label' => 'Artworks',
|
|
'author_name' => $row->author_name,
|
|
'item_title' => $row->item_title,
|
|
'body' => $row->body,
|
|
'created_at' => $this->normalizeDate($row->created_at),
|
|
'context_url' => route('art.show', ['id' => $row->item_id, 'slug' => $row->item_slug]),
|
|
]);
|
|
|
|
$cardComments = NovaCardComment::query()
|
|
->with(['user:id,name,username', 'card:id,title,slug'])
|
|
->whereNull('deleted_at')
|
|
->whereHas('card', fn ($query) => $query->where('user_id', $user->id))
|
|
->latest('created_at')
|
|
->limit($limit)
|
|
->get()
|
|
->map(fn (NovaCardComment $comment): array => [
|
|
'id' => sprintf('cards:%d', (int) $comment->id),
|
|
'module' => 'cards',
|
|
'module_label' => 'Cards',
|
|
'author_name' => $comment->user?->name ?: $comment->user?->username ?: 'Creator',
|
|
'item_title' => $comment->card?->title,
|
|
'body' => $comment->body,
|
|
'created_at' => $comment->created_at?->toIso8601String(),
|
|
'context_url' => $comment->card ? route('studio.cards.analytics', ['id' => $comment->card->id]) : route('studio.cards.index'),
|
|
]);
|
|
|
|
$collectionComments = CollectionComment::query()
|
|
->with(['user:id,name,username', 'collection:id,title'])
|
|
->whereNull('deleted_at')
|
|
->whereHas('collection', fn ($query) => $query->where('user_id', $user->id))
|
|
->latest('created_at')
|
|
->limit($limit)
|
|
->get()
|
|
->map(fn (CollectionComment $comment): array => [
|
|
'id' => sprintf('collections:%d', (int) $comment->id),
|
|
'module' => 'collections',
|
|
'module_label' => 'Collections',
|
|
'author_name' => $comment->user?->name ?: $comment->user?->username ?: 'Creator',
|
|
'item_title' => $comment->collection?->title,
|
|
'body' => $comment->body,
|
|
'created_at' => $comment->created_at?->toIso8601String(),
|
|
'context_url' => $comment->collection ? route('settings.collections.show', ['collection' => $comment->collection->id]) : route('studio.collections'),
|
|
]);
|
|
|
|
$storyComments = StoryComment::query()
|
|
->with(['user:id,name,username', 'story:id,title'])
|
|
->whereNull('deleted_at')
|
|
->where('is_approved', true)
|
|
->whereHas('story', fn ($query) => $query->where('creator_id', $user->id))
|
|
->latest('created_at')
|
|
->limit($limit)
|
|
->get()
|
|
->map(fn (StoryComment $comment): array => [
|
|
'id' => sprintf('stories:%d', (int) $comment->id),
|
|
'module' => 'stories',
|
|
'module_label' => 'Stories',
|
|
'author_name' => $comment->user?->name ?: $comment->user?->username ?: 'Creator',
|
|
'item_title' => $comment->story?->title,
|
|
'body' => $comment->content,
|
|
'created_at' => $comment->created_at?->toIso8601String(),
|
|
'context_url' => $comment->story ? route('creator.stories.analytics', ['story' => $comment->story->id]) : route('studio.stories'),
|
|
]);
|
|
|
|
return $artworkComments
|
|
->concat($cardComments)
|
|
->concat($collectionComments)
|
|
->concat($storyComments)
|
|
->sortByDesc(fn (array $comment): int => $this->timestamp($comment['created_at'] ?? null))
|
|
->take($limit)
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
public function recentFollowers(User $user, int $limit = 8): array
|
|
{
|
|
return DB::table('user_followers as uf')
|
|
->join('users as follower', 'follower.id', '=', 'uf.follower_id')
|
|
->leftJoin('user_profiles as profile', 'profile.user_id', '=', 'follower.id')
|
|
->where('uf.user_id', $user->id)
|
|
->whereNull('follower.deleted_at')
|
|
->orderByDesc('uf.created_at')
|
|
->limit($limit)
|
|
->get([
|
|
'follower.id',
|
|
'follower.username',
|
|
'follower.name',
|
|
'profile.avatar_hash',
|
|
'uf.created_at',
|
|
])
|
|
->map(fn ($row): array => [
|
|
'id' => (int) $row->id,
|
|
'name' => $row->name ?: '@' . $row->username,
|
|
'username' => $row->username,
|
|
'profile_url' => '/@' . strtolower((string) $row->username),
|
|
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64),
|
|
'created_at' => $this->normalizeDate($row->created_at),
|
|
])
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function normalizeDate(mixed $value): ?string
|
|
{
|
|
if ($value instanceof \DateTimeInterface) {
|
|
return $value->format(DATE_ATOM);
|
|
}
|
|
|
|
if (is_string($value) && $value !== '') {
|
|
return $value;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function timestamp(mixed $value): int
|
|
{
|
|
if (! is_string($value) || $value === '') {
|
|
return 0;
|
|
}
|
|
|
|
return strtotime($value) ?: 0;
|
|
}
|
|
} |