Implement academy analytics, billing, and web stories updates
This commit is contained in:
166
app/Services/WebStories/WorldWebStoryAssetService.php
Normal file
166
app/Services/WebStories/WorldWebStoryAssetService.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\WebStories;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\World;
|
||||
use App\Models\WorldSubmission;
|
||||
use App\Models\WorldWebStory;
|
||||
use App\Models\WorldWebStoryPage;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
|
||||
final class WorldWebStoryAssetService
|
||||
{
|
||||
public function defaultPublisherLogoPath(): string
|
||||
{
|
||||
return 'https://cdn.skinbase.org/images/skinbase_logo_96.webp';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{updated: bool, story: array<string, string>, pages: array<int, array<string, mixed>>}
|
||||
*/
|
||||
public function buildAssets(WorldWebStory $story, bool $force = false, bool $dryRun = false): array
|
||||
{
|
||||
$story->loadMissing(['world', 'orderedPages.artwork']);
|
||||
$world = $story->world;
|
||||
$storyChanges = [];
|
||||
$pageChanges = [];
|
||||
|
||||
$primaryImage = $this->bestWorldImage($story);
|
||||
|
||||
if (($force || blank($story->poster_portrait_path)) && filled($primaryImage)) {
|
||||
$storyChanges['poster_portrait_path'] = $primaryImage;
|
||||
}
|
||||
|
||||
if (($force || blank($story->poster_square_path)) && filled($primaryImage)) {
|
||||
$storyChanges['poster_square_path'] = $primaryImage;
|
||||
}
|
||||
|
||||
if ($force || blank($story->publisher_logo_path)) {
|
||||
$storyChanges['publisher_logo_path'] = $this->defaultPublisherLogoPath();
|
||||
}
|
||||
|
||||
foreach ($story->orderedPages as $page) {
|
||||
$changes = [];
|
||||
$background = $this->bestPageBackground($page, $world, $primaryImage);
|
||||
|
||||
if (($force || blank($page->background_path)) && filled($background)) {
|
||||
$changes['background_path'] = $background;
|
||||
}
|
||||
|
||||
if (($force || blank($page->background_mobile_path)) && filled($background)) {
|
||||
$changes['background_mobile_path'] = $background;
|
||||
}
|
||||
|
||||
if (($force || blank($page->alt_text)) && filled($page->headline)) {
|
||||
$changes['alt_text'] = (string) $page->headline;
|
||||
}
|
||||
|
||||
if ($changes !== []) {
|
||||
$pageChanges[(int) $page->id] = $changes;
|
||||
if (! $dryRun) {
|
||||
$page->forceFill($changes)->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($storyChanges !== [] && ! $dryRun) {
|
||||
$story->forceFill($storyChanges)->save();
|
||||
}
|
||||
|
||||
return [
|
||||
'updated' => $storyChanges !== [] || $pageChanges !== [],
|
||||
'story' => $storyChanges,
|
||||
'pages' => $pageChanges,
|
||||
];
|
||||
}
|
||||
|
||||
public function storyBasePath(WorldWebStory $story): string
|
||||
{
|
||||
$slug = trim((string) ($story->world?->slug ?: $story->slug));
|
||||
|
||||
return 'web-stories/worlds/' . $slug;
|
||||
}
|
||||
|
||||
private function bestWorldImage(WorldWebStory $story): ?string
|
||||
{
|
||||
$world = $story->world;
|
||||
|
||||
if ($world instanceof World) {
|
||||
foreach ([$world->ogImageUrl(), $world->coverUrl(), $world->teaserImageUrl()] as $candidate) {
|
||||
if (filled($candidate)) {
|
||||
return (string) $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
$artwork = $this->bestWorldArtwork($world);
|
||||
if ($artwork instanceof Artwork) {
|
||||
return $this->artworkImage($artwork);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function bestPageBackground(WorldWebStoryPage $page, ?World $world, ?string $fallback): ?string
|
||||
{
|
||||
if ($page->artwork instanceof Artwork) {
|
||||
$artworkImage = $this->artworkImage($page->artwork);
|
||||
if (filled($artworkImage)) {
|
||||
return $artworkImage;
|
||||
}
|
||||
}
|
||||
|
||||
if ($world instanceof World) {
|
||||
$artwork = $this->bestWorldArtwork($world);
|
||||
if ($artwork instanceof Artwork) {
|
||||
$artworkImage = $this->artworkImage($artwork);
|
||||
if (filled($artworkImage)) {
|
||||
return $artworkImage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
private function bestWorldArtwork(World $world): ?Artwork
|
||||
{
|
||||
$relatedArtworkIds = $world->worldRelations()
|
||||
->where('related_type', 'artwork')
|
||||
->orderByDesc('is_featured')
|
||||
->orderBy('sort_order')
|
||||
->pluck('related_id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
if ($relatedArtworkIds->isNotEmpty()) {
|
||||
return Artwork::query()
|
||||
->whereIn('id', $relatedArtworkIds)
|
||||
->get()
|
||||
->sortBy(fn (Artwork $artwork): int => (int) ($relatedArtworkIds->search((int) $artwork->id) ?? PHP_INT_MAX))
|
||||
->first();
|
||||
}
|
||||
|
||||
$submission = WorldSubmission::query()
|
||||
->with('artwork')
|
||||
->where('world_id', $world->id)
|
||||
->where('status', WorldSubmission::STATUS_LIVE)
|
||||
->orderByDesc('is_featured')
|
||||
->orderByDesc('featured_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
return $submission?->artwork;
|
||||
}
|
||||
|
||||
private function artworkImage(Artwork $artwork): ?string
|
||||
{
|
||||
$preview = ThumbnailPresenter::present($artwork, 'xl');
|
||||
|
||||
return (string) ($preview['url'] ?? $artwork->thumbnail_url ?? $artwork->thumb_url ?? '');
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
47
app/Services/WebStories/WorldWebStorySeoService.php
Normal file
47
app/Services/WebStories/WorldWebStorySeoService.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\WebStories;
|
||||
|
||||
use App\Models\WorldWebStory;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
|
||||
final class WorldWebStorySeoService
|
||||
{
|
||||
public function __construct(private readonly SeoFactory $seo)
|
||||
{
|
||||
}
|
||||
|
||||
public function indexSeo(): array
|
||||
{
|
||||
return $this->seo->collectionListing(
|
||||
'Skinbase Web Stories',
|
||||
'Explore Skinbase Web Stories featuring digital art Worlds, wallpapers, creator highlights, seasonal collections, and visual stories from the Skinbase community.',
|
||||
route('web-stories.index'),
|
||||
)->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function storyMeta(WorldWebStory $story): array
|
||||
{
|
||||
$title = $story->seoTitle();
|
||||
$description = $story->seoDescription();
|
||||
|
||||
return [
|
||||
'title' => $title,
|
||||
'description' => $description,
|
||||
'canonical' => $story->publicUrl(),
|
||||
'robots' => $story->noindex ? 'noindex,follow' : 'index,follow,max-image-preview:large',
|
||||
'og_title' => $title,
|
||||
'og_description' => $description,
|
||||
'og_url' => $story->publicUrl(),
|
||||
'og_image' => (string) $story->posterPortraitUrl(),
|
||||
'twitter_title' => $title,
|
||||
'twitter_description' => $description,
|
||||
'twitter_image' => (string) $story->posterPortraitUrl(),
|
||||
];
|
||||
}
|
||||
}
|
||||
174
app/Services/WebStories/WorldWebStoryValidationService.php
Normal file
174
app/Services/WebStories/WorldWebStoryValidationService.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\WebStories;
|
||||
|
||||
use App\Models\WorldWebStory;
|
||||
use App\Models\WorldWebStoryPage;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class WorldWebStoryValidationService
|
||||
{
|
||||
/**
|
||||
* @return array{valid: bool, errors: list<string>, warnings: list<string>, page_count: int}
|
||||
*/
|
||||
public function validate(WorldWebStory $story): array
|
||||
{
|
||||
$story->loadMissing('orderedPages');
|
||||
$pages = $story->orderedPages->where('active', true)->values();
|
||||
|
||||
$errors = [];
|
||||
$warnings = [];
|
||||
|
||||
if (trim((string) $story->title) === '') {
|
||||
$errors[] = 'Story title is required.';
|
||||
}
|
||||
|
||||
if (trim((string) $story->slug) === '') {
|
||||
$errors[] = 'Story slug is required.';
|
||||
}
|
||||
|
||||
if (trim((string) $story->poster_portrait_path) === '') {
|
||||
$errors[] = 'Poster portrait image is required.';
|
||||
}
|
||||
|
||||
if (trim((string) $story->publisher_logo_path) === '') {
|
||||
$errors[] = 'Publisher logo is required.';
|
||||
}
|
||||
|
||||
if ($pages->count() < 5) {
|
||||
$errors[] = 'A published web story must have at least 5 active pages.';
|
||||
}
|
||||
|
||||
if ($pages->count() > 10) {
|
||||
$errors[] = 'A published web story may not have more than 10 active pages.';
|
||||
}
|
||||
|
||||
foreach ($pages as $page) {
|
||||
$pageNumber = (int) $page->position;
|
||||
$body = trim((string) $page->body);
|
||||
$headline = trim((string) $page->headline);
|
||||
|
||||
if (in_array((string) $page->background_type, [WorldWebStoryPage::BACKGROUND_IMAGE, WorldWebStoryPage::BACKGROUND_VIDEO], true)
|
||||
&& trim((string) ($page->background_mobile_path ?: $page->background_path)) === '') {
|
||||
$errors[] = sprintf('Page %d is missing required background media.', $pageNumber);
|
||||
}
|
||||
|
||||
if (mb_strlen($body) > 180) {
|
||||
$errors[] = sprintf('Page %d body exceeds 180 characters.', $pageNumber);
|
||||
}
|
||||
|
||||
if (trim((string) $page->alt_text) === '') {
|
||||
$errors[] = sprintf('Page %d is missing alt text.', $pageNumber);
|
||||
}
|
||||
|
||||
if ($headline === '' && $body === '') {
|
||||
$warnings[] = sprintf('Page %d has no story text.', $pageNumber);
|
||||
}
|
||||
|
||||
if (filled($page->cta_label) || filled($page->cta_url)) {
|
||||
if (! filled($page->cta_label) || ! filled($page->cta_url)) {
|
||||
$errors[] = sprintf('Page %d CTA requires both label and URL.', $pageNumber);
|
||||
} elseif (! $this->isAllowedCtaUrl((string) $page->cta_url)) {
|
||||
$errors[] = sprintf('Page %d CTA URL is not allowed.', $pageNumber);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => $errors === [],
|
||||
'errors' => array_values(array_unique($errors)),
|
||||
'warnings' => array_values(array_unique($warnings)),
|
||||
'page_count' => $pages->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $page
|
||||
*/
|
||||
public function validatePagePayload(array $page): array
|
||||
{
|
||||
$errors = [];
|
||||
$position = (int) ($page['position'] ?? 0);
|
||||
$body = trim((string) ($page['body'] ?? ''));
|
||||
$backgroundType = (string) ($page['background_type'] ?? WorldWebStoryPage::BACKGROUND_IMAGE);
|
||||
$backgroundPath = trim((string) ($page['background_mobile_path'] ?? $page['background_path'] ?? ''));
|
||||
$altText = trim((string) ($page['alt_text'] ?? ''));
|
||||
$ctaUrl = trim((string) ($page['cta_url'] ?? ''));
|
||||
$ctaLabel = trim((string) ($page['cta_label'] ?? ''));
|
||||
|
||||
if ($body !== '' && mb_strlen($body) > 180) {
|
||||
$errors['body'] = sprintf('Page %d body exceeds 180 characters.', max(1, $position));
|
||||
}
|
||||
|
||||
if (in_array($backgroundType, [WorldWebStoryPage::BACKGROUND_IMAGE, WorldWebStoryPage::BACKGROUND_VIDEO], true) && $backgroundPath === '') {
|
||||
$errors['background_path'] = 'Background media is required for image and video pages.';
|
||||
}
|
||||
|
||||
if ($altText === '') {
|
||||
$errors['alt_text'] = 'Alt text is required.';
|
||||
}
|
||||
|
||||
if (($ctaUrl !== '' || $ctaLabel !== '') && ($ctaUrl === '' || $ctaLabel === '')) {
|
||||
$errors['cta'] = 'CTA label and URL must both be present.';
|
||||
}
|
||||
|
||||
if ($ctaUrl !== '' && ! $this->isAllowedCtaUrl($ctaUrl)) {
|
||||
$errors['cta_url'] = 'CTA URL must stay on Skinbase or use a relative path.';
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
public function assertPublishable(WorldWebStory $story): void
|
||||
{
|
||||
$result = $this->validate($story);
|
||||
|
||||
if ($result['valid']) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'story' => $result['errors'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function isAllowedCtaUrl(string $url): bool
|
||||
{
|
||||
$value = trim($url);
|
||||
|
||||
if ($value === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Str::startsWith($value, ['/'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$parts = parse_url($value);
|
||||
$host = strtolower((string) Arr::get($parts, 'host', ''));
|
||||
|
||||
if ($host === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowedHosts = array_filter([
|
||||
strtolower((string) parse_url((string) config('app.url'), PHP_URL_HOST)),
|
||||
'skinbase.org',
|
||||
'www.skinbase.org',
|
||||
'skinbase.top',
|
||||
'www.skinbase.top',
|
||||
]);
|
||||
|
||||
foreach ($allowedHosts as $allowedHost) {
|
||||
if ($host === $allowedHost || Str::endsWith($host, '.' . $allowedHost)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user