Implement academy analytics, billing, and web stories updates
This commit is contained in:
296
app/Services/WebStories/WorldWebStoryGenerator.php
Normal file
296
app/Services/WebStories/WorldWebStoryGenerator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user