Implement academy analytics, billing, and web stories updates

This commit is contained in:
2026-05-26 07:27:29 +02:00
parent 456c3d6bb0
commit 0b33a1b074
177 changed files with 27360 additions and 2685 deletions

View File

@@ -0,0 +1,296 @@
<?php
declare(strict_types=1);
namespace App\Services\WebStories;
use App\Models\Artwork;
use App\Models\User;
use App\Models\World;
use App\Models\WorldSubmission;
use App\Models\WorldWebStory;
use App\Models\WorldWebStoryPage;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
final class WorldWebStoryGenerator
{
public function __construct(
private readonly WorldWebStoryAssetService $assets,
private readonly WorldWebStoryValidationService $validation,
) {
}
/**
* @return array{story: WorldWebStory, created: bool, validation: array{valid: bool, errors: list<string>, warnings: list<string>, page_count: int}}
*/
public function generateFromWorld(World $world, ?User $actor = null, int $pages = 7, bool $force = false, bool $publish = false, bool $dryRun = false): array
{
$pageCount = max(5, min(10, $pages));
$existing = WorldWebStory::query()->where('world_id', $world->id)->orderByDesc('id')->first();
if ($existing && ! $force && ! $dryRun) {
throw ValidationException::withMessages([
'world' => ['A web story already exists for this world. Use --force to rebuild it.'],
]);
}
$selectedArtworks = $this->candidateArtworks($world)->take(max(3, $pageCount - 3))->values();
$storyAttributes = [
'world_id' => $world->id,
'slug' => $existing?->slug ?: $this->uniqueSlug($world->slug, $existing?->id),
'title' => $existing?->title ?: (string) $world->title,
'subtitle' => $world->tagline,
'excerpt' => $world->summary ?: $world->tagline,
'description' => $world->description ?: $world->summary,
'seo_title' => trim((string) ($world->seo_title ?: ($world->title . ' Skinbase Web Story'))),
'seo_description' => trim((string) ($world->seo_description ?: $world->summary ?: $world->description ?: '')),
'status' => WorldWebStory::STATUS_DRAFT,
'active' => true,
'noindex' => false,
'featured' => false,
'updated_by' => $actor?->id,
];
if (! $existing) {
$storyAttributes['created_by'] = $actor?->id;
}
$pagePayloads = $this->buildPagePayloads($world, $selectedArtworks, $pageCount);
if ($dryRun) {
$story = $existing ?? new WorldWebStory($storyAttributes);
$story->fill($storyAttributes);
$story->setRelation('orderedPages', collect($pagePayloads)->map(fn (array $page): WorldWebStoryPage => new WorldWebStoryPage($page)));
$this->assets->buildAssets($story, force: $force, dryRun: true);
$validation = $this->validation->validate($story);
return [
'story' => $story,
'created' => ! $existing,
'validation' => $validation,
];
}
$story = DB::transaction(function () use ($existing, $storyAttributes, $pagePayloads): WorldWebStory {
$story = $existing ?? new WorldWebStory();
$story->fill($storyAttributes);
$story->save();
$story->pages()->delete();
foreach ($pagePayloads as $pagePayload) {
$story->pages()->create($pagePayload);
}
return $story->fresh(['orderedPages', 'world']);
});
$this->assets->buildAssets($story, force: $force);
$story->refresh()->load('orderedPages', 'world');
if ($publish) {
$this->validation->assertPublishable($story);
$story->forceFill([
'status' => WorldWebStory::STATUS_PUBLISHED,
'published_at' => now(),
])->save();
}
return [
'story' => $story->fresh(['orderedPages', 'world']),
'created' => ! $existing,
'validation' => $this->validation->validate($story),
];
}
/**
* @return Collection<int, Artwork>
*/
private function candidateArtworks(World $world): Collection
{
$relationIds = $world->worldRelations()
->where('related_type', 'artwork')
->orderByDesc('is_featured')
->orderBy('sort_order')
->pluck('related_id')
->map(fn ($id): int => (int) $id)
->filter()
->values();
$artworks = collect();
if ($relationIds->isNotEmpty()) {
$artworks = Artwork::query()
->whereIn('id', $relationIds)
->get()
->sortBy(fn (Artwork $artwork): int => $relationIds->search((int) $artwork->id))
->values();
}
if ($artworks->count() < 3) {
$submissionArtworks = WorldSubmission::query()
->with('artwork.user')
->where('world_id', $world->id)
->where('status', WorldSubmission::STATUS_LIVE)
->orderByDesc('is_featured')
->orderByDesc('featured_at')
->orderByDesc('id')
->get()
->pluck('artwork')
->filter(fn ($artwork): bool => $artwork instanceof Artwork);
$artworks = $artworks->concat($submissionArtworks)->unique(fn (Artwork $artwork): int => (int) $artwork->id)->values();
}
return $artworks;
}
/**
* @param Collection<int, Artwork> $artworks
* @return list<array<string, mixed>>
*/
private function buildPagePayloads(World $world, Collection $artworks, int $pageCount): array
{
$primaryArtwork = $artworks->get(0);
$secondaryArtwork = $artworks->get(1) ?: $primaryArtwork;
$tertiaryArtwork = $artworks->get(2) ?: $secondaryArtwork;
$pages = [
[
'position' => 1,
'layout' => WorldWebStoryPage::LAYOUT_COVER,
'background_type' => WorldWebStoryPage::BACKGROUND_IMAGE,
'headline' => (string) $world->title,
'body' => Str::limit((string) ($world->tagline ?: $world->summary ?: 'A cinematic Skinbase World.'), 160, ''),
'caption' => 'Skinbase World',
'alt_text' => (string) $world->title,
'text_position' => 'bottom',
'overlay_strength' => 45,
'animation' => 'fade-in',
'active' => true,
],
[
'position' => 2,
'layout' => WorldWebStoryPage::LAYOUT_MOOD,
'background_type' => WorldWebStoryPage::BACKGROUND_IMAGE,
'headline' => 'Step into ' . $world->title,
'body' => Str::limit((string) ($world->summary ?: $world->description ?: 'Curated visuals, featured creators, and a clear editorial mood.'), 170, ''),
'caption' => 'World intro',
'alt_text' => 'Intro for ' . $world->title,
'text_position' => 'bottom',
'overlay_strength' => 35,
'animation' => 'fly-in-bottom',
'active' => true,
],
];
if ($primaryArtwork instanceof Artwork) {
$pages[] = [
'position' => count($pages) + 1,
'layout' => WorldWebStoryPage::LAYOUT_ARTWORK,
'artwork_id' => $primaryArtwork->id,
'background_type' => WorldWebStoryPage::BACKGROUND_IMAGE,
'headline' => (string) ($primaryArtwork->title ?: 'Featured artwork'),
'body' => Str::limit('A featured visual from ' . $world->title . ' by ' . ($primaryArtwork->user?->name ?: $primaryArtwork->user?->username ?: 'a Skinbase creator') . '.', 160, ''),
'caption' => 'Featured artwork',
'alt_text' => (string) ($primaryArtwork->title ?: 'Featured artwork'),
'text_position' => 'bottom',
'overlay_strength' => 35,
'animation' => 'pan-left',
'active' => true,
];
}
if ($secondaryArtwork instanceof Artwork) {
$pages[] = [
'position' => count($pages) + 1,
'layout' => WorldWebStoryPage::LAYOUT_CREATOR,
'artwork_id' => $secondaryArtwork->id,
'background_type' => WorldWebStoryPage::BACKGROUND_IMAGE,
'headline' => 'Creator spotlight',
'body' => Str::limit(($secondaryArtwork->user?->name ?: $secondaryArtwork->user?->username ?: 'A featured creator') . ' helps define the mood of ' . $world->title . '.', 160, ''),
'caption' => 'Creator spotlight',
'alt_text' => (string) ($secondaryArtwork->title ?: 'Creator spotlight artwork'),
'text_position' => 'bottom',
'overlay_strength' => 40,
'animation' => 'fade-in',
'active' => true,
];
}
if ($tertiaryArtwork instanceof Artwork) {
$pages[] = [
'position' => count($pages) + 1,
'layout' => WorldWebStoryPage::LAYOUT_COLLECTION,
'artwork_id' => $tertiaryArtwork->id,
'background_type' => WorldWebStoryPage::BACKGROUND_IMAGE,
'headline' => 'More from this World',
'body' => Str::limit('Explore more wallpapers, digital art, and creator picks collected inside ' . $world->title . '.', 155, ''),
'caption' => 'Community picks',
'alt_text' => (string) ($tertiaryArtwork->title ?: 'World picks'),
'text_position' => 'bottom',
'overlay_strength' => 35,
'animation' => 'pan-right',
'active' => true,
];
}
while (count($pages) < max(5, $pageCount - 1)) {
$pages[] = [
'position' => count($pages) + 1,
'layout' => WorldWebStoryPage::LAYOUT_MOOD,
'background_type' => WorldWebStoryPage::BACKGROUND_IMAGE,
'headline' => 'Inside the theme',
'body' => Str::limit('A short visual pause that keeps the story connected to ' . $world->title . '.', 150, ''),
'caption' => 'World mood',
'alt_text' => 'Mood page for ' . $world->title,
'text_position' => 'bottom',
'overlay_strength' => 35,
'animation' => 'fade-in',
'active' => true,
];
}
$pages[] = [
'position' => count($pages) + 1,
'layout' => WorldWebStoryPage::LAYOUT_CTA,
'background_type' => WorldWebStoryPage::BACKGROUND_IMAGE,
'headline' => 'Explore ' . $world->title,
'body' => Str::limit('Open the full World page for the complete artwork grid, featured picks, and related creator content.', 160, ''),
'caption' => 'Continue on Skinbase',
'cta_label' => 'View World',
'cta_url' => $world->publicUrl(),
'alt_text' => 'Explore ' . $world->title . ' on Skinbase',
'text_position' => 'bottom',
'overlay_strength' => 45,
'animation' => 'pulse',
'active' => true,
];
return collect($pages)
->take($pageCount)
->values()
->map(fn (array $page, int $index): array => array_merge($page, [
'position' => $index + 1,
]))
->all();
}
private function uniqueSlug(string $base, ?int $ignoreId = null): string
{
$candidate = Str::slug($base) ?: 'web-story';
$slug = $candidate;
$suffix = 2;
while (WorldWebStory::query()->when($ignoreId, fn ($query) => $query->whereKeyNot($ignoreId))->where('slug', $slug)->exists()) {
$slug = $candidate . '-' . $suffix;
$suffix++;
}
return $slug;
}
}