Implement academy analytics, billing, and web stories updates
This commit is contained in:
45
app/Models/AcademyBillingEvent.php
Normal file
45
app/Models/AcademyBillingEvent.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class AcademyBillingEvent extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'stripe_event_id',
|
||||
'stripe_customer_id',
|
||||
'stripe_subscription_id',
|
||||
'event_type',
|
||||
'academy_tier',
|
||||
'academy_plan',
|
||||
'payload_summary',
|
||||
'processed_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'payload_summary' => 'array',
|
||||
'processed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
47
app/Models/AcademyContentMetricDaily.php
Normal file
47
app/Models/AcademyContentMetricDaily.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class AcademyContentMetricDaily extends Model
|
||||
{
|
||||
protected $table = 'academy_content_metrics_daily';
|
||||
|
||||
protected $fillable = [
|
||||
'date',
|
||||
'content_type',
|
||||
'content_id',
|
||||
'views',
|
||||
'unique_visitors',
|
||||
'guest_views',
|
||||
'user_views',
|
||||
'subscriber_views',
|
||||
'engaged_views',
|
||||
'scroll_50',
|
||||
'scroll_75',
|
||||
'scroll_100',
|
||||
'likes',
|
||||
'saves',
|
||||
'prompt_copies',
|
||||
'negative_prompt_copies',
|
||||
'starts',
|
||||
'completions',
|
||||
'upgrade_clicks',
|
||||
'premium_preview_views',
|
||||
'search_impressions',
|
||||
'search_clicks',
|
||||
'bounce_count',
|
||||
'avg_engaged_seconds',
|
||||
'popularity_score',
|
||||
'conversion_score',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'date' => 'date',
|
||||
'popularity_score' => 'decimal:2',
|
||||
'conversion_score' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
54
app/Models/AcademyEvent.php
Normal file
54
app/Models/AcademyEvent.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AcademyEvent extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'event_type',
|
||||
'content_type',
|
||||
'content_id',
|
||||
'user_id',
|
||||
'visitor_id',
|
||||
'session_id',
|
||||
'url',
|
||||
'route_name',
|
||||
'referrer',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'device_type',
|
||||
'browser',
|
||||
'platform',
|
||||
'country_code',
|
||||
'is_logged_in',
|
||||
'is_subscriber',
|
||||
'is_admin',
|
||||
'is_bot',
|
||||
'is_crawler',
|
||||
'is_suspicious',
|
||||
'metadata',
|
||||
'occurred_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
'occurred_at' => 'datetime',
|
||||
'is_logged_in' => 'boolean',
|
||||
'is_subscriber' => 'boolean',
|
||||
'is_admin' => 'boolean',
|
||||
'is_bot' => 'boolean',
|
||||
'is_crawler' => 'boolean',
|
||||
'is_suspicious' => 'boolean',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
}
|
||||
22
app/Models/AcademyLike.php
Normal file
22
app/Models/AcademyLike.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AcademyLike extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'content_type',
|
||||
'content_id',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,10 @@ class AcademyPromptTemplate extends Model
|
||||
'negative_prompt',
|
||||
'usage_notes',
|
||||
'workflow_notes',
|
||||
'documentation',
|
||||
'placeholders',
|
||||
'helper_prompts',
|
||||
'prompt_variants',
|
||||
'difficulty',
|
||||
'access_level',
|
||||
'aspect_ratio',
|
||||
@@ -41,6 +45,10 @@ class AcademyPromptTemplate extends Model
|
||||
protected $casts = [
|
||||
'tags' => 'array',
|
||||
'tool_notes' => 'array',
|
||||
'documentation' => 'array',
|
||||
'placeholders' => 'array',
|
||||
'helper_prompts' => 'array',
|
||||
'prompt_variants' => 'array',
|
||||
'featured' => 'boolean',
|
||||
'prompt_of_week' => 'boolean',
|
||||
'active' => 'boolean',
|
||||
|
||||
22
app/Models/AcademySave.php
Normal file
22
app/Models/AcademySave.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AcademySave extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'content_type',
|
||||
'content_id',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
}
|
||||
37
app/Models/AcademySearchLog.php
Normal file
37
app/Models/AcademySearchLog.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AcademySearchLog extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'visitor_id',
|
||||
'query',
|
||||
'normalized_query',
|
||||
'results_count',
|
||||
'clicked_content_type',
|
||||
'clicked_content_id',
|
||||
'filters',
|
||||
'is_logged_in',
|
||||
'is_subscriber',
|
||||
'is_bot',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'filters' => 'array',
|
||||
'is_logged_in' => 'boolean',
|
||||
'is_subscriber' => 'boolean',
|
||||
'is_bot' => 'boolean',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
}
|
||||
47
app/Models/AcademyUserProgress.php
Normal file
47
app/Models/AcademyUserProgress.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AcademyUserProgress extends Model
|
||||
{
|
||||
protected $table = 'academy_user_progress';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'course_id',
|
||||
'lesson_id',
|
||||
'status',
|
||||
'progress_percent',
|
||||
'started_at',
|
||||
'completed_at',
|
||||
'last_seen_at',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
'started_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'last_seen_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
public function course(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AcademyCourse::class, 'course_id');
|
||||
}
|
||||
|
||||
public function lesson(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AcademyLesson::class, 'lesson_id');
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,13 @@ use App\Models\ConversationParticipant;
|
||||
use App\Models\AcademyBadge;
|
||||
use App\Models\AcademyCourseEnrollment;
|
||||
use App\Models\AcademyChallengeSubmission;
|
||||
use App\Models\AcademyEvent;
|
||||
use App\Models\AcademyLike;
|
||||
use App\Models\AcademyLessonProgress;
|
||||
use App\Models\AcademySave;
|
||||
use App\Models\AcademySavedPrompt;
|
||||
use App\Models\AcademySearchLog;
|
||||
use App\Models\AcademyUserProgress;
|
||||
use App\Models\Message;
|
||||
use App\Models\Notification;
|
||||
use App\Models\Achievement;
|
||||
@@ -30,6 +35,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Cashier\Billable;
|
||||
use Laravel\Scout\Searchable;
|
||||
|
||||
class User extends Authenticatable
|
||||
@@ -40,7 +46,7 @@ class User extends Authenticatable
|
||||
];
|
||||
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable, SoftDeletes;
|
||||
use Billable, HasFactory, Notifiable, SoftDeletes;
|
||||
use Searchable {
|
||||
Searchable::bootSearchable as private bootScoutSearchable;
|
||||
}
|
||||
@@ -218,6 +224,31 @@ class User extends Authenticatable
|
||||
return $this->hasMany(AcademySavedPrompt::class, 'user_id');
|
||||
}
|
||||
|
||||
public function academyEvents(): HasMany
|
||||
{
|
||||
return $this->hasMany(AcademyEvent::class, 'user_id');
|
||||
}
|
||||
|
||||
public function academyLikes(): HasMany
|
||||
{
|
||||
return $this->hasMany(AcademyLike::class, 'user_id');
|
||||
}
|
||||
|
||||
public function academySaves(): HasMany
|
||||
{
|
||||
return $this->hasMany(AcademySave::class, 'user_id');
|
||||
}
|
||||
|
||||
public function academyUserProgress(): HasMany
|
||||
{
|
||||
return $this->hasMany(AcademyUserProgress::class, 'user_id');
|
||||
}
|
||||
|
||||
public function academySearchLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(AcademySearchLog::class, 'user_id');
|
||||
}
|
||||
|
||||
public function academyChallengeSubmissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(AcademyChallengeSubmission::class, 'user_id');
|
||||
@@ -448,12 +479,12 @@ class User extends Authenticatable
|
||||
|
||||
public function hasAcademyCreatorAccess(): bool
|
||||
{
|
||||
return $this->hasAcademyProAccess() || strtolower(trim((string) ($this->role ?? ''))) === 'academy_creator';
|
||||
return in_array(app(\App\Services\Academy\AcademyAccessService::class)->currentTier($this), ['creator', 'pro', 'admin'], true);
|
||||
}
|
||||
|
||||
public function hasAcademyProAccess(): bool
|
||||
{
|
||||
return strtolower(trim((string) ($this->role ?? ''))) === 'academy_pro';
|
||||
return in_array(app(\App\Services\Academy\AcademyAccessService::class)->currentTier($this), ['pro', 'admin'], true);
|
||||
}
|
||||
|
||||
public function canAccessAcademyContent(object|array $content): bool
|
||||
|
||||
@@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
@@ -197,6 +198,16 @@ class World extends Model
|
||||
return $this->hasMany(WorldRewardGrant::class)->orderByDesc('granted_at')->orderByDesc('id');
|
||||
}
|
||||
|
||||
public function webStories(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorldWebStory::class)->orderByDesc('published_at')->orderByDesc('id');
|
||||
}
|
||||
|
||||
public function publishedWebStory(): HasOne
|
||||
{
|
||||
return $this->hasOne(WorldWebStory::class)->visible()->latest('published_at')->latest('id');
|
||||
}
|
||||
|
||||
public function scopePublished(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
|
||||
181
app/Models/WorldWebStory.php
Normal file
181
app/Models/WorldWebStory.php
Normal file
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class WorldWebStory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
public const STATUS_PUBLISHED = 'published';
|
||||
public const STATUS_ARCHIVED = 'archived';
|
||||
|
||||
protected $fillable = [
|
||||
'world_id',
|
||||
'slug',
|
||||
'title',
|
||||
'subtitle',
|
||||
'excerpt',
|
||||
'description',
|
||||
'seo_title',
|
||||
'seo_description',
|
||||
'poster_portrait_path',
|
||||
'poster_square_path',
|
||||
'publisher_logo_path',
|
||||
'status',
|
||||
'featured',
|
||||
'active',
|
||||
'noindex',
|
||||
'published_at',
|
||||
'starts_at',
|
||||
'ends_at',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'featured' => 'boolean',
|
||||
'active' => 'boolean',
|
||||
'noindex' => 'boolean',
|
||||
'published_at' => 'datetime',
|
||||
'starts_at' => 'datetime',
|
||||
'ends_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
$flushCache = static function (self $story): void {
|
||||
Cache::forget('web_story_index');
|
||||
Cache::forget('web_story:' . $story->slug);
|
||||
|
||||
if ($story->world?->slug) {
|
||||
Cache::forget('world:' . $story->world->slug . ':web_story');
|
||||
} elseif ($story->world_id) {
|
||||
$worldSlug = World::query()->whereKey($story->world_id)->value('slug');
|
||||
if (is_string($worldSlug) && $worldSlug !== '') {
|
||||
Cache::forget('world:' . $worldSlug . ':web_story');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
static::saved($flushCache);
|
||||
static::deleted($flushCache);
|
||||
static::restored($flushCache);
|
||||
}
|
||||
|
||||
public function world(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(World::class);
|
||||
}
|
||||
|
||||
public function pages(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorldWebStoryPage::class, 'story_id')->orderedPages();
|
||||
}
|
||||
|
||||
public function orderedPages(): HasMany
|
||||
{
|
||||
return $this->hasMany(WorldWebStoryPage::class, 'story_id')->orderedPages();
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function updater(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by');
|
||||
}
|
||||
|
||||
public function scopePublished(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_PUBLISHED);
|
||||
}
|
||||
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('active', true);
|
||||
}
|
||||
|
||||
public function scopeFeatured(Builder $query): Builder
|
||||
{
|
||||
return $query->where('featured', true);
|
||||
}
|
||||
|
||||
public function scopeVisible(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->active()
|
||||
->published()
|
||||
->where('noindex', false)
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->whereNull('published_at')
|
||||
->orWhere('published_at', '<=', now());
|
||||
})
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->whereNull('starts_at')
|
||||
->orWhere('starts_at', '<=', now());
|
||||
})
|
||||
->where(function (Builder $builder): void {
|
||||
$builder->whereNull('ends_at')
|
||||
->orWhere('ends_at', '>=', now());
|
||||
});
|
||||
}
|
||||
|
||||
public function publicUrl(): string
|
||||
{
|
||||
return route('web-stories.show', ['slug' => $this->slug]);
|
||||
}
|
||||
|
||||
public function posterPortraitUrl(): ?string
|
||||
{
|
||||
return $this->assetUrl($this->poster_portrait_path);
|
||||
}
|
||||
|
||||
public function posterSquareUrl(): ?string
|
||||
{
|
||||
return $this->assetUrl($this->poster_square_path);
|
||||
}
|
||||
|
||||
public function publisherLogoUrl(): ?string
|
||||
{
|
||||
return $this->assetUrl($this->publisher_logo_path);
|
||||
}
|
||||
|
||||
public function seoTitle(): string
|
||||
{
|
||||
return trim((string) ($this->seo_title ?: $this->title));
|
||||
}
|
||||
|
||||
public function seoDescription(): string
|
||||
{
|
||||
return trim((string) ($this->seo_description ?: $this->excerpt ?: $this->description ?: ''));
|
||||
}
|
||||
|
||||
private function assetUrl(?string $path): ?string
|
||||
{
|
||||
$resolved = trim((string) $path);
|
||||
|
||||
if ($resolved === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($resolved, 'http://') || str_starts_with($resolved, 'https://')) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($resolved, '/');
|
||||
}
|
||||
}
|
||||
118
app/Models/WorldWebStoryPage.php
Normal file
118
app/Models/WorldWebStoryPage.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class WorldWebStoryPage extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
public const LAYOUT_COVER = 'cover';
|
||||
public const LAYOUT_ARTWORK = 'artwork';
|
||||
public const LAYOUT_CREATOR = 'creator';
|
||||
public const LAYOUT_MOOD = 'mood';
|
||||
public const LAYOUT_COLLECTION = 'collection';
|
||||
public const LAYOUT_CTA = 'cta';
|
||||
|
||||
public const BACKGROUND_IMAGE = 'image';
|
||||
public const BACKGROUND_VIDEO = 'video';
|
||||
public const BACKGROUND_GRADIENT = 'gradient';
|
||||
|
||||
protected $fillable = [
|
||||
'story_id',
|
||||
'artwork_id',
|
||||
'position',
|
||||
'layout',
|
||||
'background_type',
|
||||
'background_path',
|
||||
'background_mobile_path',
|
||||
'headline',
|
||||
'body',
|
||||
'cta_label',
|
||||
'cta_url',
|
||||
'alt_text',
|
||||
'caption',
|
||||
'credit_text',
|
||||
'text_position',
|
||||
'overlay_strength',
|
||||
'animation',
|
||||
'active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'position' => 'integer',
|
||||
'overlay_strength' => 'integer',
|
||||
'active' => 'boolean',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
$flushCache = static function (self $page): void {
|
||||
$story = $page->relationLoaded('story') ? $page->story : $page->story()->with('world')->first();
|
||||
|
||||
if (! ($story instanceof WorldWebStory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Cache::forget('web_story:' . $story->slug);
|
||||
Cache::forget('web_story_index');
|
||||
|
||||
if ($story->world?->slug) {
|
||||
Cache::forget('world:' . $story->world->slug . ':web_story');
|
||||
}
|
||||
};
|
||||
|
||||
static::saved($flushCache);
|
||||
static::deleted($flushCache);
|
||||
static::restored($flushCache);
|
||||
}
|
||||
|
||||
public function story(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WorldWebStory::class, 'story_id');
|
||||
}
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class);
|
||||
}
|
||||
|
||||
public function scopeOrderedPages(Builder $query): Builder
|
||||
{
|
||||
return $query->orderBy('position')->orderBy('id');
|
||||
}
|
||||
|
||||
public function backgroundUrl(): ?string
|
||||
{
|
||||
return $this->assetUrl($this->background_mobile_path ?: $this->background_path);
|
||||
}
|
||||
|
||||
public function desktopBackgroundUrl(): ?string
|
||||
{
|
||||
return $this->assetUrl($this->background_path ?: $this->background_mobile_path);
|
||||
}
|
||||
|
||||
private function assetUrl(?string $path): ?string
|
||||
{
|
||||
$resolved = trim((string) $path);
|
||||
|
||||
if ($resolved === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($resolved, 'http://') || str_starts_with($resolved, 'https://')) {
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($resolved, '/');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user