732 lines
33 KiB
PHP
732 lines
33 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Worlds;
|
|
|
|
use App\Enums\WorldRewardType;
|
|
use App\Http\Resources\ArtworkListResource;
|
|
use App\Models\Artwork;
|
|
use App\Models\User;
|
|
use App\Models\World;
|
|
use App\Models\WorldSubmission;
|
|
use App\Services\Maturity\ArtworkMaturityService;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
final class WorldSubmissionService
|
|
{
|
|
private array $canonicalRecurringEligibilityIds = [];
|
|
|
|
public function __construct(
|
|
private readonly ArtworkMaturityService $maturity,
|
|
private readonly WorldRewardService $rewards,
|
|
private readonly WorldAnalyticsService $analytics,
|
|
) {
|
|
}
|
|
|
|
public function eligibleWorldOptions(?User $viewer = null): array
|
|
{
|
|
return $this->eligibleWorlds()
|
|
->map(fn (World $world): array => $this->mapCreatorWorldOption($world, null, true))
|
|
->all();
|
|
}
|
|
|
|
public function artworkSubmissionOptions(Artwork $artwork, User $viewer): array
|
|
{
|
|
$artwork->loadMissing(['worldSubmissions.world', 'worldSubmissions.reviewer']);
|
|
|
|
$existing = $artwork->worldSubmissions
|
|
->filter(fn (WorldSubmission $submission): bool => $submission->world !== null)
|
|
->keyBy(fn (WorldSubmission $submission): int => (int) $submission->world_id);
|
|
|
|
$eligibleWorlds = $this->eligibleWorlds()->keyBy(fn (World $world): int => (int) $world->id);
|
|
$worlds = $eligibleWorlds;
|
|
|
|
$missingWorldIds = $existing->keys()
|
|
->map(fn ($id): int => (int) $id)
|
|
->reject(fn (int $id): bool => $eligibleWorlds->has($id))
|
|
->values();
|
|
|
|
if ($missingWorldIds->isNotEmpty()) {
|
|
World::query()
|
|
->whereIn('id', $missingWorldIds->all())
|
|
->get()
|
|
->each(fn (World $world) => $worlds->put((int) $world->id, $world));
|
|
}
|
|
|
|
return $worlds
|
|
->sortBy([
|
|
fn (World $world): int => $existing->has((int) $world->id) ? 0 : 1,
|
|
fn (World $world): int => $world->isActiveCampaign() ? 0 : ($world->isUpcomingCampaign() ? 1 : 2),
|
|
fn (World $world): int => -1 * (int) ($world->campaign_priority ?? 0),
|
|
fn (World $world): int => $world->effectivePromotionStartsAt()?->getTimestamp() ?? PHP_INT_MAX,
|
|
fn (World $world): string => Str::lower((string) $world->title),
|
|
])
|
|
->values()
|
|
->map(function (World $world) use ($existing): array {
|
|
$submission = $existing->get((int) $world->id);
|
|
|
|
return $this->mapCreatorWorldOption($world, $submission, $this->isEligibleWorld($world));
|
|
})
|
|
->all();
|
|
}
|
|
|
|
public function syncForArtwork(Artwork $artwork, User $actor, array $entries): void
|
|
{
|
|
$artwork->loadMissing('worldSubmissions');
|
|
|
|
$normalizedEntries = collect($entries)
|
|
->map(function (array $entry): ?array {
|
|
$worldId = (int) ($entry['world_id'] ?? 0);
|
|
if ($worldId < 1) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'world_id' => $worldId,
|
|
'note' => Str::limit(trim((string) ($entry['note'] ?? '')), 1000, ''),
|
|
'source_surface' => trim((string) ($entry['source_surface'] ?? '')),
|
|
];
|
|
})
|
|
->filter()
|
|
->unique('world_id')
|
|
->values();
|
|
|
|
$existing = $artwork->worldSubmissions->keyBy(fn (WorldSubmission $submission): int => (int) $submission->world_id);
|
|
$selectedWorldIds = $normalizedEntries->pluck('world_id')->map(fn ($id): int => (int) $id)->all();
|
|
$allWorldIds = array_values(array_unique(array_merge($selectedWorldIds, $existing->keys()->map(fn ($id): int => (int) $id)->all())));
|
|
|
|
$worlds = World::query()
|
|
->whereIn('id', $allWorldIds)
|
|
->get()
|
|
->keyBy(fn (World $world): int => (int) $world->id);
|
|
|
|
$errors = [];
|
|
|
|
foreach ($normalizedEntries as $index => $entry) {
|
|
$world = $worlds->get((int) $entry['world_id']);
|
|
$submission = $existing->get((int) $entry['world_id']);
|
|
|
|
if (! $world) {
|
|
$errors["world_submissions.{$index}.world_id"] = 'Selected world no longer exists.';
|
|
continue;
|
|
}
|
|
|
|
if (! $this->isEligibleWorld($world)) {
|
|
$errors["world_submissions.{$index}.world_id"] = 'That world is not currently accepting community submissions.';
|
|
continue;
|
|
}
|
|
|
|
if ($submission && $submission->isBlockingResubmission()) {
|
|
$errors["world_submissions.{$index}.world_id"] = 'This artwork is blocked from that world until a moderator clears the block.';
|
|
continue;
|
|
}
|
|
|
|
if ($submission && (string) $submission->status === WorldSubmission::STATUS_REMOVED && ! (bool) $world->allow_readd_after_removal) {
|
|
$errors["world_submissions.{$index}.world_id"] = 'That world does not allow re-adding removed artworks right now.';
|
|
}
|
|
}
|
|
|
|
if ($errors !== []) {
|
|
throw ValidationException::withMessages($errors);
|
|
}
|
|
|
|
DB::transaction(function () use ($normalizedEntries, $artwork, $actor, $existing, $worlds, $selectedWorldIds): void {
|
|
foreach ($normalizedEntries as $entry) {
|
|
$worldId = (int) $entry['world_id'];
|
|
$submission = $existing->get($worldId);
|
|
$world = $worlds->get($worldId);
|
|
|
|
$note = ($world?->submission_note_enabled ?? true) ? ($entry['note'] !== '' ? $entry['note'] : null) : null;
|
|
$startingStatus = $world?->submissionStartsAsLive()
|
|
? WorldSubmission::STATUS_LIVE
|
|
: WorldSubmission::STATUS_PENDING;
|
|
$reviewedAt = $startingStatus === WorldSubmission::STATUS_LIVE ? now() : null;
|
|
|
|
if ($submission && $submission->isBlockingResubmission()) {
|
|
continue;
|
|
}
|
|
|
|
if ($submission) {
|
|
$wasRemoved = (string) $submission->status === WorldSubmission::STATUS_REMOVED;
|
|
$payload = [
|
|
'mode_snapshot' => $world?->participation_mode,
|
|
'note' => $note,
|
|
];
|
|
|
|
if ((string) $submission->status === WorldSubmission::STATUS_REMOVED) {
|
|
$payload = array_merge($payload, [
|
|
'status' => $startingStatus,
|
|
'is_featured' => false,
|
|
'reviewer_note' => null,
|
|
'moderation_reason' => null,
|
|
'reviewed_by_user_id' => null,
|
|
'reviewed_at' => $reviewedAt,
|
|
'removed_at' => null,
|
|
'blocked_at' => null,
|
|
'featured_at' => null,
|
|
]);
|
|
}
|
|
|
|
$submission->forceFill($payload)->save();
|
|
|
|
if ($wasRemoved) {
|
|
$eventType = self::lifecycleEventForStatus($startingStatus);
|
|
|
|
if ($eventType !== null) {
|
|
$this->analytics->recordSubmissionLifecycle(
|
|
$submission,
|
|
$eventType,
|
|
$actor,
|
|
$entry['source_surface'] !== '' ? $entry['source_surface'] : null,
|
|
['reactivated' => true]
|
|
);
|
|
}
|
|
}
|
|
|
|
$this->rewards->syncAutomaticRewardsForSubmission($submission);
|
|
|
|
continue;
|
|
}
|
|
|
|
$submission = WorldSubmission::query()->create([
|
|
'world_id' => $worldId,
|
|
'artwork_id' => (int) $artwork->id,
|
|
'submitted_by_user_id' => (int) $actor->id,
|
|
'status' => $startingStatus,
|
|
'is_featured' => false,
|
|
'mode_snapshot' => $world?->participation_mode,
|
|
'note' => $note,
|
|
'reviewed_at' => $reviewedAt,
|
|
]);
|
|
|
|
$this->analytics->recordSubmissionLifecycle(
|
|
$submission,
|
|
WorldAnalyticsService::EVENT_SUBMISSION_CREATED,
|
|
$actor,
|
|
$entry['source_surface'] !== '' ? $entry['source_surface'] : null
|
|
);
|
|
|
|
if ($startingStatus === WorldSubmission::STATUS_LIVE) {
|
|
$this->analytics->recordSubmissionLifecycle(
|
|
$submission,
|
|
WorldAnalyticsService::EVENT_SUBMISSION_APPROVED,
|
|
$actor,
|
|
$entry['source_surface'] !== '' ? $entry['source_surface'] : null,
|
|
['auto_approved' => true]
|
|
);
|
|
}
|
|
|
|
$this->rewards->syncAutomaticRewardsForSubmission($submission);
|
|
}
|
|
|
|
$existing->each(function (WorldSubmission $submission, int $worldId) use ($selectedWorldIds): void {
|
|
if (in_array((string) $submission->status, [WorldSubmission::STATUS_LIVE, WorldSubmission::STATUS_REMOVED, WorldSubmission::STATUS_BLOCKED], true)) {
|
|
return;
|
|
}
|
|
|
|
if (! in_array($worldId, $selectedWorldIds, true)) {
|
|
$submission->delete();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
public function transition(WorldSubmission $submission, User $reviewer, string $status, ?string $reviewerNote = null): WorldSubmission
|
|
{
|
|
$payload = [
|
|
'status' => $status,
|
|
'reviewer_note' => $this->nullableText($reviewerNote),
|
|
'moderation_reason' => $this->nullableText($reviewerNote),
|
|
];
|
|
|
|
if ($status === WorldSubmission::STATUS_PENDING) {
|
|
$payload['reviewer_note'] = null;
|
|
$payload['moderation_reason'] = null;
|
|
$payload['reviewed_by_user_id'] = null;
|
|
$payload['reviewed_at'] = null;
|
|
$payload['removed_at'] = null;
|
|
$payload['blocked_at'] = null;
|
|
} else {
|
|
$payload['reviewed_by_user_id'] = (int) $reviewer->id;
|
|
$payload['reviewed_at'] = now();
|
|
$payload['removed_at'] = $status === WorldSubmission::STATUS_REMOVED ? now() : null;
|
|
$payload['blocked_at'] = $status === WorldSubmission::STATUS_BLOCKED ? now() : null;
|
|
}
|
|
|
|
if ($status !== WorldSubmission::STATUS_LIVE) {
|
|
$payload['is_featured'] = false;
|
|
$payload['featured_at'] = null;
|
|
}
|
|
|
|
$submission->forceFill($payload)->save();
|
|
|
|
$eventType = self::lifecycleEventForStatus($status);
|
|
if ($eventType !== null) {
|
|
$this->analytics->recordSubmissionLifecycle($submission, $eventType, $reviewer);
|
|
}
|
|
|
|
$this->rewards->syncAutomaticRewardsForSubmission($submission);
|
|
|
|
return $submission->fresh(['artwork.user.profile', 'artwork.stats', 'artwork.categories', 'submittedBy.profile', 'reviewer.profile']);
|
|
}
|
|
|
|
public function setFeatured(WorldSubmission $submission, User $reviewer, bool $featured, ?string $reviewerNote = null): WorldSubmission
|
|
{
|
|
$payload = [
|
|
'is_featured' => $featured,
|
|
'featured_at' => $featured ? now() : null,
|
|
'reviewed_by_user_id' => (int) $reviewer->id,
|
|
'reviewed_at' => now(),
|
|
];
|
|
|
|
if ($reviewerNote !== null) {
|
|
$payload['reviewer_note'] = $this->nullableText($reviewerNote);
|
|
$payload['moderation_reason'] = $this->nullableText($reviewerNote);
|
|
}
|
|
|
|
if ((string) $submission->status !== WorldSubmission::STATUS_LIVE) {
|
|
$payload['status'] = WorldSubmission::STATUS_LIVE;
|
|
$payload['removed_at'] = null;
|
|
$payload['blocked_at'] = null;
|
|
}
|
|
|
|
$submission->forceFill($payload)->save();
|
|
|
|
if ($featured) {
|
|
$this->analytics->recordSubmissionLifecycle($submission, WorldAnalyticsService::EVENT_SUBMISSION_FEATURED, $reviewer);
|
|
}
|
|
|
|
$this->rewards->syncAutomaticRewardsForSubmission($submission);
|
|
|
|
return $submission->fresh(['artwork.user.profile', 'artwork.stats', 'artwork.categories', 'submittedBy.profile', 'reviewer.profile']);
|
|
}
|
|
|
|
public function studioReviewQueue(World $world): array
|
|
{
|
|
$world->loadMissing([
|
|
'worldSubmissions.artwork.user.profile',
|
|
'worldSubmissions.artwork.stats',
|
|
'worldSubmissions.artwork.categories',
|
|
'worldSubmissions.submittedBy.profile',
|
|
'worldSubmissions.reviewer.profile',
|
|
]);
|
|
|
|
$rewardMap = $this->rewards->creatorRewardMapForWorld($world);
|
|
|
|
$items = $world->worldSubmissions
|
|
->sortBy([
|
|
fn (WorldSubmission $submission): int => match ((string) $submission->status) {
|
|
WorldSubmission::STATUS_PENDING => 0,
|
|
WorldSubmission::STATUS_LIVE => $submission->is_featured ? 1 : 2,
|
|
WorldSubmission::STATUS_REMOVED => 3,
|
|
WorldSubmission::STATUS_BLOCKED => 4,
|
|
default => 4,
|
|
},
|
|
fn (WorldSubmission $submission): int => -1 * ($submission->reviewed_at?->getTimestamp() ?? $submission->created_at?->getTimestamp() ?? 0),
|
|
])
|
|
->values();
|
|
|
|
return [
|
|
'counts' => [
|
|
'pending' => $items->where('status', WorldSubmission::STATUS_PENDING)->count(),
|
|
'live' => $items->where('status', WorldSubmission::STATUS_LIVE)->count(),
|
|
'removed' => $items->where('status', WorldSubmission::STATUS_REMOVED)->count(),
|
|
'blocked' => $items->where('status', WorldSubmission::STATUS_BLOCKED)->count(),
|
|
'featured' => $items->where('is_featured', true)->count(),
|
|
],
|
|
'items' => $items->map(fn (WorldSubmission $submission): array => $this->mapStudioSubmission($submission, $rewardMap))->all(),
|
|
];
|
|
}
|
|
|
|
public function publicSectionPayload(World $world, ?User $viewer = null): ?array
|
|
{
|
|
if (! $world->community_section_enabled) {
|
|
return null;
|
|
}
|
|
|
|
$query = Artwork::query()
|
|
->select('artworks.*', 'world_submissions.status as world_submission_status', 'world_submissions.is_featured as world_submission_is_featured', 'world_submissions.note as world_submission_note', 'world_submissions.reviewed_at as world_submission_reviewed_at')
|
|
->join('world_submissions', function ($join) use ($world): void {
|
|
$join->on('world_submissions.artwork_id', '=', 'artworks.id')
|
|
->where('world_submissions.world_id', '=', $world->id)
|
|
->where('world_submissions.status', '=', WorldSubmission::STATUS_LIVE);
|
|
})
|
|
->with(['user.profile', 'categories.contentType', 'stats'])
|
|
->catalogVisible();
|
|
|
|
$this->maturity->applyViewerFilter($query, $viewer);
|
|
|
|
$items = $query
|
|
->orderByRaw('CASE WHEN world_submissions.is_featured = 1 THEN 0 ELSE 1 END')
|
|
->orderByDesc('world_submissions.reviewed_at')
|
|
->limit(24)
|
|
->get()
|
|
->map(fn (Artwork $artwork): array => $this->mapPublicSubmissionArtwork($artwork))
|
|
->all();
|
|
|
|
if ($items === []) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'title' => 'Community submissions',
|
|
'description' => 'Artworks submitted by creators and selected for this world outside the editorial curated-relation system.',
|
|
'items' => $items,
|
|
];
|
|
}
|
|
|
|
private function eligibleWorldsQuery(): Builder
|
|
{
|
|
return World::query()
|
|
->published()
|
|
->where('accepts_submissions', true)
|
|
->whereIn('participation_mode', [World::PARTICIPATION_MODE_MANUAL_APPROVAL, World::PARTICIPATION_MODE_AUTO_ADD])
|
|
->where(function (Builder $builder): void {
|
|
$builder->whereNull('submission_starts_at')
|
|
->orWhere('submission_starts_at', '<=', now());
|
|
})
|
|
->where(function (Builder $builder): void {
|
|
$builder->whereNull('submission_ends_at')
|
|
->orWhere('submission_ends_at', '>=', now());
|
|
})
|
|
->orderByDesc('is_active_campaign')
|
|
->orderByDesc('is_homepage_featured')
|
|
->orderByRaw('COALESCE(campaign_priority, 0) DESC')
|
|
->orderByRaw('COALESCE(promotion_ends_at, submission_ends_at, ends_at) ASC')
|
|
->orderByRaw('COALESCE(promotion_starts_at, starts_at, submission_starts_at) ASC')
|
|
->orderBy('title');
|
|
}
|
|
|
|
private static function lifecycleEventForStatus(string $status): ?string
|
|
{
|
|
return match ($status) {
|
|
WorldSubmission::STATUS_LIVE => WorldAnalyticsService::EVENT_SUBMISSION_APPROVED,
|
|
WorldSubmission::STATUS_REMOVED => WorldAnalyticsService::EVENT_SUBMISSION_REMOVED,
|
|
WorldSubmission::STATUS_BLOCKED => WorldAnalyticsService::EVENT_SUBMISSION_BLOCKED,
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
private function eligibleWorlds()
|
|
{
|
|
return $this->eligibleWorldsQuery()
|
|
->get()
|
|
->filter(fn (World $world): bool => $this->isCanonicalRecurringEdition($world))
|
|
->values();
|
|
}
|
|
|
|
private function isEligibleWorld(World $world): bool
|
|
{
|
|
return $world->isAcceptingSubmissions() && $this->isCanonicalRecurringEdition($world);
|
|
}
|
|
|
|
private function isCanonicalRecurringEdition(World $world): bool
|
|
{
|
|
$recurrenceKey = trim((string) ($world->recurrence_key ?? ''));
|
|
|
|
if (! $world->is_recurring || $recurrenceKey === '') {
|
|
return true;
|
|
}
|
|
|
|
if (! array_key_exists($recurrenceKey, $this->canonicalRecurringEligibilityIds)) {
|
|
$canonical = $this->eligibleWorldsQuery()
|
|
->where('recurrence_key', $recurrenceKey)
|
|
->get()
|
|
->sortBy([
|
|
fn (World $edition): int => $edition->isActiveCampaign() ? 0 : ($edition->isUpcomingCampaign() ? 1 : 2),
|
|
fn (World $edition): int => -1 * (int) ($edition->campaign_priority ?? 0),
|
|
fn (World $edition): int => -1 * (int) ($edition->edition_year ?? 0),
|
|
fn (World $edition): int => -1 * ($edition->starts_at?->getTimestamp() ?? $edition->published_at?->getTimestamp() ?? 0),
|
|
fn (World $edition): int => -1 * (int) $edition->id,
|
|
])
|
|
->first();
|
|
|
|
$this->canonicalRecurringEligibilityIds[$recurrenceKey] = $canonical?->id;
|
|
}
|
|
|
|
return (int) ($this->canonicalRecurringEligibilityIds[$recurrenceKey] ?? 0) === (int) $world->id;
|
|
}
|
|
|
|
private function mapCreatorWorldOption(World $world, ?WorldSubmission $submission, bool $eligible): array
|
|
{
|
|
$status = $submission ? (string) $submission->status : null;
|
|
$selected = match ($status) {
|
|
WorldSubmission::STATUS_PENDING,
|
|
WorldSubmission::STATUS_LIVE => true,
|
|
default => false,
|
|
};
|
|
|
|
$locked = match ($status) {
|
|
WorldSubmission::STATUS_BLOCKED => true,
|
|
WorldSubmission::STATUS_PENDING => ! $eligible,
|
|
WorldSubmission::STATUS_REMOVED => ! $eligible || ! (bool) $world->allow_readd_after_removal,
|
|
default => false,
|
|
};
|
|
|
|
$lockedReason = $locked
|
|
? match ($status) {
|
|
WorldSubmission::STATUS_BLOCKED => 'This artwork is blocked from this world until a moderator clears the block.',
|
|
WorldSubmission::STATUS_PENDING => 'This world is no longer accepting submission changes right now.',
|
|
WorldSubmission::STATUS_REMOVED => (bool) $world->allow_readd_after_removal
|
|
? 'This world is not currently open for re-adding removed artworks.'
|
|
: 'Removed artworks cannot be re-added to this world right now.',
|
|
default => 'This world is locked.',
|
|
}
|
|
: null;
|
|
|
|
return [
|
|
'id' => (int) $world->id,
|
|
'title' => (string) $world->title,
|
|
'teaser_title' => $world->teaserTitle(),
|
|
'slug' => (string) $world->slug,
|
|
'tagline' => (string) ($world->tagline ?? ''),
|
|
'summary' => (string) ($world->summary ?? ''),
|
|
'teaser_summary' => (string) ($world->teaserSummary() ?? ''),
|
|
'cover_url' => $world->coverUrl(),
|
|
'teaser_image_url' => $world->teaserImageUrl(),
|
|
'campaign_label' => (string) ($world->campaign_label ?? ''),
|
|
'timeframe_label' => $this->timeframeLabel($world),
|
|
'promotion_window_label' => $this->promotionWindowLabel($world),
|
|
'submission_window_label' => $this->submissionWindowLabel($world),
|
|
'submission_guidelines' => (string) ($world->submission_guidelines ?? ''),
|
|
'participation_mode' => (string) ($world->participation_mode ?: World::PARTICIPATION_MODE_CLOSED),
|
|
'participation_mode_label' => $this->participationModeLabel((string) ($world->participation_mode ?: World::PARTICIPATION_MODE_CLOSED)),
|
|
'submission_note_enabled' => (bool) $world->submission_note_enabled,
|
|
'is_accepting_submissions' => $eligible,
|
|
'is_active_campaign' => (bool) $world->is_active_campaign,
|
|
'is_homepage_featured' => (bool) $world->is_homepage_featured,
|
|
'campaign_priority' => $world->campaign_priority,
|
|
'campaign_state_label' => $this->campaignStateLabel($world),
|
|
'status_badges' => $this->campaignBadges($world),
|
|
'selected' => $selected,
|
|
'selection_locked' => $locked,
|
|
'selection_locked_reason' => $lockedReason,
|
|
'note' => (string) ($submission?->note ?? ''),
|
|
'status' => $status,
|
|
'status_label' => $status ? $this->statusLabel($status, (bool) ($submission?->is_featured ?? false)) : null,
|
|
'reviewer_note' => (string) ($submission?->moderation_reason ?: $submission?->reviewer_note ?? ''),
|
|
'is_featured' => (bool) ($submission?->is_featured ?? false),
|
|
'submitted_at' => $submission?->created_at?->toIso8601String(),
|
|
'reviewed_at' => $submission?->reviewed_at?->toIso8601String(),
|
|
'can_resubmit' => $eligible && (bool) $world->allow_readd_after_removal && $status === WorldSubmission::STATUS_REMOVED,
|
|
];
|
|
}
|
|
|
|
private function campaignStateLabel(World $world): string
|
|
{
|
|
if ($world->isActiveCampaign()) {
|
|
return 'Live now';
|
|
}
|
|
|
|
if ($world->isUpcomingCampaign() || ($world->starts_at && $world->starts_at->isFuture())) {
|
|
return 'Upcoming';
|
|
}
|
|
|
|
if ((string) $world->status === World::STATUS_ARCHIVED || ($world->ends_at && $world->ends_at->isPast())) {
|
|
return 'Archived';
|
|
}
|
|
|
|
return 'Open';
|
|
}
|
|
|
|
private function campaignBadges(World $world): array
|
|
{
|
|
$badges = [];
|
|
|
|
if ($world->isActiveCampaign()) {
|
|
$badges[] = ['label' => 'Live now', 'tone' => 'emerald'];
|
|
} elseif ($world->isUpcomingCampaign() || ($world->starts_at && $world->starts_at->isFuture())) {
|
|
$badges[] = ['label' => 'Upcoming', 'tone' => 'sky'];
|
|
}
|
|
|
|
if ($world->isEndingSoon()) {
|
|
$badges[] = ['label' => 'Ending soon', 'tone' => 'amber'];
|
|
}
|
|
|
|
if ((bool) $world->is_homepage_featured || (bool) $world->is_featured) {
|
|
$badges[] = ['label' => 'Featured', 'tone' => 'rose'];
|
|
}
|
|
|
|
return $badges;
|
|
}
|
|
|
|
private function promotionWindowLabel(World $world): ?string
|
|
{
|
|
if (! $world->promotion_starts_at && ! $world->promotion_ends_at) {
|
|
return null;
|
|
}
|
|
|
|
if ($world->promotion_starts_at && $world->promotion_ends_at) {
|
|
return 'Promotion ' . $world->promotion_starts_at->format('d M Y') . ' - ' . $world->promotion_ends_at->format('d M Y');
|
|
}
|
|
|
|
if ($world->promotion_starts_at) {
|
|
return 'Promotion starts ' . $world->promotion_starts_at->format('d M Y');
|
|
}
|
|
|
|
return 'Promoted through ' . $world->promotion_ends_at?->format('d M Y');
|
|
}
|
|
|
|
private function mapStudioSubmission(WorldSubmission $submission, \Illuminate\Support\Collection $rewardMap): array
|
|
{
|
|
$artwork = $submission->artwork;
|
|
$views = (int) ($artwork?->stats?->views ?? 0);
|
|
$creatorId = (int) ($artwork?->user?->id ?? 0);
|
|
|
|
return [
|
|
'id' => (int) $submission->id,
|
|
'status' => (string) $submission->status,
|
|
'status_label' => $this->statusLabel((string) $submission->status, (bool) $submission->is_featured),
|
|
'can_grant_manual_rewards' => (string) $submission->status === WorldSubmission::STATUS_LIVE,
|
|
'is_featured' => (bool) $submission->is_featured,
|
|
'note' => (string) ($submission->note ?? ''),
|
|
'reviewer_note' => (string) ($submission->moderation_reason ?: $submission->reviewer_note ?? ''),
|
|
'submitted_at' => $submission->created_at?->toIso8601String(),
|
|
'reviewed_at' => $submission->reviewed_at?->toIso8601String(),
|
|
'removed_at' => $submission->removed_at?->toIso8601String(),
|
|
'blocked_at' => $submission->blocked_at?->toIso8601String(),
|
|
'featured_at' => $submission->featured_at?->toIso8601String(),
|
|
'submitted_by' => $submission->submittedBy ? [
|
|
'id' => (int) $submission->submittedBy->id,
|
|
'name' => (string) ($submission->submittedBy->name ?: $submission->submittedBy->username ?: 'Unknown creator'),
|
|
'username' => (string) ($submission->submittedBy->username ?? ''),
|
|
] : null,
|
|
'reviewed_by' => $submission->reviewer ? [
|
|
'id' => (int) $submission->reviewer->id,
|
|
'name' => (string) ($submission->reviewer->name ?: $submission->reviewer->username ?: 'Moderator'),
|
|
] : null,
|
|
'artwork' => $artwork ? [
|
|
'id' => (int) $artwork->id,
|
|
'title' => (string) ($artwork->title ?: 'Untitled artwork'),
|
|
'slug' => (string) ($artwork->slug ?? ''),
|
|
'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: Str::slug((string) $artwork->title)]),
|
|
'edit_url' => route('studio.artworks.edit', ['id' => $artwork->id]),
|
|
'thumbnail_url' => $artwork->thumbUrl('md'),
|
|
'creator_name' => (string) ($artwork->user?->name ?: $artwork->user?->username ?: ''),
|
|
'meta' => array_values(array_filter([
|
|
$artwork->categories->first()?->name,
|
|
$views > 0 ? number_format($views) . ' views' : null,
|
|
$artwork->visibility ? Str::headline((string) $artwork->visibility) : null,
|
|
])),
|
|
] : null,
|
|
'world_rewards' => $creatorId > 0 ? ($rewardMap->get($creatorId) ?? []) : [],
|
|
'actions' => [
|
|
'approve' => route('studio.worlds.submissions.approve', ['world' => $submission->world_id, 'submission' => $submission->id]),
|
|
'remove' => route('studio.worlds.submissions.remove', ['world' => $submission->world_id, 'submission' => $submission->id]),
|
|
'block' => route('studio.worlds.submissions.block', ['world' => $submission->world_id, 'submission' => $submission->id]),
|
|
'unblock' => route('studio.worlds.submissions.unblock', ['world' => $submission->world_id, 'submission' => $submission->id]),
|
|
'restore' => route('studio.worlds.submissions.restore', ['world' => $submission->world_id, 'submission' => $submission->id]),
|
|
'feature' => route('studio.worlds.submissions.feature', ['world' => $submission->world_id, 'submission' => $submission->id]),
|
|
'unfeature' => route('studio.worlds.submissions.unfeature', ['world' => $submission->world_id, 'submission' => $submission->id]),
|
|
'pending' => route('studio.worlds.submissions.pending', ['world' => $submission->world_id, 'submission' => $submission->id]),
|
|
'grant_rewards' => [
|
|
WorldRewardType::Winner->value => route('studio.worlds.submissions.rewards.grant', ['world' => $submission->world_id, 'submission' => $submission->id, 'rewardType' => WorldRewardType::Winner->value]),
|
|
WorldRewardType::Finalist->value => route('studio.worlds.submissions.rewards.grant', ['world' => $submission->world_id, 'submission' => $submission->id, 'rewardType' => WorldRewardType::Finalist->value]),
|
|
WorldRewardType::Spotlight->value => route('studio.worlds.submissions.rewards.grant', ['world' => $submission->world_id, 'submission' => $submission->id, 'rewardType' => WorldRewardType::Spotlight->value]),
|
|
],
|
|
'revoke_rewards' => [
|
|
WorldRewardType::Winner->value => route('studio.worlds.submissions.rewards.revoke', ['world' => $submission->world_id, 'submission' => $submission->id, 'rewardType' => WorldRewardType::Winner->value]),
|
|
WorldRewardType::Finalist->value => route('studio.worlds.submissions.rewards.revoke', ['world' => $submission->world_id, 'submission' => $submission->id, 'rewardType' => WorldRewardType::Finalist->value]),
|
|
WorldRewardType::Spotlight->value => route('studio.worlds.submissions.rewards.revoke', ['world' => $submission->world_id, 'submission' => $submission->id, 'rewardType' => WorldRewardType::Spotlight->value]),
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
private function mapPublicSubmissionArtwork(Artwork $artwork): array
|
|
{
|
|
$resource = ArtworkListResource::make($artwork)->toArray(request());
|
|
$views = (int) ($artwork->stats?->views ?? 0);
|
|
$status = (string) ($artwork->world_submission_status ?? WorldSubmission::STATUS_LIVE);
|
|
$isFeatured = (bool) ($artwork->world_submission_is_featured ?? false);
|
|
|
|
return [
|
|
'id' => (int) $artwork->id,
|
|
'title' => (string) ($resource['title'] ?? $artwork->title ?? 'Untitled artwork'),
|
|
'subtitle' => (string) ($resource['author']['name'] ?? ''),
|
|
'description' => Str::limit(trim(strip_tags((string) ($artwork->description ?? ''))), 120),
|
|
'url' => (string) ($resource['urls']['canonical'] ?? route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: Str::slug((string) $artwork->title)])),
|
|
'image' => $resource['thumbnail_url'] ?? $artwork->thumbUrl('md'),
|
|
'status' => $status,
|
|
'status_label' => $this->statusLabel($status, $isFeatured),
|
|
'context_label' => $isFeatured ? 'Community featured' : 'Community submission',
|
|
'meta' => array_values(array_filter([
|
|
$resource['category']['name'] ?? null,
|
|
$views > 0 ? number_format($views) . ' views' : null,
|
|
])),
|
|
];
|
|
}
|
|
|
|
private function statusLabel(string $status, bool $isFeatured = false): string
|
|
{
|
|
if ($status === WorldSubmission::STATUS_LIVE && $isFeatured) {
|
|
return 'Featured';
|
|
}
|
|
|
|
return match ($status) {
|
|
WorldSubmission::STATUS_PENDING => 'Pending',
|
|
WorldSubmission::STATUS_LIVE => 'Live',
|
|
WorldSubmission::STATUS_REMOVED => 'Removed',
|
|
WorldSubmission::STATUS_BLOCKED => 'Blocked',
|
|
default => Str::headline($status),
|
|
};
|
|
}
|
|
|
|
private function participationModeLabel(string $mode): string
|
|
{
|
|
return match ($mode) {
|
|
World::PARTICIPATION_MODE_MANUAL_APPROVAL => 'Manual approval',
|
|
World::PARTICIPATION_MODE_AUTO_ADD => 'Auto add',
|
|
World::PARTICIPATION_MODE_CLOSED => 'Closed',
|
|
default => Str::headline($mode),
|
|
};
|
|
}
|
|
|
|
private function timeframeLabel(World $world): string
|
|
{
|
|
if ($world->starts_at && $world->ends_at) {
|
|
return $world->starts_at->format('M j') . ' - ' . $world->ends_at->format('M j, Y');
|
|
}
|
|
|
|
if ($world->starts_at) {
|
|
return 'Starts ' . $world->starts_at->format('M j, Y');
|
|
}
|
|
|
|
if ($world->ends_at) {
|
|
return 'Until ' . $world->ends_at->format('M j, Y');
|
|
}
|
|
|
|
return 'Open-ended world';
|
|
}
|
|
|
|
private function submissionWindowLabel(World $world): string
|
|
{
|
|
$start = $world->submission_starts_at;
|
|
$end = $world->submission_ends_at;
|
|
|
|
if ($start && $end) {
|
|
return $start->format('M j') . ' - ' . $end->format('M j, Y');
|
|
}
|
|
|
|
if ($start) {
|
|
return 'Opens ' . $start->format('M j, Y');
|
|
}
|
|
|
|
if ($end) {
|
|
return 'Open until ' . $end->format('M j, Y');
|
|
}
|
|
|
|
return 'Open submissions';
|
|
}
|
|
|
|
private function nullableText(?string $value): ?string
|
|
{
|
|
$value = trim((string) $value);
|
|
|
|
return $value !== '' ? $value : null;
|
|
}
|
|
} |