3343 lines
149 KiB
PHP
3343 lines
149 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Worlds;
|
|
|
|
use App\Http\Resources\ArtworkListResource;
|
|
use App\Models\Artwork;
|
|
use App\Models\Collection;
|
|
use App\Models\Group;
|
|
use App\Models\GroupChallenge;
|
|
use App\Models\GroupChallengeOutcome;
|
|
use App\Models\GroupEvent;
|
|
use App\Models\GroupRelease;
|
|
use App\Models\NovaCard;
|
|
use App\Models\User;
|
|
use App\Models\World;
|
|
use App\Models\WorldRelation;
|
|
use App\Models\WorldSubmission;
|
|
use App\Services\CollectionService;
|
|
use App\Services\GroupCardService;
|
|
use App\Services\Maturity\ArtworkMaturityService;
|
|
use App\Services\Worlds\WorldAnalyticsService;
|
|
use App\Support\AvatarUrl;
|
|
use App\Support\CoverUrl;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Collection as SupportCollection;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
use cPad\Plugins\News\Models\NewsArticle;
|
|
|
|
final class WorldService
|
|
{
|
|
public const COPY_MODE_STRUCTURE_ONLY = 'structure_only';
|
|
|
|
public const COPY_MODE_WITH_RELATIONS = 'with_relations';
|
|
|
|
private array $recurrenceEditionCache = [];
|
|
|
|
private array $recurrenceCanonicalCache = [];
|
|
|
|
public function __construct(
|
|
private readonly CollectionService $collections,
|
|
private readonly GroupCardService $groups,
|
|
private readonly WorldSubmissionService $submissions,
|
|
private readonly WorldRewardService $rewards,
|
|
private readonly WorldAnalyticsService $analytics,
|
|
private readonly ArtworkMaturityService $maturity,
|
|
) {
|
|
}
|
|
|
|
public function relationTypeOptions(): array
|
|
{
|
|
return collect((array) config('worlds.relation_types', []))
|
|
->map(fn (string $label, string $value): array => ['value' => $value, 'label' => $label])
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
public function sectionOptions(): array
|
|
{
|
|
return collect((array) config('worlds.sections', []))
|
|
->map(fn (array $config, string $key): array => [
|
|
'value' => $key,
|
|
'label' => (string) ($config['label'] ?? Str::headline($key)),
|
|
'description' => (string) ($config['description'] ?? ''),
|
|
'relation_types' => array_values((array) ($config['relation_types'] ?? [])),
|
|
])
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
public function themeOptions(): array
|
|
{
|
|
return collect((array) config('worlds.themes', []))
|
|
->map(fn (array $theme, string $key): array => [
|
|
'value' => $key,
|
|
'label' => (string) ($theme['label'] ?? Str::headline($key)),
|
|
'accent_color' => $theme['accent_color'] ?? null,
|
|
'accent_color_secondary' => $theme['accent_color_secondary'] ?? null,
|
|
'background_motif' => $theme['background_motif'] ?? null,
|
|
'icon_name' => $theme['icon_name'] ?? null,
|
|
'related_tags_json' => array_values(array_map('strval', (array) ($theme['related_tags_json'] ?? []))),
|
|
'suggested_badge_label' => (string) ($theme['suggested_badge_label'] ?? ''),
|
|
'suggested_cta_label' => (string) ($theme['suggested_cta_label'] ?? ''),
|
|
])
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
public function studioListing(array $filters = []): array
|
|
{
|
|
$query = World::query()
|
|
->withCount([
|
|
'worldRelations',
|
|
'worldSubmissions as live_submission_count' => fn (Builder $builder): Builder => $builder->where('status', WorldSubmission::STATUS_LIVE),
|
|
])
|
|
->orderByDesc('is_active_campaign')
|
|
->orderByDesc('is_homepage_featured')
|
|
->orderByRaw('COALESCE(campaign_priority, 0) DESC')
|
|
->orderByDesc('is_featured')
|
|
->orderByRaw("CASE WHEN status = 'published' THEN 0 WHEN status = 'draft' THEN 1 ELSE 2 END")
|
|
->orderByRaw('COALESCE(promotion_starts_at, starts_at) ASC')
|
|
->orderByDesc('published_at');
|
|
|
|
$search = trim((string) ($filters['q'] ?? ''));
|
|
$status = trim((string) ($filters['status'] ?? ''));
|
|
$type = trim((string) ($filters['type'] ?? ''));
|
|
$perPage = max(10, min(50, (int) ($filters['per_page'] ?? 15)));
|
|
|
|
if ($search !== '') {
|
|
$query->where(function (Builder $builder) use ($search): void {
|
|
$builder->where('title', 'like', '%' . $search . '%')
|
|
->orWhere('slug', 'like', '%' . $search . '%')
|
|
->orWhere('summary', 'like', '%' . $search . '%')
|
|
->orWhere('description', 'like', '%' . $search . '%');
|
|
});
|
|
}
|
|
|
|
if ($status !== '') {
|
|
$query->where('status', $status);
|
|
}
|
|
|
|
if ($type !== '') {
|
|
$query->where('type', $type);
|
|
}
|
|
|
|
$paginator = $query->paginate($perPage)->withQueryString();
|
|
|
|
return [
|
|
'items' => $paginator->getCollection()->map(fn (World $world): array => $this->mapStudioListItem($world))->all(),
|
|
'meta' => $this->paginationMeta($paginator),
|
|
'filters' => [
|
|
'q' => $search,
|
|
'status' => $status,
|
|
'type' => $type,
|
|
'per_page' => $perPage,
|
|
],
|
|
];
|
|
}
|
|
|
|
public function mapStudioWorld(World $world, ?User $viewer = null): array
|
|
{
|
|
$world->loadMissing(['createdBy.profile', 'parentWorld', 'worldRelations', 'linkedChallenge.group', 'linkedChallenge.outcomes', 'recapArticle.author.profile', 'recapArticle.category']);
|
|
|
|
$canonicalEdition = $this->canonicalEditionForWorld($world);
|
|
$previousEdition = $this->adjacentEditionForWorld($world, 'previous');
|
|
$nextEdition = $this->adjacentEditionForWorld($world, 'next');
|
|
$familyEditions = $this->familyEditionsForWorld($world);
|
|
|
|
return [
|
|
'id' => (int) $world->id,
|
|
'title' => (string) $world->title,
|
|
'slug' => (string) $world->slug,
|
|
'tagline' => (string) ($world->tagline ?? ''),
|
|
'summary' => (string) ($world->summary ?? ''),
|
|
'teaser_title' => (string) ($world->teaser_title ?? ''),
|
|
'teaser_summary' => (string) ($world->teaser_summary ?? ''),
|
|
'description' => (string) ($world->description ?? ''),
|
|
'cover_path' => (string) ($world->cover_path ?? ''),
|
|
'cover_url' => $world->coverUrl(),
|
|
'teaser_image_path' => (string) ($world->teaser_image_path ?? ''),
|
|
'teaser_image_url' => $world->teaserImageUrl(),
|
|
'theme_key' => (string) ($world->theme_key ?? ''),
|
|
'theme' => $this->themePayload($world),
|
|
'accent_color' => (string) ($world->accent_color ?? ''),
|
|
'accent_color_secondary' => (string) ($world->accent_color_secondary ?? ''),
|
|
'background_motif' => (string) ($world->background_motif ?? ''),
|
|
'icon_name' => $this->resolvedIconName($world),
|
|
'status' => (string) $world->status,
|
|
'type' => (string) $world->type,
|
|
'starts_at' => optional($world->starts_at)?->toIso8601String(),
|
|
'ends_at' => optional($world->ends_at)?->toIso8601String(),
|
|
'promotion_starts_at' => optional($world->promotion_starts_at)?->toIso8601String(),
|
|
'promotion_ends_at' => optional($world->promotion_ends_at)?->toIso8601String(),
|
|
'accepts_submissions' => (bool) $world->accepts_submissions,
|
|
'participation_mode' => (string) ($world->participation_mode ?: World::PARTICIPATION_MODE_CLOSED),
|
|
'submission_starts_at' => optional($world->submission_starts_at)?->toIso8601String(),
|
|
'submission_ends_at' => optional($world->submission_ends_at)?->toIso8601String(),
|
|
'submission_note_enabled' => (bool) $world->submission_note_enabled,
|
|
'community_section_enabled' => (bool) $world->community_section_enabled,
|
|
'allow_readd_after_removal' => (bool) $world->allow_readd_after_removal,
|
|
'is_featured' => (bool) $world->is_featured,
|
|
'is_active_campaign' => (bool) $world->is_active_campaign,
|
|
'is_homepage_featured' => (bool) $world->is_homepage_featured,
|
|
'campaign_priority' => $world->campaign_priority,
|
|
'is_recurring' => (bool) $world->is_recurring,
|
|
'recurrence_key' => (string) ($world->recurrence_key ?? ''),
|
|
'recurrence_rule' => (string) ($world->recurrence_rule ?? ''),
|
|
'edition_year' => $world->edition_year,
|
|
'family_title' => $this->recurrenceFamilyLabel($world),
|
|
'family_url' => $this->familyUrlForWorld($world),
|
|
'edition_url' => $this->editionUrlForWorld($world),
|
|
'is_canonical_edition' => $this->isCanonicalSurfaceWorld($world),
|
|
'family_edition_count' => $familyEditions->count(),
|
|
'archive_edition_count' => max(0, $familyEditions->count() - 1),
|
|
'current_edition' => $canonicalEdition ? $this->mapWorldCard($canonicalEdition, $this->phaseForWorld($canonicalEdition)) : null,
|
|
'previous_edition' => $previousEdition ? $this->mapWorldCard($previousEdition, $this->phaseForWorld($previousEdition)) : null,
|
|
'next_edition' => $nextEdition ? $this->mapWorldCard($nextEdition, $this->phaseForWorld($nextEdition)) : null,
|
|
'cta_label' => (string) ($world->cta_label ?? ''),
|
|
'cta_url' => (string) ($world->cta_url ?? ''),
|
|
'badge_label' => (string) ($world->badge_label ?? ''),
|
|
'campaign_label' => (string) ($world->campaign_label ?? ''),
|
|
'badge_description' => (string) ($world->badge_description ?? ''),
|
|
'submission_guidelines' => (string) ($world->submission_guidelines ?? ''),
|
|
'badge_url' => (string) ($world->badge_url ?? ''),
|
|
'seo_title' => (string) ($world->seo_title ?? ''),
|
|
'seo_description' => (string) ($world->seo_description ?? ''),
|
|
'og_image_path' => (string) ($world->og_image_path ?? ''),
|
|
'og_image_url' => $world->ogImageUrl(),
|
|
'published_at' => optional($world->published_at)?->toIso8601String(),
|
|
'recap_status' => (string) ($world->recap_status ?: World::RECAP_STATUS_DRAFT),
|
|
'recap_status_label' => $this->recapStatusLabel($world),
|
|
'recap_title' => (string) ($world->recap_title ?? ''),
|
|
'recap_summary' => (string) ($world->recap_summary ?? ''),
|
|
'recap_intro' => (string) ($world->recap_intro ?? ''),
|
|
'recap_editor_note' => (string) ($world->recap_editor_note ?? ''),
|
|
'recap_cover_path' => (string) ($world->recap_cover_path ?? ''),
|
|
'recap_cover_url' => $world->recapCoverUrl(),
|
|
'recap_article_id' => $world->recap_article_id ? (int) $world->recap_article_id : null,
|
|
'recap_article' => $world->recap_article_id ? $this->resolveNewsPreview((int) $world->recap_article_id, 'Recap article') : null,
|
|
'recap_published_at' => optional($world->recap_published_at)?->toIso8601String(),
|
|
'recap_stats_snapshot' => $world->recap_stats_snapshot_json,
|
|
'parent_world_id' => $world->parent_world_id ? (int) $world->parent_world_id : null,
|
|
'parent_world' => $world->parentWorld ? [
|
|
'id' => (int) $world->parentWorld->id,
|
|
'title' => (string) $world->parentWorld->title,
|
|
'slug' => (string) $world->parentWorld->slug,
|
|
] : null,
|
|
'linked_challenge_id' => $world->linked_challenge_id ? (int) $world->linked_challenge_id : null,
|
|
'linked_challenge' => $world->linked_challenge_id ? $this->resolveChallengePreview((int) $world->linked_challenge_id, $viewer, 'Primary challenge') : null,
|
|
'show_linked_challenge_section' => (bool) ($world->show_linked_challenge_section ?? true),
|
|
'show_linked_challenge_entries' => (bool) ($world->show_linked_challenge_entries ?? true),
|
|
'show_linked_challenge_winners' => (bool) ($world->show_linked_challenge_winners ?? true),
|
|
'show_linked_challenge_finalists' => (bool) ($world->show_linked_challenge_finalists ?? true),
|
|
'auto_grant_challenge_world_rewards' => (bool) ($world->auto_grant_challenge_world_rewards ?? true),
|
|
'challenge_teaser_override' => (string) ($world->challenge_teaser_override ?? ''),
|
|
'hidden_linked_challenge_artwork_ids_json' => $this->hiddenLinkedChallengeArtworkIds($world),
|
|
'related_tags_json' => array_values(array_map('strval', $world->related_tags_json ?? [])),
|
|
'section_order_json' => $world->sectionOrder(),
|
|
'section_visibility_json' => $world->sectionVisibility(),
|
|
'campaign_state' => $this->campaignStateKey($world),
|
|
'campaign_state_label' => $this->campaignStateLabel($world),
|
|
'promotion_window_label' => $this->promotionWindowLabel($world),
|
|
'status_badges' => $this->statusBadges($world, $this->preferredLinkedChallenge($world, $viewer)),
|
|
'relations' => $world->worldRelations
|
|
->values()
|
|
->map(fn (WorldRelation $relation): array => [
|
|
'id' => (int) $relation->id,
|
|
'section_key' => (string) $relation->section_key,
|
|
'related_type' => (string) $relation->related_type,
|
|
'related_id' => (int) $relation->related_id,
|
|
'context_label' => (string) ($relation->context_label ?? ''),
|
|
'sort_order' => (int) $relation->sort_order,
|
|
'is_featured' => (bool) $relation->is_featured,
|
|
'preview' => $this->resolveEntityPreview((string) $relation->related_type, (int) $relation->related_id, $viewer, (string) ($relation->context_label ?? '')),
|
|
])
|
|
->all(),
|
|
'created_by' => $world->createdBy ? [
|
|
'id' => (int) $world->createdBy->id,
|
|
'name' => (string) $world->createdBy->name,
|
|
'username' => (string) ($world->createdBy->username ?? ''),
|
|
] : null,
|
|
'submission_review_queue' => $this->submissions->studioReviewQueue($world),
|
|
'rewarded_contributors' => $this->rewards->rewardedContributorsForWorld($world),
|
|
'analytics' => $this->analytics->studioReport($world),
|
|
'urls' => [
|
|
'public' => $world->publicUrl(),
|
|
'edit' => route('studio.worlds.edit', ['world' => $world]),
|
|
'preview' => route('studio.worlds.preview', ['world' => $world]),
|
|
'publish' => route('studio.worlds.publish', ['world' => $world]),
|
|
'publish_recap' => route('studio.worlds.recap.publish', ['world' => $world]),
|
|
'archive' => route('studio.worlds.archive', ['world' => $world]),
|
|
'duplicate' => route('studio.worlds.duplicate', ['world' => $world]),
|
|
'new_edition' => route('studio.worlds.new-edition', ['world' => $world]),
|
|
],
|
|
];
|
|
}
|
|
|
|
public function store(User $editor, array $data): World
|
|
{
|
|
$world = new World();
|
|
|
|
return $this->persist($world, $editor, $data);
|
|
}
|
|
|
|
public function update(World $world, User $editor, array $data): World
|
|
{
|
|
return $this->persist($world, $editor, $data);
|
|
}
|
|
|
|
public function duplicate(World $source, User $editor, bool $asNewEdition = false): World
|
|
{
|
|
return $this->duplicateWithMode($source, $editor, $asNewEdition, self::COPY_MODE_WITH_RELATIONS);
|
|
}
|
|
|
|
public function duplicateWithMode(World $source, User $editor, bool $asNewEdition, string $copyMode): World
|
|
{
|
|
if ($asNewEdition && ! $this->canCreateNewEdition($source)) {
|
|
throw ValidationException::withMessages([
|
|
'recurrence_key' => 'Add recurrence data before creating a new edition.',
|
|
]);
|
|
}
|
|
|
|
$source->loadMissing('worldRelations');
|
|
|
|
$derivedRecurrenceKey = $this->inferredRecurrenceKey($source);
|
|
$copyRelations = $copyMode !== self::COPY_MODE_STRUCTURE_ONLY;
|
|
$preserveRecurrence = $asNewEdition;
|
|
|
|
$data = [
|
|
'title' => $asNewEdition ? $this->nextEditionTitle($source) : $this->duplicateTitle($source),
|
|
'slug' => $asNewEdition ? $this->nextEditionSlug($source) : $source->slug . '-copy',
|
|
'tagline' => $source->tagline,
|
|
'summary' => $source->summary,
|
|
'teaser_title' => $source->teaser_title,
|
|
'teaser_summary' => $source->teaser_summary,
|
|
'description' => $source->description,
|
|
'cover_path' => $source->cover_path,
|
|
'teaser_image_path' => $source->teaser_image_path,
|
|
'theme_key' => $source->theme_key,
|
|
'accent_color' => $source->accent_color,
|
|
'accent_color_secondary' => $source->accent_color_secondary,
|
|
'background_motif' => $source->background_motif,
|
|
'icon_name' => $source->icon_name,
|
|
'status' => World::STATUS_DRAFT,
|
|
'type' => $source->type,
|
|
'starts_at' => null,
|
|
'ends_at' => null,
|
|
'promotion_starts_at' => null,
|
|
'promotion_ends_at' => null,
|
|
'accepts_submissions' => (bool) $source->accepts_submissions,
|
|
'participation_mode' => (string) ($source->participation_mode ?: World::PARTICIPATION_MODE_CLOSED),
|
|
'submission_starts_at' => null,
|
|
'submission_ends_at' => null,
|
|
'submission_note_enabled' => (bool) $source->submission_note_enabled,
|
|
'community_section_enabled' => (bool) $source->community_section_enabled,
|
|
'allow_readd_after_removal' => (bool) $source->allow_readd_after_removal,
|
|
'published_at' => null,
|
|
'is_featured' => false,
|
|
'is_active_campaign' => false,
|
|
'is_homepage_featured' => false,
|
|
'campaign_priority' => null,
|
|
'is_recurring' => $preserveRecurrence,
|
|
'recurrence_key' => $preserveRecurrence ? $derivedRecurrenceKey : null,
|
|
'recurrence_rule' => $preserveRecurrence ? $source->recurrence_rule : null,
|
|
'edition_year' => $preserveRecurrence ? $this->nextEditionYear($source) : null,
|
|
'cta_label' => $source->cta_label,
|
|
'cta_url' => $asNewEdition ? null : $source->cta_url,
|
|
'badge_label' => $source->badge_label,
|
|
'campaign_label' => $source->campaign_label,
|
|
'badge_description' => $source->badge_description,
|
|
'submission_guidelines' => $source->submission_guidelines,
|
|
'badge_url' => $asNewEdition ? null : $source->badge_url,
|
|
'seo_title' => $source->seo_title,
|
|
'seo_description' => $source->seo_description,
|
|
'og_image_path' => $source->og_image_path,
|
|
'recap_status' => World::RECAP_STATUS_DRAFT,
|
|
'recap_title' => null,
|
|
'recap_summary' => null,
|
|
'recap_intro' => null,
|
|
'recap_editor_note' => null,
|
|
'recap_cover_path' => null,
|
|
'recap_article_id' => null,
|
|
'recap_stats_snapshot_json' => null,
|
|
'recap_published_at' => null,
|
|
'linked_challenge_id' => $asNewEdition ? null : $source->linked_challenge_id,
|
|
'show_linked_challenge_section' => (bool) ($source->show_linked_challenge_section ?? true),
|
|
'show_linked_challenge_entries' => (bool) ($source->show_linked_challenge_entries ?? true),
|
|
'show_linked_challenge_winners' => (bool) ($source->show_linked_challenge_winners ?? true),
|
|
'show_linked_challenge_finalists' => (bool) ($source->show_linked_challenge_finalists ?? true),
|
|
'auto_grant_challenge_world_rewards' => (bool) ($source->auto_grant_challenge_world_rewards ?? true),
|
|
'challenge_teaser_override' => $asNewEdition ? null : $source->challenge_teaser_override,
|
|
'hidden_linked_challenge_artwork_ids_json' => $asNewEdition ? [] : $this->hiddenLinkedChallengeArtworkIds($source),
|
|
'related_tags_json' => array_values(array_map('strval', $source->related_tags_json ?? [])),
|
|
'section_order_json' => $source->sectionOrder(),
|
|
'section_visibility_json' => $source->sectionVisibility(),
|
|
'parent_world_id' => $asNewEdition ? (int) ($source->parent_world_id ?: $source->id) : $source->parent_world_id,
|
|
'relations' => $copyRelations
|
|
? $source->worldRelations
|
|
->map(fn (WorldRelation $relation): array => [
|
|
'section_key' => (string) $relation->section_key,
|
|
'related_type' => (string) $relation->related_type,
|
|
'related_id' => (int) $relation->related_id,
|
|
'context_label' => (string) ($relation->context_label ?? ''),
|
|
'sort_order' => (int) $relation->sort_order,
|
|
'is_featured' => (bool) $relation->is_featured,
|
|
])
|
|
->all()
|
|
: [],
|
|
];
|
|
|
|
return $this->store($editor, $data);
|
|
}
|
|
|
|
public function duplicateModeOptions(bool $asNewEdition = false): array
|
|
{
|
|
return [
|
|
[
|
|
'value' => self::COPY_MODE_STRUCTURE_ONLY,
|
|
'label' => $asNewEdition ? 'Structure only' : 'Shell only',
|
|
'description' => $asNewEdition
|
|
? 'Carry the theme, layout, and recurrence family forward without copying curated relations.'
|
|
: 'Create a fresh draft shell with theme, layout, and metadata but no curated attachments.',
|
|
],
|
|
[
|
|
'value' => self::COPY_MODE_WITH_RELATIONS,
|
|
'label' => 'Include curated relations',
|
|
'description' => 'Copy the current curated relations as a starting point so the editorial structure is immediately reusable.',
|
|
],
|
|
];
|
|
}
|
|
|
|
public function canCreateNewEdition(World $world): bool
|
|
{
|
|
return (bool) ($world->is_recurring || $world->recurrence_key || $world->edition_year);
|
|
}
|
|
|
|
public function publish(World $world): World
|
|
{
|
|
$world->forceFill([
|
|
'status' => World::STATUS_PUBLISHED,
|
|
'published_at' => $world->published_at ?? now(),
|
|
])->save();
|
|
|
|
return $world->fresh(['createdBy.profile', 'parentWorld', 'worldRelations', 'linkedChallenge.group']);
|
|
}
|
|
|
|
public function publishRecap(World $world): World
|
|
{
|
|
if (! $world->isEndedEdition()) {
|
|
throw ValidationException::withMessages([
|
|
'recap_status' => 'Publish recap after the world has ended or been archived.',
|
|
]);
|
|
}
|
|
|
|
$world->forceFill([
|
|
'recap_status' => World::RECAP_STATUS_PUBLISHED,
|
|
'recap_published_at' => $world->recap_published_at ?? now(),
|
|
'recap_stats_snapshot_json' => $this->buildRecapStatsSnapshot($world),
|
|
'is_active_campaign' => false,
|
|
'is_homepage_featured' => false,
|
|
])->save();
|
|
|
|
return $world->fresh(['createdBy.profile', 'parentWorld', 'worldRelations', 'linkedChallenge.group', 'recapArticle.author.profile', 'recapArticle.category']);
|
|
}
|
|
|
|
public function archive(World $world): World
|
|
{
|
|
$world->forceFill([
|
|
'status' => World::STATUS_ARCHIVED,
|
|
'ends_at' => $world->ends_at ?? now(),
|
|
'is_active_campaign' => false,
|
|
'is_homepage_featured' => false,
|
|
])->save();
|
|
|
|
return $world->fresh(['createdBy.profile', 'parentWorld', 'worldRelations', 'linkedChallenge.group']);
|
|
}
|
|
|
|
public function searchEntities(string $type, string $query, ?User $viewer = null): array
|
|
{
|
|
$type = trim(Str::lower($type));
|
|
$query = trim($query);
|
|
|
|
return match ($type) {
|
|
WorldRelation::TYPE_ARTWORK => $this->searchArtworks($query),
|
|
WorldRelation::TYPE_COLLECTION => $this->searchCollections($query, $viewer),
|
|
WorldRelation::TYPE_USER => $this->searchUsers($query),
|
|
WorldRelation::TYPE_GROUP => $this->searchGroups($query, $viewer),
|
|
WorldRelation::TYPE_NEWS => $this->searchNews($query),
|
|
WorldRelation::TYPE_CHALLENGE => $this->searchChallenges($query, $viewer),
|
|
WorldRelation::TYPE_EVENT => $this->searchEvents($query, $viewer),
|
|
WorldRelation::TYPE_RELEASE => $this->searchReleases($query, $viewer),
|
|
WorldRelation::TYPE_CARD => $this->searchCards($query, $viewer),
|
|
default => [],
|
|
};
|
|
}
|
|
|
|
public function publicIndexPayload(?User $viewer = null): array
|
|
{
|
|
$spotlight = $this->primarySpotlightWorld();
|
|
|
|
$current = $this->filterCanonicalSurfaceWorlds(
|
|
$this->currentSurfaceQuery()
|
|
->limit(24)
|
|
->get()
|
|
)
|
|
->reject(fn (World $world): bool => $spotlight !== null && (int) $world->id === (int) $spotlight->id)
|
|
->take(12)
|
|
->values();
|
|
|
|
$upcoming = $this->filterCanonicalSurfaceWorlds(
|
|
$this->upcomingSurfaceQuery()
|
|
->limit(24)
|
|
->get()
|
|
)
|
|
->take(12)
|
|
->values();
|
|
|
|
$excludedIds = collect([$spotlight?->id])
|
|
->filter()
|
|
->merge($current->pluck('id'))
|
|
->merge($upcoming->pluck('id'))
|
|
->map(fn ($id): int => (int) $id)
|
|
->unique()
|
|
->values();
|
|
|
|
$featured = $this->filterCanonicalSurfaceWorlds(
|
|
$this->featuredSurfaceQuery($excludedIds->all())
|
|
->limit(24)
|
|
->get()
|
|
)
|
|
->take(9)
|
|
->values();
|
|
$archive = $this->archiveSurfaceQuery()->limit(18)->get();
|
|
$recurringFamilies = $this->recurringFamilyIndexPayload(8);
|
|
|
|
$spotlightPayload = $spotlight ? $this->mapWorldCard($spotlight, 'spotlight') : null;
|
|
|
|
return [
|
|
'title' => 'Worlds',
|
|
'description' => 'Seasonal, cultural, and editorial campaign spaces that bring together artworks, collections, creators, groups, news, challenges, and releases.',
|
|
'spotlightWorld' => $spotlightPayload,
|
|
'featuredWorld' => $spotlightPayload,
|
|
'activeWorlds' => $current->map(fn (World $world): array => $this->mapWorldCard($world, 'active'))->all(),
|
|
'upcomingWorlds' => $upcoming->map(fn (World $world): array => $this->mapWorldCard($world, 'upcoming'))->all(),
|
|
'featuredWorlds' => $featured->map(fn (World $world): array => $this->mapWorldCard($world, 'featured'))->all(),
|
|
'recurringWorldFamilies' => $recurringFamilies,
|
|
'archivedWorlds' => $archive->map(fn (World $world): array => $this->mapWorldCard($world, 'archive'))->all(),
|
|
'themeOptions' => $this->themeOptions(),
|
|
];
|
|
}
|
|
|
|
public function resolvePublicWorld(string $slug): array
|
|
{
|
|
$world = World::query()
|
|
->publiclyVisible()
|
|
->where('slug', $slug)
|
|
->first();
|
|
|
|
if ($world) {
|
|
$canonicalUrl = $this->canonicalPublicUrl($world);
|
|
|
|
return [
|
|
'world' => $world,
|
|
'redirect' => $canonicalUrl !== route('worlds.show', ['world' => $slug]) ? $canonicalUrl : null,
|
|
];
|
|
}
|
|
|
|
$world = $this->canonicalEditionForRecurrenceKey($slug);
|
|
|
|
return [
|
|
'world' => $world,
|
|
'redirect' => null,
|
|
];
|
|
}
|
|
|
|
public function resolvePublicEdition(string $slug, int $year): array
|
|
{
|
|
$world = $this->familyEditionsForRecurrenceKey($slug)
|
|
->first(fn (World $edition): bool => (int) ($edition->edition_year ?? 0) === $year)
|
|
?? World::query()
|
|
->publiclyVisible()
|
|
->where('slug', $slug)
|
|
->where('edition_year', $year)
|
|
->first();
|
|
|
|
if (! $world) {
|
|
return [
|
|
'world' => null,
|
|
'redirect' => null,
|
|
];
|
|
}
|
|
|
|
$canonicalUrl = $this->canonicalPublicUrl($world);
|
|
|
|
return [
|
|
'world' => $world,
|
|
'redirect' => $canonicalUrl !== route('worlds.editions.show', ['world' => $slug, 'year' => $year]) ? $canonicalUrl : null,
|
|
];
|
|
}
|
|
|
|
public function canonicalPublicUrl(World $world): string
|
|
{
|
|
return $this->publicUrlForWorld($world);
|
|
}
|
|
|
|
public function publicShowPayload(World $world, ?User $viewer = null, bool $includeDraftRecap = false): array
|
|
{
|
|
$world->loadMissing(['createdBy.profile', 'parentWorld', 'worldRelations', 'linkedChallenge.group', 'recapArticle.author.profile', 'recapArticle.category']);
|
|
|
|
$sections = $this->resolveSections($world, $viewer);
|
|
$familyEditions = $this->familyEditionsForWorld($world);
|
|
$canonicalEdition = $this->canonicalEditionForWorld($world);
|
|
$previousEdition = $this->adjacentEditionForWorld($world, 'previous');
|
|
$nextEdition = $this->adjacentEditionForWorld($world, 'next');
|
|
$linkedChallenge = $this->preferredLinkedChallenge($world, $viewer);
|
|
$linkedChallengePanel = $this->linkedChallengePanelPayload($world, $viewer, $linkedChallenge);
|
|
$linkedChallengeEntries = $linkedChallenge ? $this->linkedChallengeEntriesPayload($world, $linkedChallenge, $viewer) : null;
|
|
$linkedChallengeWinners = $linkedChallenge ? $this->linkedChallengeWinnersPayload($world, $linkedChallenge, $viewer) : null;
|
|
$linkedChallengeFinalists = $linkedChallenge ? $this->linkedChallengeFinalistsPayload($world, $linkedChallenge, $viewer) : null;
|
|
$communitySubmissions = $this->submissions->publicSectionPayload($world, $viewer);
|
|
$rewardedContributors = $this->rewards->rewardedContributorsForWorld($world);
|
|
$recap = $this->recapPayload(
|
|
$world,
|
|
$sections,
|
|
$communitySubmissions,
|
|
$rewardedContributors,
|
|
$linkedChallengePanel,
|
|
$linkedChallengeWinners,
|
|
$linkedChallengeFinalists,
|
|
$includeDraftRecap,
|
|
);
|
|
|
|
if ($recap) {
|
|
$sections = $this->sectionsAfterRecapExtraction($sections);
|
|
}
|
|
|
|
$otherEditions = $familyEditions
|
|
->reject(fn (World $edition): bool => (int) $edition->id === (int) $world->id)
|
|
->values();
|
|
|
|
$archiveEditions = $otherEditions
|
|
->sortBy([
|
|
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,
|
|
])
|
|
->values()
|
|
->map(fn (World $edition): array => $this->mapWorldCard($edition, 'archive'))
|
|
->all();
|
|
|
|
$relatedWorlds = World::query()
|
|
->publiclyVisible()
|
|
->where('id', '!=', $world->id)
|
|
->when($world->recurrence_key, fn (Builder $builder) => $builder->where(function (Builder $nested) use ($world): void {
|
|
$nested->where('theme_key', $world->theme_key)
|
|
->orWhere('type', $world->type);
|
|
})->where(function (Builder $nested) use ($world): void {
|
|
$nested->whereNull('recurrence_key')
|
|
->orWhere('recurrence_key', '!=', $world->recurrence_key);
|
|
}))
|
|
->orderByDesc('starts_at')
|
|
->limit(3)
|
|
->get()
|
|
->map(fn (World $item): array => $this->mapWorldCard($item, $item->isCurrent() ? 'active' : 'archive'))
|
|
->all();
|
|
|
|
return [
|
|
'world' => $this->mapWorldDetail($world),
|
|
'recap' => $recap,
|
|
'sections' => $sections,
|
|
'linkedChallenge' => $linkedChallengePanel,
|
|
'linkedChallengeEntries' => $linkedChallengeEntries,
|
|
'linkedChallengeWinners' => $linkedChallengeWinners,
|
|
'linkedChallengeFinalists' => $linkedChallengeFinalists,
|
|
'communitySubmissions' => $communitySubmissions,
|
|
'rewardedContributors' => $rewardedContributors,
|
|
'archiveNotice' => $this->archiveNoticePayload($world, $canonicalEdition),
|
|
'currentEdition' => $canonicalEdition && (int) $canonicalEdition->id !== (int) $world->id
|
|
? $this->mapWorldCard($canonicalEdition, $this->phaseForWorld($canonicalEdition))
|
|
: null,
|
|
'previousEdition' => $previousEdition
|
|
? $this->mapWorldCard($previousEdition, $this->phaseForWorld($previousEdition))
|
|
: null,
|
|
'nextEdition' => $nextEdition
|
|
? $this->mapWorldCard($nextEdition, $this->phaseForWorld($nextEdition))
|
|
: null,
|
|
'archiveEditions' => $archiveEditions,
|
|
'familySummary' => $this->mapRecurringFamilySummary($world),
|
|
'relatedWorlds' => $relatedWorlds,
|
|
];
|
|
}
|
|
|
|
public function homepageSpotlight(?User $viewer = null): ?array
|
|
{
|
|
$world = $this->primarySpotlightWorld();
|
|
|
|
if (! $world) {
|
|
return null;
|
|
}
|
|
|
|
$world->loadMissing('worldRelations');
|
|
|
|
$secondary = $this->filterCanonicalSurfaceWorlds(
|
|
$this->homepageSecondaryQuery((int) $world->id)
|
|
->limit(12)
|
|
->get()
|
|
)
|
|
->take(3)
|
|
->map(fn (World $item): array => $this->mapWorldCard($item, $item->isActiveCampaign() ? 'active' : 'upcoming'))
|
|
->all();
|
|
|
|
$payload = $this->mapWorldDetail($world);
|
|
|
|
return [
|
|
'primary' => [
|
|
'id' => $payload['id'],
|
|
'title' => $payload['title'],
|
|
'headline' => $payload['teaser_title'] ?: $payload['title'],
|
|
'tagline' => $payload['tagline'],
|
|
'summary' => $payload['teaser_summary'] ?: $payload['summary'],
|
|
'cover_url' => $payload['teaser_image_url'] ?: $payload['cover_url'],
|
|
'icon_name' => $payload['icon_name'],
|
|
'badge_label' => $payload['badge_label'],
|
|
'campaign_label' => $payload['campaign_label'],
|
|
'timeframe_label' => $payload['timeframe_label'],
|
|
'promotion_window_label' => $payload['promotion_window_label'],
|
|
'theme' => $payload['theme'],
|
|
'cta_label' => $payload['cta_label'] ?: ($payload['challenge_cta_label'] ?: 'Explore world'),
|
|
'cta_url' => $payload['public_url'],
|
|
'public_url' => $payload['public_url'],
|
|
'status_badges' => $payload['status_badges'],
|
|
'live_submission_count' => $payload['live_submission_count'],
|
|
'featured_submission_count' => $payload['featured_submission_count'],
|
|
'relation_count' => $payload['relation_count'],
|
|
'supporting_item' => $this->resolveHomepageSupportingItem($world, $viewer),
|
|
],
|
|
'secondary' => $secondary,
|
|
'index_url' => route('worlds.index'),
|
|
];
|
|
}
|
|
|
|
private function recapPayload(
|
|
World $world,
|
|
array $sections,
|
|
?array $communitySubmissions,
|
|
array $rewardedContributors,
|
|
?array $linkedChallenge,
|
|
?array $linkedChallengeWinners,
|
|
?array $linkedChallengeFinalists,
|
|
bool $includeDraftRecap = false,
|
|
): ?array {
|
|
if (! $world->isEndedEdition()) {
|
|
return null;
|
|
}
|
|
|
|
$isPreviewDraft = $includeDraftRecap
|
|
&& ! $world->hasPublishedRecap()
|
|
&& $world->hasRecapDraftContent();
|
|
|
|
if (! $world->hasPublishedRecap() && ! $isPreviewDraft) {
|
|
return null;
|
|
}
|
|
|
|
$featuredArtworkSection = collect($sections)->firstWhere('key', 'featured_artworks');
|
|
$featuredCreatorSection = collect($sections)->firstWhere('key', 'featured_creators');
|
|
$featuredGroupSection = collect($sections)->firstWhere('key', 'featured_groups');
|
|
$newsSection = collect($sections)->firstWhere('key', 'news');
|
|
|
|
$featuredArtworkItems = $this->collectRecapArtworkItems(
|
|
$featuredArtworkSection,
|
|
$linkedChallengeWinners,
|
|
$linkedChallengeFinalists,
|
|
$communitySubmissions,
|
|
);
|
|
$communityHighlightItems = $this->collectRecapCommunityHighlights($communitySubmissions);
|
|
$article = $this->recapArticlePayload($world, $linkedChallenge['story'] ?? null, $newsSection);
|
|
$statsSnapshot = $this->normalizeRecapStatsSnapshot($world->recap_stats_snapshot_json ?: $this->buildRecapStatsSnapshot($world));
|
|
$summary = $statsSnapshot['summary'] ?? [];
|
|
$winnerCount = (int) ($summary['winner_count'] ?? 0);
|
|
$finalistCount = (int) ($summary['finalist_count'] ?? 0);
|
|
$rewardGrantCount = (int) ($summary['reward_grants'] ?? 0);
|
|
$submissionCount = (int) ($summary['submissions'] ?? $summary['live_participations'] ?? 0);
|
|
|
|
return [
|
|
'status' => $isPreviewDraft ? 'draft_preview' : World::RECAP_STATUS_PUBLISHED,
|
|
'eyebrow' => $isPreviewDraft ? 'Recap draft preview' : 'Edition recap',
|
|
'title' => trim((string) ($world->recap_title ?: ($world->title . ' recap'))),
|
|
'summary' => trim((string) ($world->recap_summary ?: ($article['description'] ?? $world->summary ?? ''))),
|
|
'intro' => trim((string) ($world->recap_intro ?: $this->defaultRecapIntro($world, $submissionCount, $rewardGrantCount, $winnerCount, $finalistCount))),
|
|
'cover_url' => $world->recapCoverUrl(),
|
|
'published_at' => optional($world->recap_published_at)?->toIso8601String(),
|
|
'article' => $article,
|
|
'stats' => [
|
|
'captured_at' => $statsSnapshot['captured_at'] ?? null,
|
|
'source' => $world->recap_stats_snapshot_json ? 'snapshot' : 'live',
|
|
'items' => $this->recapStatItems($statsSnapshot),
|
|
],
|
|
'featured_artworks' => [
|
|
'title' => 'Edition highlights',
|
|
'description' => 'Curated standouts, synced challenge outcomes, and featured community work that defined this edition.',
|
|
'items' => $featuredArtworkItems,
|
|
],
|
|
'community_highlights' => [
|
|
'title' => 'Community highlights',
|
|
'description' => 'Featured community submissions remain visible here so the edition archive keeps its strongest participation in view.',
|
|
'items' => $communityHighlightItems,
|
|
],
|
|
'creators' => [
|
|
'title' => 'Creators and groups',
|
|
'description' => 'Featured creators, groups, and rewarded contributors who shaped the atmosphere of this edition.',
|
|
'items' => array_slice(array_values(array_merge(
|
|
array_values((array) ($featuredCreatorSection['items'] ?? [])),
|
|
array_values((array) ($featuredGroupSection['items'] ?? [])),
|
|
)), 0, 8),
|
|
'rewarded' => array_slice(array_values((array) ($rewardedContributors['items'] ?? [])), 0, 6),
|
|
],
|
|
'winner_count' => $winnerCount,
|
|
'finalist_count' => $finalistCount,
|
|
];
|
|
}
|
|
|
|
private function sectionsAfterRecapExtraction(array $sections): array
|
|
{
|
|
return array_values(array_filter($sections, fn (array $section): bool => ! in_array((string) ($section['key'] ?? ''), [
|
|
'featured_artworks',
|
|
'featured_creators',
|
|
'featured_groups',
|
|
'news',
|
|
], true)));
|
|
}
|
|
|
|
private function recapStatusLabel(World $world): string
|
|
{
|
|
return $world->hasPublishedRecap() ? 'Published recap' : 'Draft recap';
|
|
}
|
|
|
|
private function recapArticlePayload(World $world, ?array $fallbackStory = null, ?array $newsSection = null): ?array
|
|
{
|
|
if ((int) ($world->recap_article_id ?? 0) > 0) {
|
|
$preview = $this->resolveNewsPreview((int) $world->recap_article_id, 'Recap article');
|
|
|
|
if ($preview) {
|
|
return array_merge($preview, [
|
|
'eyebrow' => 'Recap article',
|
|
'cta_label' => 'Read full recap',
|
|
]);
|
|
}
|
|
}
|
|
|
|
if (($fallbackStory['intent'] ?? null) === 'recap') {
|
|
return array_merge($fallbackStory, [
|
|
'eyebrow' => $fallbackStory['eyebrow'] ?? 'Challenge recap',
|
|
'cta_label' => $fallbackStory['cta_label'] ?? 'Read recap',
|
|
]);
|
|
}
|
|
|
|
$newsItem = collect((array) ($newsSection['items'] ?? []))
|
|
->first(fn (array $item): bool => is_string($item['url'] ?? null) && $item['url'] !== '');
|
|
|
|
if (! $newsItem) {
|
|
return null;
|
|
}
|
|
|
|
return array_merge($newsItem, [
|
|
'eyebrow' => 'Related story',
|
|
'cta_label' => 'Read story',
|
|
]);
|
|
}
|
|
|
|
private function recapPrimaryCta(World $world, ?array $article): ?array
|
|
{
|
|
if (! $world->hasPublishedRecap() || ! $world->isEndedEdition()) {
|
|
return null;
|
|
}
|
|
|
|
if ($article && is_string($article['url'] ?? null) && $article['url'] !== '') {
|
|
return [
|
|
'label' => (string) ($article['cta_label'] ?? 'Read full recap'),
|
|
'url' => (string) $article['url'],
|
|
];
|
|
}
|
|
|
|
return [
|
|
'label' => 'Browse edition highlights',
|
|
'url' => $this->publicUrlForWorld($world) . '#world-recap',
|
|
];
|
|
}
|
|
|
|
private function collectRecapArtworkItems(?array $featuredArtworkSection, ?array $linkedChallengeWinners, ?array $linkedChallengeFinalists, ?array $communitySubmissions): array
|
|
{
|
|
return collect(array_merge(
|
|
array_values((array) ($featuredArtworkSection['items'] ?? [])),
|
|
array_values((array) ($linkedChallengeWinners['items'] ?? [])),
|
|
array_values((array) ($linkedChallengeFinalists['items'] ?? [])),
|
|
array_values(array_filter((array) ($communitySubmissions['items'] ?? []), fn (array $item): bool => (string) ($item['status_label'] ?? '') === 'Featured')),
|
|
))
|
|
->filter(fn (array $item): bool => (int) ($item['id'] ?? 0) > 0)
|
|
->unique('id')
|
|
->take(8)
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function collectRecapCommunityHighlights(?array $communitySubmissions): array
|
|
{
|
|
$items = collect((array) ($communitySubmissions['items'] ?? []));
|
|
|
|
if ($items->isEmpty()) {
|
|
return [];
|
|
}
|
|
|
|
$featured = $items->filter(fn (array $item): bool => (string) ($item['status_label'] ?? '') === 'Featured');
|
|
|
|
return ($featured->isNotEmpty() ? $featured : $items)
|
|
->take(6)
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function defaultRecapIntro(World $world, int $submissionCount, int $rewardGrantCount, int $winnerCount, int $finalistCount): string
|
|
{
|
|
$fragments = array_filter([
|
|
$submissionCount > 0 ? number_format($submissionCount) . ' creator submissions' : null,
|
|
$rewardGrantCount > 0 ? number_format($rewardGrantCount) . ' visible recognitions' : null,
|
|
$winnerCount > 0 ? number_format($winnerCount) . ' winner' . ($winnerCount === 1 ? '' : 's') : null,
|
|
$finalistCount > 0 ? number_format($finalistCount) . ' finalist' . ($finalistCount === 1 ? '' : 's') : null,
|
|
]);
|
|
|
|
if ($fragments === []) {
|
|
return 'This edition has moved into the archive, but its strongest moments, contributors, and editorial highlights remain preserved here as a recap.';
|
|
}
|
|
|
|
return sprintf(
|
|
'%s has moved into the archive, and this recap preserves the edition through %s.',
|
|
(string) $world->title,
|
|
implode(', ', $fragments),
|
|
);
|
|
}
|
|
|
|
private function buildRecapStatsSnapshot(World $world): array
|
|
{
|
|
$analytics = $this->analytics->studioReport($world);
|
|
$summary = (array) ($analytics['ranges']['all']['summary'] ?? []);
|
|
$liveSubmissions = (int) $world->worldSubmissions()->where('status', WorldSubmission::STATUS_LIVE)->count();
|
|
$featuredParticipations = (int) $world->worldSubmissions()->where('status', WorldSubmission::STATUS_LIVE)->where('is_featured', true)->count();
|
|
$rewardGrants = (int) $world->worldRewardGrants()->count();
|
|
$winnerCount = (int) $world->worldRewardGrants()->where('reward_type', 'winner')->count();
|
|
$finalistCount = (int) $world->worldRewardGrants()->where('reward_type', 'finalist')->count();
|
|
$featuredArtworkCount = (int) $world->worldRelations()->where('section_key', 'featured_artworks')->count() + $featuredParticipations;
|
|
|
|
return [
|
|
'captured_at' => now()->toIso8601String(),
|
|
'summary' => [
|
|
'views' => (int) ($summary['views'] ?? 0),
|
|
'unique_visitors' => (int) ($summary['unique_visitors'] ?? 0),
|
|
'submissions' => (int) ($summary['submissions'] ?? $liveSubmissions),
|
|
'live_participations' => max($liveSubmissions, (int) ($summary['approved_live_participations'] ?? 0)),
|
|
'featured_participations' => max($featuredParticipations, (int) ($summary['featured_participations'] ?? 0)),
|
|
'reward_grants' => max($rewardGrants, (int) ($summary['reward_grants'] ?? 0)),
|
|
'challenge_clicks' => (int) ($summary['challenge_clicks'] ?? 0),
|
|
'winner_count' => $winnerCount,
|
|
'finalist_count' => $finalistCount,
|
|
'featured_artwork_count' => $featuredArtworkCount,
|
|
],
|
|
];
|
|
}
|
|
|
|
private function recapStatItems(array $snapshot): array
|
|
{
|
|
$summary = (array) ($snapshot['summary'] ?? []);
|
|
|
|
return array_values(array_filter([
|
|
$this->recapStatItem('views', 'Tracked views', (int) ($summary['views'] ?? 0), 'Public visits recorded across the edition.'),
|
|
$this->recapStatItem('unique_visitors', 'Unique visitors', (int) ($summary['unique_visitors'] ?? 0), 'Distinct visitors who reached the world page.'),
|
|
$this->recapStatItem('submissions', 'Submitted works', (int) ($summary['submissions'] ?? 0), 'Creator submissions captured during the campaign.'),
|
|
$this->recapStatItem('reward_grants', 'Recognitions', (int) ($summary['reward_grants'] ?? 0), 'World rewards and recognitions granted in this edition.'),
|
|
$this->recapStatItem('featured_artwork_count', 'Highlights surfaced', (int) ($summary['featured_artwork_count'] ?? 0), 'Curated and featured works pulled into the recap.'),
|
|
$this->recapStatItem('challenge_clicks', 'Challenge follow-through', (int) ($summary['challenge_clicks'] ?? 0), 'Tracked challenge CTA and section interactions.'),
|
|
]));
|
|
}
|
|
|
|
private function recapStatItem(string $key, string $label, int $value, string $description): ?array
|
|
{
|
|
if ($value < 1) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'key' => $key,
|
|
'label' => $label,
|
|
'value' => $value,
|
|
'description' => $description,
|
|
];
|
|
}
|
|
|
|
public function navigationCampaign(): ?array
|
|
{
|
|
return Cache::remember('worlds.navigation_campaign', 60, function (): ?array {
|
|
$world = World::query()
|
|
->select([
|
|
'id',
|
|
'slug',
|
|
'title',
|
|
'status',
|
|
'is_recurring',
|
|
'recurrence_key',
|
|
'edition_year',
|
|
'starts_at',
|
|
'ends_at',
|
|
'promotion_starts_at',
|
|
'promotion_ends_at',
|
|
'published_at',
|
|
'is_active_campaign',
|
|
'is_featured',
|
|
'is_homepage_featured',
|
|
'campaign_priority',
|
|
'campaign_label',
|
|
])
|
|
->campaignActive()
|
|
->orderByDesc('is_homepage_featured')
|
|
->orderByRaw('COALESCE(campaign_priority, 0) DESC')
|
|
->orderByDesc('is_featured')
|
|
->orderBy('title')
|
|
->limit(12)
|
|
->get()
|
|
->first(fn (World $candidate): bool => $this->isCanonicalSurfaceWorld($candidate));
|
|
|
|
if (! $world || ! $world->isActiveCampaign()) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'title' => (string) $world->title,
|
|
'campaign_label' => (string) ($world->campaign_label ?: 'Live now'),
|
|
'status_label' => $this->campaignStateLabel($world),
|
|
'url' => $this->publicPathForWorld($world),
|
|
];
|
|
});
|
|
}
|
|
|
|
private function persist(World $world, User $editor, array $data): World
|
|
{
|
|
$originalCoverPath = (string) ($world->cover_path ?? '');
|
|
$originalTeaserImagePath = (string) ($world->teaser_image_path ?? '');
|
|
$originalOgImagePath = (string) ($world->og_image_path ?? '');
|
|
$originalRecapCoverPath = (string) ($world->recap_cover_path ?? '');
|
|
|
|
$title = trim((string) ($data['title'] ?? $world->title ?? 'Untitled World'));
|
|
$status = (string) ($data['status'] ?? $world->status ?? World::STATUS_DRAFT);
|
|
$publishedAt = $this->normalizePublishedAt($status, $data['published_at'] ?? $world->published_at);
|
|
|
|
$world->fill([
|
|
'title' => $title,
|
|
'slug' => $this->resolveSlug($title, $world, $data),
|
|
'tagline' => $this->nullableText($data['tagline'] ?? null),
|
|
'summary' => $this->nullableText($data['summary'] ?? null),
|
|
'teaser_title' => $this->nullableText($data['teaser_title'] ?? null),
|
|
'teaser_summary' => $this->nullableText($data['teaser_summary'] ?? null),
|
|
'description' => $this->nullableText($data['description'] ?? null),
|
|
'cover_path' => $this->nullableText($data['cover_path'] ?? null),
|
|
'teaser_image_path' => $this->nullableText($data['teaser_image_path'] ?? null),
|
|
'theme_key' => $this->nullableText($data['theme_key'] ?? null),
|
|
'accent_color' => $this->nullableText($data['accent_color'] ?? null),
|
|
'accent_color_secondary' => $this->nullableText($data['accent_color_secondary'] ?? null),
|
|
'background_motif' => $this->nullableText($data['background_motif'] ?? null),
|
|
'icon_name' => $this->nullableText($data['icon_name'] ?? null),
|
|
'status' => $status,
|
|
'type' => (string) ($data['type'] ?? World::TYPE_SEASONAL),
|
|
'starts_at' => ! empty($data['starts_at']) ? Carbon::parse((string) $data['starts_at']) : null,
|
|
'ends_at' => ! empty($data['ends_at']) ? Carbon::parse((string) $data['ends_at']) : null,
|
|
'promotion_starts_at' => ! empty($data['promotion_starts_at']) ? Carbon::parse((string) $data['promotion_starts_at']) : null,
|
|
'promotion_ends_at' => ! empty($data['promotion_ends_at']) ? Carbon::parse((string) $data['promotion_ends_at']) : null,
|
|
'accepts_submissions' => (bool) ($data['accepts_submissions'] ?? false),
|
|
'participation_mode' => (string) ($data['participation_mode'] ?? ((bool) ($data['accepts_submissions'] ?? false) ? World::PARTICIPATION_MODE_MANUAL_APPROVAL : World::PARTICIPATION_MODE_CLOSED)),
|
|
'submission_starts_at' => ! empty($data['submission_starts_at']) ? Carbon::parse((string) $data['submission_starts_at']) : null,
|
|
'submission_ends_at' => ! empty($data['submission_ends_at']) ? Carbon::parse((string) $data['submission_ends_at']) : null,
|
|
'submission_note_enabled' => (bool) ($data['submission_note_enabled'] ?? true),
|
|
'community_section_enabled' => (bool) ($data['community_section_enabled'] ?? true),
|
|
'allow_readd_after_removal' => (bool) ($data['allow_readd_after_removal'] ?? true),
|
|
'is_featured' => (bool) ($data['is_featured'] ?? false),
|
|
'is_active_campaign' => (bool) ($data['is_active_campaign'] ?? false),
|
|
'is_homepage_featured' => (bool) ($data['is_homepage_featured'] ?? false),
|
|
'campaign_priority' => isset($data['campaign_priority']) && $data['campaign_priority'] !== '' ? (int) $data['campaign_priority'] : null,
|
|
'is_recurring' => (bool) ($data['is_recurring'] ?? false),
|
|
'recurrence_key' => $this->nullableText($data['recurrence_key'] ?? null),
|
|
'recurrence_rule' => $this->nullableText($data['recurrence_rule'] ?? null),
|
|
'edition_year' => ! empty($data['edition_year']) ? (int) $data['edition_year'] : null,
|
|
'cta_label' => $this->nullableText($data['cta_label'] ?? null),
|
|
'cta_url' => $this->nullableText($data['cta_url'] ?? null),
|
|
'badge_label' => $this->nullableText($data['badge_label'] ?? null),
|
|
'campaign_label' => $this->nullableText($data['campaign_label'] ?? null),
|
|
'badge_description' => $this->nullableText($data['badge_description'] ?? null),
|
|
'submission_guidelines' => $this->nullableText($data['submission_guidelines'] ?? null),
|
|
'badge_url' => $this->nullableText($data['badge_url'] ?? null),
|
|
'seo_title' => $this->nullableText($data['seo_title'] ?? null),
|
|
'seo_description' => $this->nullableText($data['seo_description'] ?? null),
|
|
'og_image_path' => $this->nullableText($data['og_image_path'] ?? null),
|
|
'recap_status' => (string) ($data['recap_status'] ?? $world->recap_status ?? World::RECAP_STATUS_DRAFT),
|
|
'recap_title' => $this->nullableText($data['recap_title'] ?? null),
|
|
'recap_summary' => $this->nullableText($data['recap_summary'] ?? null),
|
|
'recap_intro' => $this->nullableText($data['recap_intro'] ?? null),
|
|
'recap_editor_note' => $this->nullableText($data['recap_editor_note'] ?? null),
|
|
'recap_cover_path' => $this->nullableText($data['recap_cover_path'] ?? null),
|
|
'recap_article_id' => $this->normalizeRecapArticleId($data['recap_article_id'] ?? null),
|
|
'recap_stats_snapshot_json' => array_key_exists('recap_stats_snapshot_json', $data)
|
|
? $this->normalizeRecapStatsSnapshot($data['recap_stats_snapshot_json'])
|
|
: $world->recap_stats_snapshot_json,
|
|
'recap_published_at' => ! empty($data['recap_published_at'])
|
|
? Carbon::parse((string) $data['recap_published_at'])
|
|
: $world->recap_published_at,
|
|
'related_tags_json' => collect((array) ($data['related_tags_json'] ?? []))->map(fn ($tag) => trim((string) $tag))->filter()->values()->all(),
|
|
'section_order_json' => $this->normalizeSectionOrder($data['section_order_json'] ?? []),
|
|
'section_visibility_json' => $this->normalizeSectionVisibility($data['section_visibility_json'] ?? []),
|
|
'parent_world_id' => ! empty($data['parent_world_id']) ? (int) $data['parent_world_id'] : null,
|
|
'linked_challenge_id' => $this->normalizeLinkedChallengeId($data['linked_challenge_id'] ?? null),
|
|
'show_linked_challenge_section' => (bool) ($data['show_linked_challenge_section'] ?? true),
|
|
'show_linked_challenge_entries' => (bool) ($data['show_linked_challenge_entries'] ?? true),
|
|
'show_linked_challenge_winners' => (bool) ($data['show_linked_challenge_winners'] ?? true),
|
|
'show_linked_challenge_finalists' => (bool) ($data['show_linked_challenge_finalists'] ?? true),
|
|
'auto_grant_challenge_world_rewards' => (bool) ($data['auto_grant_challenge_world_rewards'] ?? true),
|
|
'challenge_teaser_override' => $this->nullableText($data['challenge_teaser_override'] ?? null),
|
|
'hidden_linked_challenge_artwork_ids_json' => $this->normalizeArtworkIdList($data['hidden_linked_challenge_artwork_ids_json'] ?? []),
|
|
'published_at' => $publishedAt,
|
|
]);
|
|
|
|
if (! $world->linked_challenge_id) {
|
|
$world->challenge_teaser_override = null;
|
|
$world->hidden_linked_challenge_artwork_ids_json = [];
|
|
}
|
|
|
|
if ((string) $world->recap_status !== World::RECAP_STATUS_PUBLISHED) {
|
|
$world->recap_published_at = null;
|
|
}
|
|
|
|
if ((string) $world->participation_mode === World::PARTICIPATION_MODE_CLOSED) {
|
|
$world->accepts_submissions = false;
|
|
}
|
|
|
|
if (! $world->exists) {
|
|
$world->created_by_user_id = (int) $editor->id;
|
|
}
|
|
|
|
$world->save();
|
|
|
|
$this->deleteWorldMediaIfReplaced($originalCoverPath, (string) ($world->cover_path ?? ''));
|
|
$this->deleteWorldMediaIfReplaced($originalTeaserImagePath, (string) ($world->teaser_image_path ?? ''));
|
|
$this->deleteWorldMediaIfReplaced($originalOgImagePath, (string) ($world->og_image_path ?? ''));
|
|
$this->deleteWorldMediaIfReplaced($originalRecapCoverPath, (string) ($world->recap_cover_path ?? ''));
|
|
|
|
$this->syncRelations($world, (array) ($data['relations'] ?? []));
|
|
$this->rewards->syncLinkedChallengeRewardsForWorld($world);
|
|
|
|
return $world->fresh(['createdBy.profile', 'parentWorld', 'worldRelations', 'linkedChallenge.group', 'recapArticle.author.profile', 'recapArticle.category']);
|
|
}
|
|
|
|
private function syncRelations(World $world, array $relations): void
|
|
{
|
|
$normalized = collect($relations)
|
|
->map(function (array $relation): ?array {
|
|
$sectionKey = trim((string) ($relation['section_key'] ?? ''));
|
|
$relatedType = trim((string) ($relation['related_type'] ?? ''));
|
|
$relatedId = (int) ($relation['related_id'] ?? 0);
|
|
|
|
if ($sectionKey === '' || $relatedType === '' || $relatedId < 1) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'section_key' => $sectionKey,
|
|
'related_type' => $relatedType,
|
|
'related_id' => $relatedId,
|
|
'context_label' => Str::limit(trim((string) ($relation['context_label'] ?? '')), 120, ''),
|
|
'sort_order' => max(0, (int) ($relation['sort_order'] ?? 0)),
|
|
'is_featured' => (bool) ($relation['is_featured'] ?? false),
|
|
];
|
|
})
|
|
->filter()
|
|
->sortBy(fn (array $relation): string => sprintf('%s:%09d:%s:%09d', $relation['section_key'], (int) $relation['sort_order'], $relation['related_type'], (int) $relation['related_id']))
|
|
->values();
|
|
|
|
$world->worldRelations()->delete();
|
|
|
|
foreach ($normalized as $index => $relation) {
|
|
$world->worldRelations()->create([
|
|
'section_key' => $relation['section_key'],
|
|
'related_type' => $relation['related_type'],
|
|
'related_id' => $relation['related_id'],
|
|
'context_label' => $relation['context_label'] !== '' ? $relation['context_label'] : null,
|
|
'sort_order' => $relation['sort_order'] ?: $index,
|
|
'is_featured' => $relation['is_featured'],
|
|
]);
|
|
}
|
|
}
|
|
|
|
private function resolveSections(World $world, ?User $viewer = null): array
|
|
{
|
|
$relations = $world->worldRelations->values();
|
|
|
|
if ($relations->isEmpty()) {
|
|
return [];
|
|
}
|
|
|
|
$entities = $this->resolveEntitiesForRelations($relations, $viewer);
|
|
$sectionConfigs = (array) config('worlds.sections', []);
|
|
$sectionVisibility = $world->sectionVisibility();
|
|
$sections = [];
|
|
|
|
foreach ($world->sectionOrder() as $sectionKey) {
|
|
if (($sectionVisibility[$sectionKey] ?? true) !== true) {
|
|
continue;
|
|
}
|
|
|
|
$items = $relations
|
|
->where('section_key', $sectionKey)
|
|
->map(function (WorldRelation $relation) use ($entities): ?array {
|
|
$item = $entities->get($this->entityMapKey((string) $relation->related_type, (int) $relation->related_id));
|
|
|
|
if (! $item) {
|
|
return null;
|
|
}
|
|
|
|
return array_merge($item, [
|
|
'context_label' => trim((string) ($relation->context_label ?? '')) !== ''
|
|
? (string) $relation->context_label
|
|
: ($item['context_label'] ?? null),
|
|
'is_featured' => (bool) $relation->is_featured,
|
|
]);
|
|
})
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
|
|
if ($items === []) {
|
|
continue;
|
|
}
|
|
|
|
$sections[] = [
|
|
'key' => $sectionKey,
|
|
'title' => (string) ($sectionConfigs[$sectionKey]['label'] ?? Str::headline($sectionKey)),
|
|
'description' => (string) ($sectionConfigs[$sectionKey]['description'] ?? ''),
|
|
'items' => $items,
|
|
];
|
|
}
|
|
|
|
return $sections;
|
|
}
|
|
|
|
private function resolveEntitiesForRelations(SupportCollection $relations, ?User $viewer = null): SupportCollection
|
|
{
|
|
$map = collect();
|
|
$grouped = $relations->groupBy('related_type');
|
|
|
|
if ($grouped->has(WorldRelation::TYPE_ARTWORK)) {
|
|
$ids = $grouped[WorldRelation::TYPE_ARTWORK]->pluck('related_id')->map(fn ($id) => (int) $id)->all();
|
|
$artworks = Artwork::query()
|
|
->with(['user.profile', 'categories.contentType'])
|
|
->whereIn('id', $ids)
|
|
->where('artwork_status', 'published')
|
|
->where('visibility', Artwork::VISIBILITY_PUBLIC)
|
|
->get();
|
|
|
|
foreach ($artworks as $artwork) {
|
|
$preview = $this->resolveArtworkPreview((int) $artwork->id, '');
|
|
if ($preview) {
|
|
$map->put($this->entityMapKey(WorldRelation::TYPE_ARTWORK, (int) $artwork->id), $preview);
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($grouped->has(WorldRelation::TYPE_COLLECTION)) {
|
|
$ids = $grouped[WorldRelation::TYPE_COLLECTION]->pluck('related_id')->map(fn ($id) => (int) $id)->all();
|
|
$collections = Collection::query()->with(['user.profile', 'group', 'coverArtwork'])->whereIn('id', $ids)->get();
|
|
$payloads = collect($this->collections->mapCollectionCardPayloads($collections, false, $viewer))->keyBy(fn (array $item): int => (int) $item['id']);
|
|
|
|
foreach ($ids as $id) {
|
|
$payload = $payloads->get($id);
|
|
if ($payload) {
|
|
$map->put($this->entityMapKey(WorldRelation::TYPE_COLLECTION, $id), array_merge($payload, [
|
|
'entity_type' => WorldRelation::TYPE_COLLECTION,
|
|
'entity_label' => (string) (config('worlds.relation_types.collection') ?? 'Collection'),
|
|
]));
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($grouped->has(WorldRelation::TYPE_USER)) {
|
|
$ids = $grouped[WorldRelation::TYPE_USER]->pluck('related_id')->map(fn ($id) => (int) $id)->all();
|
|
$users = User::query()->with('profile')->whereIn('id', $ids)->get();
|
|
|
|
foreach ($users as $user) {
|
|
$preview = $this->resolveUserPreview((int) $user->id, '');
|
|
if ($preview) {
|
|
$map->put($this->entityMapKey(WorldRelation::TYPE_USER, (int) $user->id), $preview);
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($grouped->has(WorldRelation::TYPE_GROUP)) {
|
|
$ids = $grouped[WorldRelation::TYPE_GROUP]->pluck('related_id')->map(fn ($id) => (int) $id)->all();
|
|
$groups = Group::query()->with(['owner.profile', 'recruitmentProfile', 'discoveryMetric', 'members', 'badges'])->whereIn('id', $ids)->get();
|
|
|
|
foreach ($groups as $group) {
|
|
if ($group->canBeViewedBy($viewer)) {
|
|
$map->put($this->entityMapKey(WorldRelation::TYPE_GROUP, (int) $group->id), array_merge($this->groups->mapGroupCard($group, $viewer), [
|
|
'entity_type' => WorldRelation::TYPE_GROUP,
|
|
'entity_label' => (string) (config('worlds.relation_types.group') ?? 'Group'),
|
|
]));
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($grouped->has(WorldRelation::TYPE_NEWS)) {
|
|
$ids = $grouped[WorldRelation::TYPE_NEWS]->pluck('related_id')->map(fn ($id) => (int) $id)->all();
|
|
$articles = NewsArticle::query()->with(['author.profile', 'category'])->published()->whereIn('id', $ids)->get();
|
|
|
|
foreach ($articles as $article) {
|
|
$preview = $this->resolveNewsPreview((int) $article->id, '');
|
|
if ($preview) {
|
|
$map->put($this->entityMapKey(WorldRelation::TYPE_NEWS, (int) $article->id), $preview);
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($grouped->has(WorldRelation::TYPE_CHALLENGE)) {
|
|
$ids = $grouped[WorldRelation::TYPE_CHALLENGE]->pluck('related_id')->map(fn ($id) => (int) $id)->all();
|
|
$items = GroupChallenge::query()->with('group')->whereIn('id', $ids)->get();
|
|
|
|
foreach ($items as $item) {
|
|
$preview = $this->resolveChallengePreview((int) $item->id, $viewer, '');
|
|
if ($preview) {
|
|
$map->put($this->entityMapKey(WorldRelation::TYPE_CHALLENGE, (int) $item->id), $preview);
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($grouped->has(WorldRelation::TYPE_EVENT)) {
|
|
$ids = $grouped[WorldRelation::TYPE_EVENT]->pluck('related_id')->map(fn ($id) => (int) $id)->all();
|
|
$items = GroupEvent::query()->with('group')->whereIn('id', $ids)->get();
|
|
|
|
foreach ($items as $item) {
|
|
$preview = $this->resolveEventPreview((int) $item->id, $viewer, '');
|
|
if ($preview) {
|
|
$map->put($this->entityMapKey(WorldRelation::TYPE_EVENT, (int) $item->id), $preview);
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($grouped->has(WorldRelation::TYPE_RELEASE)) {
|
|
$ids = $grouped[WorldRelation::TYPE_RELEASE]->pluck('related_id')->map(fn ($id) => (int) $id)->all();
|
|
$items = GroupRelease::query()->with('group')->whereIn('id', $ids)->get();
|
|
|
|
foreach ($items as $item) {
|
|
$preview = $this->resolveReleasePreview((int) $item->id, $viewer, '');
|
|
if ($preview) {
|
|
$map->put($this->entityMapKey(WorldRelation::TYPE_RELEASE, (int) $item->id), $preview);
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($grouped->has(WorldRelation::TYPE_CARD)) {
|
|
$ids = $grouped[WorldRelation::TYPE_CARD]->pluck('related_id')->map(fn ($id) => (int) $id)->all();
|
|
$items = NovaCard::query()->with(['user.profile', 'category'])->whereIn('id', $ids)->get();
|
|
|
|
foreach ($items as $item) {
|
|
$preview = $this->resolveCardPreview((int) $item->id, $viewer, '');
|
|
if ($preview) {
|
|
$map->put($this->entityMapKey(WorldRelation::TYPE_CARD, (int) $item->id), $preview);
|
|
}
|
|
}
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
private function mapWorldCard(World $world, string $phase): array
|
|
{
|
|
$detail = $this->mapWorldDetail($world);
|
|
|
|
return [
|
|
'id' => $detail['id'],
|
|
'title' => $detail['title'],
|
|
'teaser_title' => $detail['teaser_title'],
|
|
'slug' => $detail['slug'],
|
|
'tagline' => $detail['tagline'],
|
|
'summary' => $detail['teaser_summary'] ?: $detail['summary'],
|
|
'cover_url' => $detail['teaser_image_url'] ?: $detail['cover_url'],
|
|
'theme' => $detail['theme'],
|
|
'type' => $detail['type'],
|
|
'status' => $detail['status'],
|
|
'phase' => $phase,
|
|
'badge_label' => $detail['badge_label'],
|
|
'campaign_label' => $detail['campaign_label'],
|
|
'icon_name' => $detail['icon_name'],
|
|
'timeframe_label' => $detail['timeframe_label'],
|
|
'promotion_window_label' => $detail['promotion_window_label'],
|
|
'starts_at' => $detail['starts_at'],
|
|
'ends_at' => $detail['ends_at'],
|
|
'edition_year' => $detail['edition_year'],
|
|
'edition_label' => $detail['edition_label'],
|
|
'is_recurring' => $detail['is_recurring'],
|
|
'family_title' => $detail['family_title'],
|
|
'family_url' => $detail['family_url'],
|
|
'edition_url' => $detail['edition_url'],
|
|
'is_canonical_edition' => $detail['is_canonical_edition'],
|
|
'public_url' => $detail['public_url'],
|
|
'cta_label' => $detail['cta_label'],
|
|
'challenge_cta_label' => $detail['challenge_cta_label'],
|
|
'challenge_cta_url' => $detail['challenge_cta_url'],
|
|
'is_featured' => $detail['is_featured'],
|
|
'is_active_campaign' => $detail['is_active_campaign'],
|
|
'is_homepage_featured' => $detail['is_homepage_featured'],
|
|
'campaign_state' => $detail['campaign_state'],
|
|
'campaign_state_label' => $detail['campaign_state_label'],
|
|
'status_badges' => $detail['status_badges'],
|
|
'live_submission_count' => $detail['live_submission_count'],
|
|
'featured_submission_count' => $detail['featured_submission_count'],
|
|
'relation_count' => $detail['relation_count'],
|
|
];
|
|
}
|
|
|
|
private function mapWorldDetail(World $world): array
|
|
{
|
|
$world->loadMissing(['linkedChallenge.group', 'worldRelations', 'recapArticle.author.profile', 'recapArticle.category']);
|
|
$theme = $this->themePayload($world);
|
|
$familyTitle = $this->recurrenceFamilyLabel($world);
|
|
$familyUrl = $this->familyUrlForWorld($world);
|
|
$editionUrl = $this->editionUrlForWorld($world);
|
|
$isCanonicalEdition = $this->isCanonicalSurfaceWorld($world);
|
|
$linkedChallenge = $this->preferredLinkedChallenge($world);
|
|
$linkedChallengeState = $linkedChallenge ? $this->challengeLifecycleStateForWorld($world, $linkedChallenge) : null;
|
|
$linkedChallengeStory = $linkedChallenge ? $this->linkedChallengeStoryPayload($world, $linkedChallenge, $linkedChallengeState) : null;
|
|
$recapArticle = $this->recapArticlePayload($world, $linkedChallengeStory);
|
|
$recapPrimaryCta = $this->recapPrimaryCta($world, $recapArticle);
|
|
|
|
return [
|
|
'id' => (int) $world->id,
|
|
'title' => (string) $world->title,
|
|
'slug' => (string) $world->slug,
|
|
'tagline' => (string) ($world->tagline ?? ''),
|
|
'summary' => (string) ($world->summary ?? ''),
|
|
'teaser_title' => (string) ($world->teaser_title ?? ''),
|
|
'teaser_summary' => (string) ($world->teaser_summary ?? ''),
|
|
'description' => (string) ($world->description ?? ''),
|
|
'cover_url' => $world->coverUrl(),
|
|
'teaser_image_url' => $world->teaserImageUrl(),
|
|
'recap_cover_url' => $world->recapCoverUrl(),
|
|
'type' => (string) $world->type,
|
|
'status' => (string) $world->status,
|
|
'theme' => $theme,
|
|
'icon_name' => $this->resolvedIconName($world, $theme),
|
|
'badge_label' => (string) ($world->badge_label ?? ''),
|
|
'campaign_label' => (string) ($world->campaign_label ?? ''),
|
|
'badge_description' => (string) ($world->badge_description ?? ''),
|
|
'badge_url' => (string) ($world->badge_url ?? ''),
|
|
'cta_label' => $recapPrimaryCta['label'] ?? (string) ($world->cta_label ?? ''),
|
|
'challenge_cta_label' => $linkedChallenge ? $this->linkedChallengePrimaryCtaLabel($linkedChallengeState, $linkedChallengeStory) : null,
|
|
'challenge_cta_url' => $linkedChallenge ? $this->linkedChallengePrimaryUrl($world, $linkedChallenge, $linkedChallengeState, $linkedChallengeStory) : null,
|
|
'cta_url' => $recapPrimaryCta['url'] ?? (string) ($world->cta_url ?? ''),
|
|
'starts_at' => optional($world->starts_at)?->toIso8601String(),
|
|
'ends_at' => optional($world->ends_at)?->toIso8601String(),
|
|
'promotion_starts_at' => optional($world->promotion_starts_at)?->toIso8601String(),
|
|
'promotion_ends_at' => optional($world->promotion_ends_at)?->toIso8601String(),
|
|
'timeframe_label' => $this->timeframeLabel($world),
|
|
'promotion_window_label' => $this->promotionWindowLabel($world),
|
|
'related_tags' => array_values(array_map('strval', $world->related_tags_json ?? [])),
|
|
'recurrence_key' => (string) ($world->recurrence_key ?? ''),
|
|
'edition_year' => $world->edition_year,
|
|
'edition_label' => $world->edition_year ? ('Edition ' . $world->edition_year) : null,
|
|
'is_recurring' => (bool) $world->is_recurring,
|
|
'family_title' => $familyTitle,
|
|
'family_slug' => $world->recurrence_key ?: null,
|
|
'family_url' => $familyUrl,
|
|
'edition_url' => $editionUrl,
|
|
'is_canonical_edition' => $isCanonicalEdition,
|
|
'is_featured' => (bool) $world->is_featured,
|
|
'is_active_campaign' => (bool) $world->is_active_campaign,
|
|
'is_homepage_featured' => (bool) $world->is_homepage_featured,
|
|
'campaign_priority' => $world->campaign_priority,
|
|
'campaign_state' => $this->campaignStateKey($world),
|
|
'campaign_state_label' => $this->campaignStateLabel($world),
|
|
'status_badges' => $this->statusBadges($world, $linkedChallenge),
|
|
'recap_status' => (string) ($world->recap_status ?: World::RECAP_STATUS_DRAFT),
|
|
'recap_title' => (string) ($world->recap_title ?? ''),
|
|
'recap_summary' => (string) ($world->recap_summary ?? ''),
|
|
'recap_cover_url' => $world->recapCoverUrl(),
|
|
'recap_published_at' => optional($world->recap_published_at)?->toIso8601String(),
|
|
'has_recap' => $world->hasPublishedRecap(),
|
|
'recap_article' => $recapArticle,
|
|
'live_submission_count' => (int) ($world->live_submission_count ?? $world->worldSubmissions()->where('status', WorldSubmission::STATUS_LIVE)->count()),
|
|
'featured_submission_count' => (int) ($world->featured_submission_count ?? $world->worldSubmissions()->where('status', WorldSubmission::STATUS_LIVE)->where('is_featured', true)->count()),
|
|
'rewarded_contributor_count' => (int) $world->worldRewardGrants()->count(),
|
|
'relation_count' => (int) ($world->world_relations_count ?? $world->worldRelations()->count()),
|
|
'public_url' => $this->publicUrlForWorld($world),
|
|
];
|
|
}
|
|
|
|
private function mapStudioListItem(World $world): array
|
|
{
|
|
$linkedChallenge = $this->preferredLinkedChallenge($world);
|
|
|
|
return [
|
|
'id' => (int) $world->id,
|
|
'title' => (string) $world->title,
|
|
'slug' => (string) $world->slug,
|
|
'status' => (string) $world->status,
|
|
'type' => (string) $world->type,
|
|
'is_recurring' => (bool) $world->is_recurring,
|
|
'recurrence_key' => (string) ($world->recurrence_key ?? ''),
|
|
'edition_year' => $world->edition_year,
|
|
'theme_key' => (string) ($world->theme_key ?? ''),
|
|
'summary' => Str::limit(trim(strip_tags((string) ($world->summary ?: $world->description ?: ''))), 120),
|
|
'timeframe_label' => $this->timeframeLabel($world),
|
|
'promotion_window_label' => $this->promotionWindowLabel($world),
|
|
'relation_count' => (int) ($world->world_relations_count ?? 0),
|
|
'live_submission_count' => (int) ($world->live_submission_count ?? 0),
|
|
'is_featured' => (bool) $world->is_featured,
|
|
'is_active_campaign' => (bool) $world->is_active_campaign,
|
|
'is_homepage_featured' => (bool) $world->is_homepage_featured,
|
|
'campaign_priority' => $world->campaign_priority,
|
|
'campaign_state' => $this->campaignStateKey($world),
|
|
'campaign_state_label' => $this->campaignStateLabel($world),
|
|
'status_badges' => $this->statusBadges($world, $linkedChallenge),
|
|
'edit_url' => route('studio.worlds.edit', ['world' => $world]),
|
|
'preview_url' => route('studio.worlds.preview', ['world' => $world]),
|
|
'public_url' => $this->publicUrlForWorld($world),
|
|
];
|
|
}
|
|
|
|
private function publicSurfaceQuery(): Builder
|
|
{
|
|
return World::query()->with(['linkedChallenge.group'])->withCount([
|
|
'worldRelations',
|
|
'worldSubmissions as live_submission_count' => fn (Builder $builder): Builder => $builder->where('status', WorldSubmission::STATUS_LIVE),
|
|
'worldSubmissions as featured_submission_count' => fn (Builder $builder): Builder => $builder->where('status', WorldSubmission::STATUS_LIVE)->where('is_featured', true),
|
|
]);
|
|
}
|
|
|
|
private function currentSurfaceQuery(): Builder
|
|
{
|
|
return $this->publicSurfaceQuery()
|
|
->current()
|
|
->orderByDesc('is_active_campaign')
|
|
->orderByDesc('is_homepage_featured')
|
|
->orderByRaw('COALESCE(campaign_priority, 0) DESC')
|
|
->orderByDesc('is_featured')
|
|
->orderBy('starts_at')
|
|
->orderBy('title');
|
|
}
|
|
|
|
private function upcomingSurfaceQuery(): Builder
|
|
{
|
|
return $this->publicSurfaceQuery()
|
|
->published()
|
|
->where(function (Builder $builder): void {
|
|
$builder->where(function (Builder $upcoming): void {
|
|
$upcoming->whereNotNull('starts_at')
|
|
->where('starts_at', '>', now());
|
|
})->orWhere(function (Builder $campaign): void {
|
|
$campaign->where('is_active_campaign', true)
|
|
->whereRaw('COALESCE(promotion_starts_at, starts_at) > ?', [now()->toDateTimeString()]);
|
|
});
|
|
})
|
|
->orderByDesc('is_homepage_featured')
|
|
->orderByRaw('COALESCE(campaign_priority, 0) DESC')
|
|
->orderByRaw('COALESCE(promotion_starts_at, starts_at) ASC')
|
|
->orderBy('title');
|
|
}
|
|
|
|
private function featuredSurfaceQuery(array $excludeIds = []): Builder
|
|
{
|
|
return $this->publicSurfaceQuery()
|
|
->publiclyVisible()
|
|
->where(function (Builder $builder): void {
|
|
$builder->where('is_featured', true)
|
|
->orWhere('is_homepage_featured', true);
|
|
})
|
|
->when($excludeIds !== [], fn (Builder $builder): Builder => $builder->whereNotIn('id', $excludeIds))
|
|
->orderByDesc('is_homepage_featured')
|
|
->orderByRaw('COALESCE(campaign_priority, 0) DESC')
|
|
->orderByDesc('is_featured')
|
|
->orderByDesc('published_at');
|
|
}
|
|
|
|
private function archiveSurfaceQuery(): Builder
|
|
{
|
|
return $this->publicSurfaceQuery()
|
|
->archive()
|
|
->orderByDesc('ends_at')
|
|
->orderByDesc('published_at');
|
|
}
|
|
|
|
private function primarySpotlightWorld(): ?World
|
|
{
|
|
return $this->firstCanonicalWorld(
|
|
$this->publicSurfaceQuery()
|
|
->campaignActive()
|
|
->homepageFeatured()
|
|
->orderByRaw('COALESCE(campaign_priority, 0) DESC')
|
|
->orderByDesc('is_featured')
|
|
->orderBy('title')
|
|
->limit(12)
|
|
)
|
|
?? $this->firstCanonicalWorld(
|
|
$this->publicSurfaceQuery()
|
|
->campaignActive()
|
|
->orderByDesc('is_homepage_featured')
|
|
->orderByRaw('COALESCE(campaign_priority, 0) DESC')
|
|
->orderByDesc('is_featured')
|
|
->orderBy('title')
|
|
->limit(12)
|
|
)
|
|
?? $this->firstCanonicalWorld(
|
|
$this->publicSurfaceQuery()
|
|
->current()
|
|
->where(function (Builder $builder): void {
|
|
$builder->where('is_featured', true)
|
|
->orWhere('is_homepage_featured', true);
|
|
})
|
|
->orderByDesc('is_homepage_featured')
|
|
->orderByRaw('COALESCE(campaign_priority, 0) DESC')
|
|
->orderByDesc('is_featured')
|
|
->orderBy('title')
|
|
->limit(12)
|
|
)
|
|
?? $this->firstCanonicalWorld(
|
|
$this->publicSurfaceQuery()
|
|
->campaignUpcoming()
|
|
->homepageFeatured()
|
|
->orderByRaw('COALESCE(campaign_priority, 0) DESC')
|
|
->orderByRaw('COALESCE(promotion_starts_at, starts_at) ASC')
|
|
->limit(12)
|
|
)
|
|
?? $this->firstCanonicalWorld(
|
|
$this->publicSurfaceQuery()
|
|
->campaignUpcoming()
|
|
->orderByDesc('is_homepage_featured')
|
|
->orderByRaw('COALESCE(campaign_priority, 0) DESC')
|
|
->orderByRaw('COALESCE(promotion_starts_at, starts_at) ASC')
|
|
->limit(12)
|
|
);
|
|
}
|
|
|
|
private function homepageSecondaryQuery(int $excludeWorldId): Builder
|
|
{
|
|
return $this->publicSurfaceQuery()
|
|
->published()
|
|
->where('id', '!=', $excludeWorldId)
|
|
->where(function (Builder $builder): void {
|
|
$builder->where(function (Builder $active): void {
|
|
$active->where('is_active_campaign', true)
|
|
->where(function (Builder $window): void {
|
|
$window->whereRaw('COALESCE(promotion_ends_at, ends_at) IS NULL')
|
|
->orWhereRaw('COALESCE(promotion_ends_at, ends_at) >= ?', [now()->toDateTimeString()]);
|
|
});
|
|
})->orWhere(function (Builder $featured): void {
|
|
$featured->where('is_featured', true)
|
|
->current();
|
|
});
|
|
})
|
|
->orderByDesc('is_active_campaign')
|
|
->orderByDesc('is_homepage_featured')
|
|
->orderByRaw('COALESCE(campaign_priority, 0) DESC')
|
|
->orderByDesc('is_featured')
|
|
->orderByRaw('COALESCE(promotion_starts_at, starts_at) ASC')
|
|
->orderBy('title');
|
|
}
|
|
|
|
private function resolveHomepageSupportingItem(World $world, ?User $viewer = null): ?array
|
|
{
|
|
$relation = $world->worldRelations
|
|
->first(fn (WorldRelation $item): bool => in_array((string) $item->related_type, [WorldRelation::TYPE_CHALLENGE, WorldRelation::TYPE_NEWS, WorldRelation::TYPE_EVENT], true));
|
|
|
|
if (! $relation) {
|
|
return null;
|
|
}
|
|
|
|
return $this->resolveEntityPreview((string) $relation->related_type, (int) $relation->related_id, $viewer, (string) ($relation->context_label ?? ''));
|
|
}
|
|
|
|
private function firstCanonicalWorld(Builder $query): ?World
|
|
{
|
|
return $query
|
|
->get()
|
|
->first(fn (World $world): bool => $this->isCanonicalSurfaceWorld($world));
|
|
}
|
|
|
|
private function campaignStateKey(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';
|
|
}
|
|
|
|
if ($world->isCurrent()) {
|
|
return 'current';
|
|
}
|
|
|
|
return (string) $world->status;
|
|
}
|
|
|
|
private function campaignStateLabel(World $world): string
|
|
{
|
|
return match ($this->campaignStateKey($world)) {
|
|
'live_now' => 'Live now',
|
|
'upcoming' => 'Upcoming',
|
|
'archived' => 'Archived',
|
|
'current' => 'Current',
|
|
World::STATUS_PUBLISHED => 'Published',
|
|
World::STATUS_ARCHIVED => 'Archived',
|
|
default => 'Draft',
|
|
};
|
|
}
|
|
|
|
private function statusBadges(World $world, ?GroupChallenge $linkedChallenge = null): 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'];
|
|
} elseif ((string) $world->status === World::STATUS_ARCHIVED || ($world->ends_at && $world->ends_at->isPast())) {
|
|
$badges[] = ['label' => 'Archived', 'tone' => 'amber'];
|
|
}
|
|
|
|
if ($world->isEndingSoon()) {
|
|
$badges[] = ['label' => 'Ending soon', 'tone' => 'amber'];
|
|
}
|
|
|
|
if ((bool) $world->is_homepage_featured || (bool) $world->is_featured) {
|
|
$badges[] = ['label' => 'Featured', 'tone' => 'rose'];
|
|
}
|
|
|
|
if ($world->hasPublishedRecap()) {
|
|
$badges[] = ['label' => 'Recap live', 'tone' => 'sky'];
|
|
}
|
|
|
|
if ($linkedChallenge) {
|
|
$challengeState = $this->challengeLifecycleStateForWorld($world, $linkedChallenge);
|
|
if (in_array($challengeState['key'], ['upcoming', 'open', 'voting', 'judging', 'winners_announced'], true)) {
|
|
$badges[] = ['label' => $challengeState['label'], 'tone' => $challengeState['tone']];
|
|
}
|
|
}
|
|
|
|
return collect($badges)
|
|
->unique('label')
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
public function linkedWorldForChallenge(GroupChallenge $challenge): ?array
|
|
{
|
|
$world = $this->firstCanonicalWorld(
|
|
$this->publicSurfaceQuery()
|
|
->publiclyVisible()
|
|
->where('linked_challenge_id', (int) $challenge->id)
|
|
->orderByDesc('is_active_campaign')
|
|
->orderByDesc('published_at')
|
|
->limit(12)
|
|
);
|
|
|
|
return $world ? $this->mapWorldCard($world, $this->phaseForWorld($world)) : null;
|
|
}
|
|
|
|
private function preferredLinkedChallenge(World $world, ?User $viewer = null): ?GroupChallenge
|
|
{
|
|
$world->loadMissing(['linkedChallenge.group', 'worldRelations']);
|
|
|
|
$linkedChallenge = $world->linkedChallenge;
|
|
|
|
if ($linkedChallenge && $linkedChallenge->group && $linkedChallenge->canBeViewedBy($viewer)) {
|
|
return $linkedChallenge;
|
|
}
|
|
|
|
$fallbackChallengeId = $world->worldRelations
|
|
->first(fn (WorldRelation $relation): bool => (string) $relation->related_type === WorldRelation::TYPE_CHALLENGE)?->related_id;
|
|
|
|
if (! $fallbackChallengeId) {
|
|
return null;
|
|
}
|
|
|
|
$fallback = GroupChallenge::query()->with('group')->find((int) $fallbackChallengeId);
|
|
|
|
if (! $fallback || ! $fallback->group || ! $fallback->canBeViewedBy($viewer)) {
|
|
return null;
|
|
}
|
|
|
|
return $fallback;
|
|
}
|
|
|
|
private function linkedChallengePanelPayload(World $world, ?User $viewer = null, ?GroupChallenge $linkedChallenge = null): ?array
|
|
{
|
|
if (! (bool) ($world->show_linked_challenge_section ?? true)) {
|
|
return null;
|
|
}
|
|
|
|
$challenge = $linkedChallenge ?? $this->preferredLinkedChallenge($world, $viewer);
|
|
|
|
if (! $challenge) {
|
|
return null;
|
|
}
|
|
|
|
$state = $this->challengeLifecycleStateForWorld($world, $challenge);
|
|
$story = $this->linkedChallengeStoryPayload($world, $challenge, $state);
|
|
$summary = trim((string) ($world->challenge_teaser_override ?: $challenge->summary ?: $challenge->description ?: ''));
|
|
|
|
return [
|
|
'id' => (int) $challenge->id,
|
|
'title' => (string) $challenge->title,
|
|
'summary' => $summary !== '' ? Str::limit($summary, 220) : null,
|
|
'cover_url' => $challenge->coverUrl(),
|
|
'url' => $this->challengeUrl($challenge),
|
|
'challenge_url' => $this->challengeUrl($challenge),
|
|
'group' => $challenge->group ? [
|
|
'name' => (string) $challenge->group->name,
|
|
'url' => $challenge->group->publicUrl(),
|
|
] : null,
|
|
'status' => (string) $challenge->status,
|
|
'state' => $state['key'],
|
|
'state_label' => $state['label'],
|
|
'state_tone' => $state['tone'],
|
|
'cta_label' => $this->linkedChallengePrimaryCtaLabel($state, $story),
|
|
'cta_url' => $this->linkedChallengePrimaryUrl($world, $challenge, $state, $story),
|
|
'timeframe_label' => $this->challengeTimeframeLabel($challenge),
|
|
'entry_count' => (int) ($challenge->artwork_links_count ?? $challenge->artworkLinks()->count()),
|
|
'has_winner' => $this->challengeOutcomeArtworkIds($challenge, GroupChallengeOutcome::TYPE_WINNER)->isNotEmpty(),
|
|
'show_entries' => (bool) ($world->show_linked_challenge_entries ?? true),
|
|
'show_winners' => (bool) ($world->show_linked_challenge_winners ?? true),
|
|
'show_finalists' => (bool) ($world->show_linked_challenge_finalists ?? true),
|
|
'supports_finalists' => true,
|
|
'story' => $story,
|
|
];
|
|
}
|
|
|
|
private function challengeLifecycleStateForWorld(World $world, GroupChallenge $challenge): array
|
|
{
|
|
$state = $this->challengeLifecycleState($challenge);
|
|
|
|
if ((string) $world->status === World::STATUS_ARCHIVED || ($world->ends_at && $world->ends_at->isPast())) {
|
|
return [
|
|
'key' => 'closed',
|
|
'label' => $state['key'] === 'winners_announced' ? 'Winners announced' : 'Challenge closed',
|
|
'tone' => $state['key'] === 'winners_announced' ? 'amber' : 'slate',
|
|
'cta_label' => 'View challenge recap',
|
|
];
|
|
}
|
|
|
|
return $state;
|
|
}
|
|
|
|
private function linkedChallengeEntriesPayload(World $world, GroupChallenge $challenge, ?User $viewer = null): ?array
|
|
{
|
|
if (! (bool) ($world->show_linked_challenge_entries ?? true)) {
|
|
return null;
|
|
}
|
|
|
|
$state = $this->challengeLifecycleStateForWorld($world, $challenge);
|
|
$winnerIds = $this->challengeOutcomeArtworkIds($challenge, GroupChallengeOutcome::TYPE_WINNER)->all();
|
|
$finalistIds = $this->challengeOutcomeArtworkIds($challenge, GroupChallengeOutcome::TYPE_FINALIST)->all();
|
|
$hiddenArtworkIds = $this->hiddenLinkedChallengeArtworkIds($world);
|
|
$items = $this->visibleChallengeArtworkQuery($challenge, $viewer)
|
|
->when($hiddenArtworkIds !== [], fn (Builder $builder): Builder => $builder->whereNotIn('artworks.id', $hiddenArtworkIds))
|
|
->orderBy('group_challenge_artworks.sort_order')
|
|
->limit(12)
|
|
->get()
|
|
->map(function (Artwork $artwork) use ($state, $winnerIds, $finalistIds): array {
|
|
$status = 'entry';
|
|
|
|
if (in_array((int) $artwork->id, $winnerIds, true)) {
|
|
$status = 'winner';
|
|
} elseif (in_array((int) $artwork->id, $finalistIds, true)) {
|
|
$status = 'finalist';
|
|
}
|
|
|
|
return $this->mapLinkedChallengeArtwork($artwork, $status, $state['key']);
|
|
})
|
|
->all();
|
|
|
|
if ($items === []) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'title' => 'Challenge entries',
|
|
'description' => $state['key'] === 'closed'
|
|
? 'Entries from the linked challenge remain visible here so the world recap preserves the full field of work.'
|
|
: 'Entries pulled directly from the linked challenge so the world stays current without duplicating editorial relations.',
|
|
'hidden_count' => count($hiddenArtworkIds),
|
|
'items' => $items,
|
|
];
|
|
}
|
|
|
|
private function linkedChallengeWinnersPayload(World $world, GroupChallenge $challenge, ?User $viewer = null): ?array
|
|
{
|
|
if (! (bool) ($world->show_linked_challenge_winners ?? true)) {
|
|
return null;
|
|
}
|
|
|
|
$state = $this->challengeLifecycleStateForWorld($world, $challenge);
|
|
$items = $this->linkedChallengeOutcomeItems($challenge, GroupChallengeOutcome::TYPE_WINNER, $viewer, $state['key']);
|
|
|
|
if ($items->isEmpty()) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'title' => $items->count() === 1 ? 'Challenge winner' : 'Challenge winners',
|
|
'description' => $state['key'] === 'winners_announced'
|
|
? 'The linked challenge has published results, and those winners are being carried into the world automatically.'
|
|
: 'This world is carrying the linked challenge result forward so the campaign recap stays visible here too.',
|
|
'item' => $items->first(),
|
|
'items' => $items->all(),
|
|
];
|
|
}
|
|
|
|
private function linkedChallengeFinalistsPayload(World $world, GroupChallenge $challenge, ?User $viewer = null): ?array
|
|
{
|
|
if (! (bool) ($world->show_linked_challenge_finalists ?? true)) {
|
|
return null;
|
|
}
|
|
|
|
$state = $this->challengeLifecycleStateForWorld($world, $challenge);
|
|
$items = $this->linkedChallengeOutcomeItems($challenge, GroupChallengeOutcome::TYPE_FINALIST, $viewer, $state['key']);
|
|
|
|
if ($items->isEmpty()) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'title' => 'Challenge finalists',
|
|
'description' => $state['key'] === 'closed'
|
|
? 'Finalists from the linked challenge remain visible here so the archived world keeps the complete recap in view.'
|
|
: 'Finalists from the linked challenge are being pulled directly into the world so the campaign recap reflects the full result set.',
|
|
'items' => $items->all(),
|
|
];
|
|
}
|
|
|
|
private function linkedChallengeStoryPayload(World $world, GroupChallenge $challenge, ?array $state = null): ?array
|
|
{
|
|
$world->loadMissing('worldRelations');
|
|
|
|
$newsRelations = $world->worldRelations
|
|
->where('related_type', WorldRelation::TYPE_NEWS)
|
|
->values();
|
|
|
|
if ($newsRelations->isEmpty()) {
|
|
return null;
|
|
}
|
|
|
|
$state = $state ?? $this->challengeLifecycleStateForWorld($world, $challenge);
|
|
$intent = in_array($state['key'] ?? null, ['winners_announced', 'closed'], true) ? 'recap' : 'announcement';
|
|
|
|
$candidate = $newsRelations
|
|
->map(function (WorldRelation $relation) use ($intent): ?array {
|
|
$preview = $this->resolveNewsPreview(
|
|
(int) $relation->related_id,
|
|
trim((string) ($relation->context_label ?? '')),
|
|
);
|
|
|
|
if (! $preview) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'preview' => $preview,
|
|
'score' => $this->linkedChallengeStoryScore($preview, $relation, $intent),
|
|
'sort_key' => sprintf(
|
|
'%06d:%06d:%09d',
|
|
999999 - $this->linkedChallengeStoryScore($preview, $relation, $intent),
|
|
(bool) $relation->is_featured ? 0 : 1,
|
|
(int) $relation->sort_order,
|
|
),
|
|
];
|
|
})
|
|
->filter()
|
|
->sortBy('sort_key')
|
|
->first();
|
|
|
|
if (! $candidate) {
|
|
return null;
|
|
}
|
|
|
|
if ($intent === 'recap' && (int) ($candidate['score'] ?? 0) < 25) {
|
|
return null;
|
|
}
|
|
|
|
return array_merge($candidate['preview'], [
|
|
'intent' => $intent,
|
|
'eyebrow' => $intent === 'recap' ? 'Challenge recap' : 'Challenge story',
|
|
'cta_label' => $intent === 'recap' ? 'Read recap' : 'Read story',
|
|
]);
|
|
}
|
|
|
|
private function linkedChallengeStoryScore(array $preview, WorldRelation $relation, string $intent): int
|
|
{
|
|
$haystack = Str::lower(implode(' ', array_filter([
|
|
(string) ($relation->context_label ?? ''),
|
|
(string) ($preview['title'] ?? ''),
|
|
(string) ($preview['subtitle'] ?? ''),
|
|
(string) ($preview['description'] ?? ''),
|
|
])));
|
|
|
|
$score = (bool) $relation->is_featured ? 18 : 0;
|
|
$keywords = $intent === 'recap'
|
|
? [
|
|
'recap' => 40,
|
|
'results' => 36,
|
|
'winner' => 34,
|
|
'winners' => 34,
|
|
'finalist' => 30,
|
|
'finalists' => 30,
|
|
'roundup' => 22,
|
|
'highlights' => 18,
|
|
]
|
|
: [
|
|
'challenge' => 22,
|
|
'announcement' => 20,
|
|
'announce' => 20,
|
|
'launch' => 18,
|
|
'opens' => 16,
|
|
'open' => 12,
|
|
'submissions' => 14,
|
|
'join' => 12,
|
|
'call for entries' => 20,
|
|
];
|
|
|
|
foreach ($keywords as $keyword => $weight) {
|
|
if (Str::contains($haystack, $keyword)) {
|
|
$score += $weight;
|
|
}
|
|
}
|
|
|
|
return $score;
|
|
}
|
|
|
|
private function linkedChallengePrimaryCtaLabel(?array $state, ?array $story): ?string
|
|
{
|
|
if (! $state) {
|
|
return null;
|
|
}
|
|
|
|
if (($story['intent'] ?? null) !== 'recap') {
|
|
return $state['cta_label'] ?? null;
|
|
}
|
|
|
|
return match ($state['key'] ?? null) {
|
|
'winners_announced' => 'Read results recap',
|
|
'closed' => 'View challenge recap',
|
|
default => $state['cta_label'] ?? null,
|
|
};
|
|
}
|
|
|
|
private function linkedChallengePrimaryUrl(World $world, GroupChallenge $challenge, ?array $state = null, ?array $story = null): string
|
|
{
|
|
$state = $state ?? $this->challengeLifecycleStateForWorld($world, $challenge);
|
|
$story = $story ?? $this->linkedChallengeStoryPayload($world, $challenge, $state);
|
|
|
|
if (($story['intent'] ?? null) === 'recap' && in_array($state['key'] ?? null, ['winners_announced', 'closed'], true)) {
|
|
return (string) $story['url'];
|
|
}
|
|
|
|
return $this->challengeUrl($challenge);
|
|
}
|
|
|
|
private function visibleChallengeArtworkQuery(GroupChallenge $challenge, ?User $viewer = null): Builder
|
|
{
|
|
$query = Artwork::query()
|
|
->select('artworks.*', 'group_challenge_artworks.sort_order as challenge_sort_order')
|
|
->join('group_challenge_artworks', function ($join) use ($challenge): void {
|
|
$join->on('group_challenge_artworks.artwork_id', '=', 'artworks.id')
|
|
->where('group_challenge_artworks.group_challenge_id', '=', $challenge->id);
|
|
})
|
|
->with(['user.profile', 'categories.contentType', 'stats'])
|
|
->catalogVisible();
|
|
|
|
$this->maturity->applyViewerFilter($query, $viewer);
|
|
|
|
return $query;
|
|
}
|
|
|
|
private function mapLinkedChallengeArtwork(Artwork $artwork, string $status = 'entry', string $stateKey = 'open', ?string $statusLabelOverride = null, ?string $note = null, ?int $position = null): array
|
|
{
|
|
$resource = ArtworkListResource::make($artwork)->toArray(request());
|
|
$views = (int) ($artwork->stats?->views ?? 0);
|
|
$statusLabel = $statusLabelOverride ?: match ($status) {
|
|
'winner' => $stateKey === 'winners_announced' ? 'Winner announced' : 'Winner',
|
|
'finalist' => 'Finalist',
|
|
'runner_up' => 'Runner-up',
|
|
'honorable_mention' => 'Honorable Mention',
|
|
'featured' => 'Featured',
|
|
default => 'Challenge entry',
|
|
};
|
|
$contextLabel = match ($status) {
|
|
'winner' => 'Linked challenge winner',
|
|
'finalist' => 'Linked challenge finalist',
|
|
'runner_up' => 'Linked challenge runner-up',
|
|
'honorable_mention' => 'Linked challenge honorable mention',
|
|
'featured' => 'Linked challenge featured entry',
|
|
default => 'Linked challenge entry',
|
|
};
|
|
|
|
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) ($note ?: $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' => $statusLabel,
|
|
'context_label' => $contextLabel,
|
|
'meta' => array_values(array_filter([
|
|
$position ? 'Place ' . $position : null,
|
|
$resource['category']['name'] ?? null,
|
|
$views > 0 ? number_format($views) . ' views' : null,
|
|
])),
|
|
];
|
|
}
|
|
|
|
private function challengeLifecycleState(GroupChallenge $challenge): array
|
|
{
|
|
$now = now();
|
|
$status = (string) $challenge->status;
|
|
$startsAt = $challenge->start_at;
|
|
$endsAt = $challenge->end_at;
|
|
$hasWinner = $this->challengeOutcomeArtworkIds($challenge, GroupChallengeOutcome::TYPE_WINNER)->isNotEmpty();
|
|
|
|
if ($status === GroupChallenge::STATUS_DRAFT) {
|
|
return ['key' => 'draft', 'label' => 'Challenge draft', 'tone' => 'slate', 'cta_label' => 'Preview challenge'];
|
|
}
|
|
|
|
if ($hasWinner) {
|
|
return ['key' => 'winners_announced', 'label' => 'Winners announced', 'tone' => 'amber', 'cta_label' => 'See results'];
|
|
}
|
|
|
|
if ($status === GroupChallenge::STATUS_ARCHIVED) {
|
|
return ['key' => 'closed', 'label' => 'Challenge closed', 'tone' => 'slate', 'cta_label' => 'View challenge recap'];
|
|
}
|
|
|
|
if ($status === GroupChallenge::STATUS_ENDED || ($endsAt && $endsAt->isPast())) {
|
|
if ((string) ($challenge->judging_mode ?? '') === 'community_vote') {
|
|
return ['key' => 'voting', 'label' => 'Voting live', 'tone' => 'sky', 'cta_label' => 'View entries'];
|
|
}
|
|
|
|
return ['key' => 'judging', 'label' => 'Judging now', 'tone' => 'violet', 'cta_label' => 'Track challenge'];
|
|
}
|
|
|
|
if ($startsAt && $startsAt->isFuture()) {
|
|
return ['key' => 'upcoming', 'label' => 'Challenge upcoming', 'tone' => 'sky', 'cta_label' => 'Challenge opens soon'];
|
|
}
|
|
|
|
if (in_array($status, [GroupChallenge::STATUS_ACTIVE, GroupChallenge::STATUS_PUBLISHED], true) || ! $startsAt || $startsAt->lte($now)) {
|
|
return ['key' => 'open', 'label' => 'Challenge open', 'tone' => 'emerald', 'cta_label' => 'Join challenge'];
|
|
}
|
|
|
|
return ['key' => 'closed', 'label' => 'Challenge closed', 'tone' => 'slate', 'cta_label' => 'View challenge recap'];
|
|
}
|
|
|
|
private function challengeOutcomeArtworkIds(GroupChallenge $challenge, string $type): SupportCollection
|
|
{
|
|
$challenge->loadMissing('outcomes');
|
|
|
|
$ids = $challenge->outcomes
|
|
->where('outcome_type', $type)
|
|
->pluck('artwork_id')
|
|
->map(fn ($id): int => (int) $id)
|
|
->filter(fn (int $id): bool => $id > 0)
|
|
->values();
|
|
|
|
if ($type === GroupChallengeOutcome::TYPE_WINNER && $ids->isEmpty() && (int) ($challenge->featured_artwork_id ?? 0) > 0) {
|
|
return collect([(int) $challenge->featured_artwork_id]);
|
|
}
|
|
|
|
return $ids;
|
|
}
|
|
|
|
private function linkedChallengeOutcomeItems(GroupChallenge $challenge, string $type, ?User $viewer = null, string $stateKey = 'open'): SupportCollection
|
|
{
|
|
$artworkIds = $this->challengeOutcomeArtworkIds($challenge, $type);
|
|
|
|
if ($artworkIds->isEmpty()) {
|
|
return collect();
|
|
}
|
|
|
|
$challenge->loadMissing('outcomes');
|
|
$artworks = $this->visibleChallengeArtworkQuery($challenge, $viewer)
|
|
->whereIn('artworks.id', $artworkIds->all())
|
|
->get()
|
|
->keyBy(fn (Artwork $artwork): int => (int) $artwork->id);
|
|
|
|
$outcomes = $challenge->outcomes
|
|
->where('outcome_type', $type)
|
|
->values();
|
|
|
|
if ($outcomes->isEmpty() && $type === GroupChallengeOutcome::TYPE_WINNER && (int) ($challenge->featured_artwork_id ?? 0) > 0) {
|
|
$artwork = $artworks->get((int) $challenge->featured_artwork_id);
|
|
|
|
return $artwork
|
|
? collect([$this->mapLinkedChallengeArtwork($artwork, 'winner', $stateKey)])
|
|
: collect();
|
|
}
|
|
|
|
return $outcomes
|
|
->map(function (GroupChallengeOutcome $outcome) use ($artworks, $stateKey, $type): ?array {
|
|
$artwork = $artworks->get((int) $outcome->artwork_id);
|
|
|
|
if (! $artwork) {
|
|
return null;
|
|
}
|
|
|
|
return $this->mapLinkedChallengeArtwork(
|
|
$artwork,
|
|
$type,
|
|
$stateKey,
|
|
$outcome->title_override,
|
|
$outcome->note,
|
|
$outcome->position,
|
|
);
|
|
})
|
|
->filter()
|
|
->values();
|
|
}
|
|
|
|
private function challengeTimeframeLabel(GroupChallenge $challenge): ?string
|
|
{
|
|
if ($challenge->start_at && $challenge->end_at) {
|
|
return $challenge->start_at->format('M j') . ' - ' . $challenge->end_at->format('M j, Y');
|
|
}
|
|
|
|
if ($challenge->start_at) {
|
|
return 'Starts ' . $challenge->start_at->format('M j, Y');
|
|
}
|
|
|
|
if ($challenge->end_at) {
|
|
return 'Ends ' . $challenge->end_at->format('M j, Y');
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function challengeUrl(GroupChallenge $challenge): string
|
|
{
|
|
return route('groups.challenges.show', ['group' => $challenge->group, 'challenge' => $challenge]);
|
|
}
|
|
|
|
private function normalizeLinkedChallengeId(mixed $value): ?int
|
|
{
|
|
$id = (int) $value;
|
|
|
|
return $id > 0 ? $id : null;
|
|
}
|
|
|
|
private function normalizeArtworkIdList(mixed $value): array
|
|
{
|
|
return collect((array) $value)
|
|
->map(fn ($id): int => (int) $id)
|
|
->filter(fn (int $id): bool => $id > 0)
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function hiddenLinkedChallengeArtworkIds(World $world): array
|
|
{
|
|
return $this->normalizeArtworkIdList($world->hidden_linked_challenge_artwork_ids_json ?? []);
|
|
}
|
|
|
|
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 paginationMeta(LengthAwarePaginator $paginator): array
|
|
{
|
|
return [
|
|
'current_page' => $paginator->currentPage(),
|
|
'last_page' => $paginator->lastPage(),
|
|
'per_page' => $paginator->perPage(),
|
|
'total' => $paginator->total(),
|
|
'from' => $paginator->firstItem(),
|
|
'to' => $paginator->lastItem(),
|
|
];
|
|
}
|
|
|
|
private function resolveSlug(string $title, World $world, array $data): string
|
|
{
|
|
$requested = trim(Str::slug((string) ($data['slug'] ?? '')));
|
|
$base = $requested !== '' ? $requested : Str::slug($title);
|
|
$slug = $base !== '' ? $base : 'world';
|
|
$counter = 2;
|
|
|
|
while (World::query()
|
|
->where('slug', $slug)
|
|
->when($world->exists, fn (Builder $builder) => $builder->where('id', '!=', $world->id))
|
|
->exists()) {
|
|
$slug = Str::limit($base !== '' ? $base : 'world', 170, '') . '-' . $counter;
|
|
$counter++;
|
|
}
|
|
|
|
return $slug;
|
|
}
|
|
|
|
private function normalizePublishedAt(string $status, mixed $value): ?Carbon
|
|
{
|
|
if ($status === World::STATUS_PUBLISHED) {
|
|
return $value ? Carbon::parse((string) $value) : now();
|
|
}
|
|
|
|
return $value ? Carbon::parse((string) $value) : null;
|
|
}
|
|
|
|
private function normalizeRecapArticleId(mixed $value): ?int
|
|
{
|
|
$articleId = (int) $value;
|
|
|
|
return $articleId > 0 ? $articleId : null;
|
|
}
|
|
|
|
private function normalizeRecapStatsSnapshot(mixed $value): ?array
|
|
{
|
|
if (! is_array($value)) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'captured_at' => is_string($value['captured_at'] ?? null) ? $value['captured_at'] : now()->toIso8601String(),
|
|
'summary' => array_map(fn ($item): int => (int) $item, (array) ($value['summary'] ?? [])),
|
|
];
|
|
}
|
|
|
|
private function normalizeSectionOrder(iterable $sectionOrder): array
|
|
{
|
|
$valid = array_keys((array) config('worlds.sections', []));
|
|
|
|
return collect($sectionOrder)
|
|
->map(fn ($key) => trim((string) $key))
|
|
->filter(fn (string $key) => in_array($key, $valid, true))
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function normalizeSectionVisibility(iterable $visibility): array
|
|
{
|
|
$valid = array_keys((array) config('worlds.sections', []));
|
|
|
|
return collect($visibility)
|
|
->mapWithKeys(fn ($value, $key): array => [(string) $key => (bool) $value])
|
|
->filter(fn (bool $enabled, string $key): bool => in_array($key, $valid, true))
|
|
->all();
|
|
}
|
|
|
|
private function nullableText(mixed $value): ?string
|
|
{
|
|
$text = trim((string) ($value ?? ''));
|
|
|
|
return $text === '' ? null : $text;
|
|
}
|
|
|
|
private function themePayload(World $world): array
|
|
{
|
|
$preset = $this->resolvedThemePreset($world);
|
|
|
|
return [
|
|
'key' => $world->theme_key,
|
|
'label' => (string) ($preset['label'] ?? Str::headline((string) ($world->theme_key ?: $world->type))),
|
|
'accent_color' => $world->accent_color ?: ($preset['accent_color'] ?? '#38bdf8'),
|
|
'accent_color_secondary' => $world->accent_color_secondary ?: ($preset['accent_color_secondary'] ?? '#0f172a'),
|
|
'background_motif' => $world->background_motif ?: ($preset['background_motif'] ?? 'atmosphere'),
|
|
'icon_name' => $this->resolvedIconName($world, $preset),
|
|
];
|
|
}
|
|
|
|
private function resolvedIconName(World $world, ?array $theme = null): string
|
|
{
|
|
$theme ??= $this->resolvedThemePreset($world);
|
|
|
|
$icon = $this->supportedIconName($world->icon_name ?? null);
|
|
if ($icon !== null) {
|
|
return $icon;
|
|
}
|
|
|
|
$themeIcon = $this->supportedIconName($theme['icon_name'] ?? null);
|
|
if ($themeIcon !== null) {
|
|
return $themeIcon;
|
|
}
|
|
|
|
return 'fa-solid fa-globe';
|
|
}
|
|
|
|
private function resolvedThemePreset(World $world): array
|
|
{
|
|
$themeKey = trim((string) ($world->theme_key ?? ''));
|
|
if ($themeKey !== '') {
|
|
$preset = (array) config('worlds.themes.' . $themeKey, []);
|
|
if ($preset !== []) {
|
|
return $preset;
|
|
}
|
|
}
|
|
|
|
$typeKey = trim((string) $world->type);
|
|
|
|
return (array) config('worlds.themes.' . $typeKey, []);
|
|
}
|
|
|
|
private function supportedIconName(mixed $icon): ?string
|
|
{
|
|
$value = trim((string) ($icon ?? ''));
|
|
|
|
if ($value === '' || ! Str::startsWith($value, 'fa-')) {
|
|
return null;
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
private function timeframeLabel(World $world): ?string
|
|
{
|
|
if ($world->starts_at && $world->ends_at) {
|
|
return $world->starts_at->format('d M Y') . ' - ' . $world->ends_at->format('d M Y');
|
|
}
|
|
|
|
if ($world->starts_at) {
|
|
return 'Starts ' . $world->starts_at->format('d M Y');
|
|
}
|
|
|
|
if ($world->ends_at) {
|
|
return 'Through ' . $world->ends_at->format('d M Y');
|
|
}
|
|
|
|
return $world->edition_year ? 'Edition ' . $world->edition_year : null;
|
|
}
|
|
|
|
private function familyEditionsForWorld(World $world): SupportCollection
|
|
{
|
|
$recurrenceKey = trim((string) ($world->recurrence_key ?? ''));
|
|
|
|
if ($recurrenceKey === '') {
|
|
return collect();
|
|
}
|
|
|
|
return $this->familyEditionsForRecurrenceKey($recurrenceKey);
|
|
}
|
|
|
|
private function familyEditionsForRecurrenceKey(string $recurrenceKey): SupportCollection
|
|
{
|
|
if (! array_key_exists($recurrenceKey, $this->recurrenceEditionCache)) {
|
|
$this->recurrenceEditionCache[$recurrenceKey] = $this->publicSurfaceQuery()
|
|
->publiclyVisible()
|
|
->where('recurrence_key', $recurrenceKey)
|
|
->orderByDesc('edition_year')
|
|
->orderByDesc('starts_at')
|
|
->orderByDesc('published_at')
|
|
->get();
|
|
}
|
|
|
|
return collect($this->recurrenceEditionCache[$recurrenceKey]);
|
|
}
|
|
|
|
private function canonicalEditionForWorld(World $world): ?World
|
|
{
|
|
$recurrenceKey = trim((string) ($world->recurrence_key ?? ''));
|
|
|
|
if ($recurrenceKey === '') {
|
|
return null;
|
|
}
|
|
|
|
return $this->canonicalEditionForRecurrenceKey($recurrenceKey);
|
|
}
|
|
|
|
private function canonicalEditionForRecurrenceKey(string $recurrenceKey): ?World
|
|
{
|
|
if (! array_key_exists($recurrenceKey, $this->recurrenceCanonicalCache)) {
|
|
$this->recurrenceCanonicalCache[$recurrenceKey] = $this->selectCanonicalEdition($this->familyEditionsForRecurrenceKey($recurrenceKey));
|
|
}
|
|
|
|
return $this->recurrenceCanonicalCache[$recurrenceKey];
|
|
}
|
|
|
|
private function selectCanonicalEdition(SupportCollection $editions): ?World
|
|
{
|
|
return $editions
|
|
->sortBy([
|
|
fn (World $edition): int => (string) $edition->status === World::STATUS_PUBLISHED ? 0 : 1,
|
|
fn (World $edition): int => $edition->isCurrent() ? 0 : 1,
|
|
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();
|
|
}
|
|
|
|
private function filterCanonicalSurfaceWorlds(SupportCollection $worlds): SupportCollection
|
|
{
|
|
return $worlds
|
|
->filter(fn (World $world): bool => $this->isCanonicalSurfaceWorld($world))
|
|
->values();
|
|
}
|
|
|
|
private function isCanonicalSurfaceWorld(World $world): bool
|
|
{
|
|
$recurrenceKey = trim((string) ($world->recurrence_key ?? ''));
|
|
|
|
if (! $world->is_recurring || $recurrenceKey === '') {
|
|
return true;
|
|
}
|
|
|
|
return (int) ($this->canonicalEditionForRecurrenceKey($recurrenceKey)?->id ?? 0) === (int) $world->id;
|
|
}
|
|
|
|
private function publicUrlForWorld(World $world): string
|
|
{
|
|
$recurrenceKey = trim((string) ($world->recurrence_key ?? ''));
|
|
|
|
if (! $world->is_recurring || $recurrenceKey === '') {
|
|
return route('worlds.show', ['world' => $world->slug]);
|
|
}
|
|
|
|
if ($this->isCanonicalSurfaceWorld($world)) {
|
|
return route('worlds.show', ['world' => $recurrenceKey]);
|
|
}
|
|
|
|
if ($world->edition_year !== null) {
|
|
return route('worlds.editions.show', ['world' => $recurrenceKey, 'year' => $world->edition_year]);
|
|
}
|
|
|
|
return route('worlds.show', ['world' => $recurrenceKey]);
|
|
}
|
|
|
|
private function publicPathForWorld(World $world): string
|
|
{
|
|
$recurrenceKey = trim((string) ($world->recurrence_key ?? ''));
|
|
|
|
if (! $world->is_recurring || $recurrenceKey === '') {
|
|
return route('worlds.show', ['world' => $world->slug], false);
|
|
}
|
|
|
|
if ($this->isCanonicalSurfaceWorld($world)) {
|
|
return route('worlds.show', ['world' => $recurrenceKey], false);
|
|
}
|
|
|
|
if ($world->edition_year !== null) {
|
|
return route('worlds.editions.show', ['world' => $recurrenceKey, 'year' => $world->edition_year], false);
|
|
}
|
|
|
|
return route('worlds.show', ['world' => $recurrenceKey], false);
|
|
}
|
|
|
|
private function familyUrlForWorld(World $world): ?string
|
|
{
|
|
$recurrenceKey = trim((string) ($world->recurrence_key ?? ''));
|
|
|
|
return $recurrenceKey !== '' ? route('worlds.show', ['world' => $recurrenceKey]) : null;
|
|
}
|
|
|
|
private function editionUrlForWorld(World $world): ?string
|
|
{
|
|
$recurrenceKey = trim((string) ($world->recurrence_key ?? ''));
|
|
|
|
if (! $world->is_recurring || $recurrenceKey === '' || $world->edition_year === null) {
|
|
return null;
|
|
}
|
|
|
|
return route('worlds.editions.show', ['world' => $recurrenceKey, 'year' => $world->edition_year]);
|
|
}
|
|
|
|
private function recurrenceFamilyLabel(World $world): ?string
|
|
{
|
|
$recurrenceKey = trim((string) ($world->recurrence_key ?? ''));
|
|
|
|
if ($recurrenceKey === '') {
|
|
return null;
|
|
}
|
|
|
|
return Str::title(str_replace('-', ' ', $recurrenceKey));
|
|
}
|
|
|
|
private function mapRecurringFamilySummary(World $world): ?array
|
|
{
|
|
$recurrenceKey = trim((string) ($world->recurrence_key ?? ''));
|
|
|
|
if ($recurrenceKey === '') {
|
|
return null;
|
|
}
|
|
|
|
return $this->buildRecurringFamilySummary($recurrenceKey, $this->familyEditionsForRecurrenceKey($recurrenceKey));
|
|
}
|
|
|
|
private function recurringFamilyIndexPayload(int $limit = 8): array
|
|
{
|
|
return $this->publicSurfaceQuery()
|
|
->publiclyVisible()
|
|
->whereNotNull('recurrence_key')
|
|
->get()
|
|
->filter(fn (World $world): bool => trim((string) ($world->recurrence_key ?? '')) !== '')
|
|
->groupBy(fn (World $world): string => (string) $world->recurrence_key)
|
|
->map(fn (SupportCollection $editions, string $recurrenceKey): array => $this->buildRecurringFamilySummary($recurrenceKey, $editions))
|
|
->sortBy([
|
|
fn (array $family): int => match ((string) ($family['current_world']['campaign_state'] ?? '')) {
|
|
'live_now' => 0,
|
|
'upcoming' => 1,
|
|
default => 2,
|
|
},
|
|
fn (array $family): int => -1 * (int) ($family['current_world']['campaign_priority'] ?? 0),
|
|
fn (array $family): int => -1 * (int) ($family['latest_edition_year'] ?? 0),
|
|
fn (array $family): string => Str::lower((string) ($family['title'] ?? '')),
|
|
])
|
|
->take($limit)
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function buildRecurringFamilySummary(string $recurrenceKey, SupportCollection $editions): array
|
|
{
|
|
$canonicalEdition = $this->canonicalEditionForRecurrenceKey($recurrenceKey);
|
|
$otherEditions = $editions
|
|
->reject(fn (World $edition): bool => (int) $edition->id === (int) ($canonicalEdition?->id ?? 0))
|
|
->sortBy([
|
|
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,
|
|
])
|
|
->values();
|
|
|
|
$currentWorld = $canonicalEdition ? $this->mapWorldCard($canonicalEdition, $this->phaseForWorld($canonicalEdition)) : null;
|
|
|
|
return [
|
|
'id' => 'family-' . $recurrenceKey,
|
|
'key' => $recurrenceKey,
|
|
'title' => Str::title(str_replace('-', ' ', $recurrenceKey)),
|
|
'public_url' => route('worlds.show', ['world' => $recurrenceKey]),
|
|
'current_world' => $currentWorld,
|
|
'theme' => $currentWorld['theme'] ?? null,
|
|
'summary' => $currentWorld['summary'] ?? null,
|
|
'tagline' => $currentWorld['tagline'] ?? null,
|
|
'cover_url' => $currentWorld['cover_url'] ?? null,
|
|
'latest_edition_year' => $canonicalEdition?->edition_year,
|
|
'edition_count' => $editions->count(),
|
|
'archive_count' => $otherEditions->count(),
|
|
'years' => $editions->pluck('edition_year')->filter()->map(fn ($year): int => (int) $year)->unique()->sortDesc()->values()->all(),
|
|
'previous_editions' => $otherEditions
|
|
->take(3)
|
|
->map(fn (World $edition): array => [
|
|
'id' => (int) $edition->id,
|
|
'title' => (string) $edition->title,
|
|
'edition_year' => $edition->edition_year,
|
|
'public_url' => $this->publicUrlForWorld($edition),
|
|
])
|
|
->all(),
|
|
];
|
|
}
|
|
|
|
private function adjacentEditionForWorld(World $world, string $direction): ?World
|
|
{
|
|
$familyEditions = $this->familyEditionsForWorld($world);
|
|
|
|
if ($familyEditions->isEmpty() || $world->edition_year === null) {
|
|
return null;
|
|
}
|
|
|
|
$currentYear = (int) $world->edition_year;
|
|
|
|
if ($direction === 'previous') {
|
|
return $familyEditions
|
|
->filter(fn (World $edition): bool => $edition->edition_year !== null && (int) $edition->edition_year < $currentYear)
|
|
->sortByDesc([
|
|
fn (World $edition): int => (int) ($edition->edition_year ?? 0),
|
|
fn (World $edition): int => $edition->starts_at?->getTimestamp() ?? $edition->published_at?->getTimestamp() ?? 0,
|
|
fn (World $edition): int => (int) $edition->id,
|
|
])
|
|
->first();
|
|
}
|
|
|
|
return $familyEditions
|
|
->filter(fn (World $edition): bool => $edition->edition_year !== null && (int) $edition->edition_year > $currentYear)
|
|
->sortBy([
|
|
fn (World $edition): int => (int) ($edition->edition_year ?? 0),
|
|
fn (World $edition): int => $edition->starts_at?->getTimestamp() ?? $edition->published_at?->getTimestamp() ?? 0,
|
|
fn (World $edition): int => (int) $edition->id,
|
|
])
|
|
->first();
|
|
}
|
|
|
|
private function archiveNoticePayload(World $world, ?World $canonicalEdition): ?array
|
|
{
|
|
$familyTitle = $this->recurrenceFamilyLabel($world);
|
|
|
|
if ($familyTitle === null) {
|
|
return null;
|
|
}
|
|
|
|
if ($canonicalEdition && (int) $canonicalEdition->id !== (int) $world->id) {
|
|
return [
|
|
'eyebrow' => 'Archived edition',
|
|
'title' => sprintf('You are viewing the %s archived edition of %s.', (string) ($world->edition_year ?? 'previous'), $familyTitle),
|
|
'description' => $world->hasPublishedRecap()
|
|
? 'Past editions remain public as part of the recurring campaign archive, and this edition now carries a published recap.'
|
|
: 'Past editions remain public as part of the recurring campaign archive.',
|
|
'current_edition' => $this->mapWorldCard($canonicalEdition, $this->phaseForWorld($canonicalEdition)),
|
|
];
|
|
}
|
|
|
|
if ((string) $world->status === World::STATUS_ARCHIVED || ($world->ends_at && $world->ends_at->isPast())) {
|
|
return [
|
|
'eyebrow' => 'Archive edition',
|
|
'title' => sprintf('You are viewing the latest archived edition of %s.', $familyTitle),
|
|
'description' => $world->hasPublishedRecap()
|
|
? 'No newer public edition is live right now, but this archived edition now preserves its highlights as a published recap.'
|
|
: 'No newer public edition is live right now, but the family archive remains readable and linked together.',
|
|
'current_edition' => null,
|
|
];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function phaseForWorld(World $world): string
|
|
{
|
|
if ($world->isActiveCampaign()) {
|
|
return 'active';
|
|
}
|
|
|
|
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 'archive';
|
|
}
|
|
|
|
return 'featured';
|
|
}
|
|
|
|
private function duplicateTitle(World $world): string
|
|
{
|
|
$title = trim((string) $world->title);
|
|
|
|
return $title === '' ? 'World copy' : $title . ' Copy';
|
|
}
|
|
|
|
private function nextEditionYear(World $world): int
|
|
{
|
|
return max((int) now()->year, (int) ($world->edition_year ?? now()->year)) + 1;
|
|
}
|
|
|
|
private function nextEditionTitle(World $world): string
|
|
{
|
|
$nextYear = $this->nextEditionYear($world);
|
|
$title = $this->stripTrailingEditionYear(trim((string) $world->title));
|
|
|
|
if ($title === '') {
|
|
return 'World ' . $nextYear;
|
|
}
|
|
|
|
return $title . ' ' . $nextYear;
|
|
}
|
|
|
|
private function nextEditionSlug(World $world): string
|
|
{
|
|
$nextYear = $this->nextEditionYear($world);
|
|
$slug = $this->inferredRecurrenceKey($world);
|
|
|
|
if ($slug === '') {
|
|
return 'world-' . $nextYear;
|
|
}
|
|
|
|
return $slug . '-' . $nextYear;
|
|
}
|
|
|
|
private function inferredRecurrenceKey(World $world): string
|
|
{
|
|
$recurrenceKey = trim((string) ($world->recurrence_key ?? ''));
|
|
|
|
if ($recurrenceKey !== '') {
|
|
return $recurrenceKey;
|
|
}
|
|
|
|
$slug = Str::slug($this->stripTrailingEditionYear(trim((string) $world->slug)));
|
|
if ($slug !== '') {
|
|
return $slug;
|
|
}
|
|
|
|
$title = Str::slug($this->stripTrailingEditionYear(trim((string) $world->title)));
|
|
|
|
return $title !== '' ? $title : 'world';
|
|
}
|
|
|
|
private function stripTrailingEditionYear(string $value): string
|
|
{
|
|
$trimmed = trim($value);
|
|
|
|
if ($trimmed === '') {
|
|
return '';
|
|
}
|
|
|
|
return trim((string) preg_replace('/(?:[\s-]+)(?:19|20)\d{2}$/', '', $trimmed));
|
|
}
|
|
|
|
private function searchArtworks(string $query): array
|
|
{
|
|
return Artwork::query()
|
|
->with(['user.profile', 'group', 'categories.contentType'])
|
|
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
|
|
->select('artworks.*')
|
|
->where('artwork_status', 'published')
|
|
->where('visibility', Artwork::VISIBILITY_PUBLIC)
|
|
->when($query !== '', function (Builder $builder) use ($query): void {
|
|
$builder->where(function (Builder $nested) use ($query): void {
|
|
$like = '%' . $query . '%';
|
|
|
|
$nested->where('title', 'like', $like)
|
|
->orWhere('slug', 'like', $like)
|
|
->orWhere('description', 'like', $like)
|
|
->orWhereHas('user', function (Builder $userQuery) use ($like): void {
|
|
$userQuery->where('username', 'like', $like)
|
|
->orWhere('name', 'like', $like);
|
|
})
|
|
->orWhereHas('group', function (Builder $groupQuery) use ($like): void {
|
|
$groupQuery->where('name', 'like', $like)
|
|
->orWhere('slug', 'like', $like);
|
|
})
|
|
->orWhereHas('categories', function (Builder $categoryQuery) use ($like): void {
|
|
$categoryQuery->where('name', 'like', $like)
|
|
->orWhere('slug', 'like', $like)
|
|
->orWhereHas('contentType', function (Builder $contentTypeQuery) use ($like): void {
|
|
$contentTypeQuery->where('name', 'like', $like)
|
|
->orWhere('slug', 'like', $like);
|
|
});
|
|
});
|
|
});
|
|
})
|
|
->orderByRaw('COALESCE(artwork_stats.views, 0) DESC')
|
|
->orderByDesc('artworks.published_at')
|
|
->limit(8)
|
|
->get()
|
|
->map(fn (Artwork $artwork): ?array => $this->resolveArtworkPreview((int) $artwork->id, ''))
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function searchCollections(string $query, ?User $viewer): array
|
|
{
|
|
return Collection::query()
|
|
->with(['user.profile', 'coverArtwork'])
|
|
->public()
|
|
->when($query !== '', function (Builder $builder) use ($query): void {
|
|
$builder->where(function (Builder $nested) use ($query): void {
|
|
$nested->where('title', 'like', '%' . $query . '%')
|
|
->orWhere('slug', 'like', '%' . $query . '%')
|
|
->orWhere('summary', 'like', '%' . $query . '%');
|
|
});
|
|
})
|
|
->orderByDesc('followers_count')
|
|
->limit(8)
|
|
->get()
|
|
->map(fn (Collection $collection): ?array => $this->resolveCollectionPreview((int) $collection->id, $viewer, ''))
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function searchUsers(string $query): array
|
|
{
|
|
return User::query()
|
|
->with(['profile', 'statistics'])
|
|
->when($query !== '', function (Builder $builder) use ($query): void {
|
|
$builder->where(function (Builder $nested) use ($query): void {
|
|
$nested->where('username', 'like', '%' . $query . '%')
|
|
->orWhere('name', 'like', '%' . $query . '%');
|
|
});
|
|
})
|
|
->orderByDesc('nova_featured_creator')
|
|
->orderBy('username')
|
|
->limit(8)
|
|
->get()
|
|
->map(fn (User $user): ?array => $this->resolveUserPreview((int) $user->id, ''))
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function searchGroups(string $query, ?User $viewer): array
|
|
{
|
|
return Group::query()
|
|
->with(['owner.profile', 'recruitmentProfile', 'discoveryMetric', 'members', 'badges'])
|
|
->public()
|
|
->when($query !== '', function (Builder $builder) use ($query): void {
|
|
$builder->where(function (Builder $nested) use ($query): void {
|
|
$nested->where('name', 'like', '%' . $query . '%')
|
|
->orWhere('slug', 'like', '%' . $query . '%')
|
|
->orWhere('headline', 'like', '%' . $query . '%');
|
|
});
|
|
})
|
|
->orderByDesc('followers_count')
|
|
->limit(8)
|
|
->get()
|
|
->map(fn (Group $group): ?array => $this->resolveGroupPreview((int) $group->id, $viewer, ''))
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function searchNews(string $query): array
|
|
{
|
|
return NewsArticle::query()
|
|
->with(['author.profile', 'category'])
|
|
->published()
|
|
->when($query !== '', function (Builder $builder) use ($query): void {
|
|
$builder->where(function (Builder $nested) use ($query): void {
|
|
$nested->where('title', 'like', '%' . $query . '%')
|
|
->orWhere('slug', 'like', '%' . $query . '%')
|
|
->orWhere('excerpt', 'like', '%' . $query . '%');
|
|
});
|
|
})
|
|
->orderByDesc('published_at')
|
|
->limit(8)
|
|
->get()
|
|
->map(fn (NewsArticle $article): ?array => $this->resolveNewsPreview((int) $article->id, ''))
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function searchChallenges(string $query, ?User $viewer): array
|
|
{
|
|
return GroupChallenge::query()
|
|
->with('group')
|
|
->when($query !== '', fn (Builder $builder): Builder => $builder->where('title', 'like', '%' . $query . '%'))
|
|
->orderByDesc('start_at')
|
|
->limit(8)
|
|
->get()
|
|
->map(fn (GroupChallenge $challenge): ?array => $this->resolveChallengePreview((int) $challenge->id, $viewer, ''))
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function searchEvents(string $query, ?User $viewer): array
|
|
{
|
|
return GroupEvent::query()
|
|
->with('group')
|
|
->when($query !== '', fn (Builder $builder): Builder => $builder->where('title', 'like', '%' . $query . '%'))
|
|
->orderByDesc('start_at')
|
|
->limit(8)
|
|
->get()
|
|
->map(fn (GroupEvent $event): ?array => $this->resolveEventPreview((int) $event->id, $viewer, ''))
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function searchReleases(string $query, ?User $viewer): array
|
|
{
|
|
return GroupRelease::query()
|
|
->with('group')
|
|
->when($query !== '', fn (Builder $builder): Builder => $builder->where('title', 'like', '%' . $query . '%'))
|
|
->orderByDesc('published_at')
|
|
->limit(8)
|
|
->get()
|
|
->map(fn (GroupRelease $release): ?array => $this->resolveReleasePreview((int) $release->id, $viewer, ''))
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function searchCards(string $query, ?User $viewer): array
|
|
{
|
|
return NovaCard::query()
|
|
->with(['user.profile', 'category'])
|
|
->when($query !== '', function (Builder $builder) use ($query): void {
|
|
$builder->where(function (Builder $nested) use ($query): void {
|
|
$nested->where('title', 'like', '%' . $query . '%')
|
|
->orWhere('slug', 'like', '%' . $query . '%')
|
|
->orWhere('description', 'like', '%' . $query . '%');
|
|
});
|
|
})
|
|
->orderByDesc('published_at')
|
|
->limit(8)
|
|
->get()
|
|
->map(fn (NovaCard $card): ?array => $this->resolveCardPreview((int) $card->id, $viewer, ''))
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
public function resolveEntityPreview(string $type, int $entityId, ?User $viewer = null, string $contextLabel = ''): ?array
|
|
{
|
|
return match ($type) {
|
|
WorldRelation::TYPE_ARTWORK => $this->resolveArtworkPreview($entityId, $contextLabel),
|
|
WorldRelation::TYPE_COLLECTION => $this->resolveCollectionPreview($entityId, $viewer, $contextLabel),
|
|
WorldRelation::TYPE_USER => $this->resolveUserPreview($entityId, $contextLabel),
|
|
WorldRelation::TYPE_GROUP => $this->resolveGroupPreview($entityId, $viewer, $contextLabel),
|
|
WorldRelation::TYPE_NEWS => $this->resolveNewsPreview($entityId, $contextLabel),
|
|
WorldRelation::TYPE_CHALLENGE => $this->resolveChallengePreview($entityId, $viewer, $contextLabel),
|
|
WorldRelation::TYPE_EVENT => $this->resolveEventPreview($entityId, $viewer, $contextLabel),
|
|
WorldRelation::TYPE_RELEASE => $this->resolveReleasePreview($entityId, $viewer, $contextLabel),
|
|
WorldRelation::TYPE_CARD => $this->resolveCardPreview($entityId, $viewer, $contextLabel),
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
public function previewArtwork(Artwork $artwork, string $contextLabel = ''): ?array
|
|
{
|
|
if ((string) $artwork->artwork_status !== 'published' || (string) $artwork->visibility !== Artwork::VISIBILITY_PUBLIC) {
|
|
return null;
|
|
}
|
|
|
|
$artwork->loadMissing(['user.profile', 'categories.contentType', 'stats']);
|
|
$resource = ArtworkListResource::make($artwork)->toArray(request());
|
|
$views = (int) ($artwork->stats?->views ?? 0);
|
|
|
|
return [
|
|
'id' => (int) $artwork->id,
|
|
'entity_type' => WorldRelation::TYPE_ARTWORK,
|
|
'entity_label' => (string) (config('worlds.relation_types.artwork') ?? 'Artwork'),
|
|
'title' => (string) ($resource['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'),
|
|
'avatar' => null,
|
|
'context_label' => $contextLabel !== '' ? $contextLabel : 'Featured artwork',
|
|
'meta' => array_values(array_filter([
|
|
$resource['category']['name'] ?? null,
|
|
$views > 0 ? number_format($views) . ' views' : null,
|
|
])),
|
|
];
|
|
}
|
|
|
|
public function previewCollection(Collection $collection, ?User $viewer = null, string $contextLabel = ''): ?array
|
|
{
|
|
$collection->loadMissing(['user.profile', 'group', 'coverArtwork']);
|
|
|
|
if (! $collection->canBeViewedBy($viewer) || ! $collection->user?->username) {
|
|
return null;
|
|
}
|
|
|
|
$payload = $this->collections->mapCollectionCardPayloads([$collection], false, $viewer)[0] ?? null;
|
|
|
|
if (! $payload) {
|
|
return null;
|
|
}
|
|
|
|
return array_merge($payload, [
|
|
'entity_type' => WorldRelation::TYPE_COLLECTION,
|
|
'entity_label' => (string) (config('worlds.relation_types.collection') ?? 'Collection'),
|
|
'context_label' => $contextLabel !== '' ? $contextLabel : 'Curated collection',
|
|
]);
|
|
}
|
|
|
|
public function previewUser(User $user, string $contextLabel = ''): ?array
|
|
{
|
|
$user->loadMissing(['profile', 'statistics']);
|
|
|
|
if (! $user->username) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'id' => (int) $user->id,
|
|
'entity_type' => WorldRelation::TYPE_USER,
|
|
'entity_label' => (string) (config('worlds.relation_types.user') ?? 'Creator'),
|
|
'title' => (string) ($user->name ?: $user->username),
|
|
'subtitle' => $user->username ? '@' . $user->username : null,
|
|
'description' => Str::limit((string) ($user->profile?->description ?? $user->profile?->about ?? ''), 140),
|
|
'url' => route('profile.show', ['username' => strtolower((string) $user->username)]),
|
|
'image' => $user->cover_hash && $user->cover_ext ? CoverUrl::forUser($user->cover_hash, $user->cover_ext, $user->updated_at?->timestamp) : null,
|
|
'avatar' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 128),
|
|
'context_label' => $contextLabel !== '' ? $contextLabel : 'Featured creator',
|
|
'meta' => array_values(array_filter([
|
|
$user->nova_featured_creator ? 'Featured creator' : null,
|
|
(int) $user->followers_count > 0 ? number_format((int) $user->followers_count) . ' followers' : null,
|
|
])),
|
|
];
|
|
}
|
|
|
|
public function previewGroup(Group $group, ?User $viewer = null, string $contextLabel = ''): ?array
|
|
{
|
|
$group->loadMissing(['owner.profile', 'recruitmentProfile', 'discoveryMetric', 'members', 'badges']);
|
|
|
|
if (! $group->canBeViewedBy($viewer)) {
|
|
return null;
|
|
}
|
|
|
|
$card = $this->groups->mapGroupCard($group, $viewer);
|
|
|
|
return array_merge($card, [
|
|
'entity_type' => WorldRelation::TYPE_GROUP,
|
|
'entity_label' => (string) (config('worlds.relation_types.group') ?? 'Group'),
|
|
'title' => (string) ($card['name'] ?? $group->name),
|
|
'subtitle' => (string) (($card['owner']['name'] ?? null) ?: 'Group'),
|
|
'description' => (string) (($card['bio_excerpt'] ?? null) ?: ($card['headline'] ?? '')),
|
|
'url' => route('groups.show', ['group' => $group->slug]),
|
|
'image' => $card['banner_url'] ?? null,
|
|
'avatar' => $card['avatar_url'] ?? null,
|
|
'meta' => array_values(array_filter([
|
|
(bool) ($card['is_verified'] ?? false) ? 'Verified group' : null,
|
|
((int) data_get($card, 'counts.followers', 0)) > 0 ? number_format((int) data_get($card, 'counts.followers', 0)) . ' followers' : null,
|
|
((int) data_get($card, 'counts.artworks', 0)) > 0 ? number_format((int) data_get($card, 'counts.artworks', 0)) . ' artworks' : null,
|
|
])),
|
|
'context_label' => $contextLabel !== '' ? $contextLabel : 'Featured group',
|
|
]);
|
|
}
|
|
|
|
public function previewNews(NewsArticle $article, string $contextLabel = ''): ?array
|
|
{
|
|
return [
|
|
'id' => (int) $article->id,
|
|
'entity_type' => WorldRelation::TYPE_NEWS,
|
|
'entity_label' => (string) (config('worlds.relation_types.news') ?? 'News article'),
|
|
'title' => (string) $article->title,
|
|
'subtitle' => (string) ($article->category?->name ?? 'News'),
|
|
'description' => Str::limit((string) ($article->excerpt ?: strip_tags((string) $article->content)), 160),
|
|
'url' => route('news.show', ['slug' => $article->slug]),
|
|
'image' => $article->cover_url,
|
|
'avatar' => $article->author ? AvatarUrl::forUser((int) $article->author->id, $article->author->profile?->avatar_hash, 72) : null,
|
|
'context_label' => $contextLabel !== '' ? $contextLabel : 'Related story',
|
|
'meta' => array_values(array_filter([
|
|
$article->published_at?->format('d M Y'),
|
|
(string) ($article->type_label ?? ''),
|
|
])),
|
|
];
|
|
}
|
|
|
|
public function previewChallenge(GroupChallenge $challenge, ?User $viewer = null, string $contextLabel = ''): ?array
|
|
{
|
|
$challenge->loadMissing(['group', 'outcomes']);
|
|
|
|
if (! $challenge->group || ! $challenge->canBeViewedBy($viewer)) {
|
|
return null;
|
|
}
|
|
|
|
$winnerIds = $this->challengeOutcomeArtworkIds($challenge, GroupChallengeOutcome::TYPE_WINNER)->all();
|
|
$finalistIds = $this->challengeOutcomeArtworkIds($challenge, GroupChallengeOutcome::TYPE_FINALIST)->all();
|
|
$entryPreviewItems = $this->visibleChallengeArtworkQuery($challenge, $viewer)
|
|
->orderBy('group_challenge_artworks.sort_order')
|
|
->limit(12)
|
|
->get()
|
|
->map(function (Artwork $artwork) use ($winnerIds, $finalistIds): array {
|
|
$status = 'entry';
|
|
|
|
if (in_array((int) $artwork->id, $winnerIds, true)) {
|
|
$status = 'winner';
|
|
} elseif (in_array((int) $artwork->id, $finalistIds, true)) {
|
|
$status = 'finalist';
|
|
}
|
|
|
|
return [
|
|
'id' => (int) $artwork->id,
|
|
'title' => (string) $artwork->title,
|
|
'image' => $artwork->thumbUrl('sm'),
|
|
'status' => $status,
|
|
];
|
|
})
|
|
->all();
|
|
|
|
return [
|
|
'id' => (int) $challenge->id,
|
|
'entity_type' => WorldRelation::TYPE_CHALLENGE,
|
|
'entity_label' => (string) (config('worlds.relation_types.challenge') ?? 'Challenge'),
|
|
'title' => (string) $challenge->title,
|
|
'subtitle' => (string) $challenge->group->name,
|
|
'description' => Str::limit((string) ($challenge->summary ?: $challenge->description ?: ''), 140),
|
|
'url' => route('groups.challenges.show', ['group' => $challenge->group, 'challenge' => $challenge]),
|
|
'image' => $challenge->coverUrl(),
|
|
'avatar' => $challenge->group->avatarUrl(),
|
|
'context_label' => $contextLabel !== '' ? $contextLabel : 'Join the challenge',
|
|
'meta' => array_values(array_filter([
|
|
$challenge->start_at?->format('d M Y'),
|
|
Str::headline((string) $challenge->status),
|
|
])),
|
|
'judging_mode' => (string) ($challenge->judging_mode ?? ''),
|
|
'entry_preview_items' => $entryPreviewItems,
|
|
];
|
|
}
|
|
|
|
private function resolveArtworkPreview(int $entityId, string $contextLabel): ?array
|
|
{
|
|
$artwork = Artwork::query()->with(['user.profile', 'categories.contentType', 'stats'])->find($entityId);
|
|
|
|
if (! $artwork) {
|
|
return null;
|
|
}
|
|
|
|
return $this->previewArtwork($artwork, $contextLabel);
|
|
}
|
|
|
|
private function resolveCollectionPreview(int $entityId, ?User $viewer, string $contextLabel): ?array
|
|
{
|
|
$collection = Collection::query()->with(['user.profile', 'group', 'coverArtwork'])->find($entityId);
|
|
|
|
if (! $collection) {
|
|
return null;
|
|
}
|
|
|
|
return $this->previewCollection($collection, $viewer, $contextLabel);
|
|
}
|
|
|
|
private function resolveUserPreview(int $entityId, string $contextLabel): ?array
|
|
{
|
|
$user = User::query()->with(['profile', 'statistics'])->find($entityId);
|
|
|
|
if (! $user) {
|
|
return null;
|
|
}
|
|
|
|
return $this->previewUser($user, $contextLabel);
|
|
}
|
|
|
|
private function resolveGroupPreview(int $entityId, ?User $viewer, string $contextLabel): ?array
|
|
{
|
|
$group = Group::query()->with(['owner.profile', 'recruitmentProfile', 'discoveryMetric', 'members', 'badges'])->find($entityId);
|
|
|
|
if (! $group) {
|
|
return null;
|
|
}
|
|
|
|
return $this->previewGroup($group, $viewer, $contextLabel);
|
|
}
|
|
|
|
private function resolveNewsPreview(int $entityId, string $contextLabel): ?array
|
|
{
|
|
$article = NewsArticle::query()->with(['author.profile', 'category'])->published()->find($entityId);
|
|
|
|
if (! $article) {
|
|
return null;
|
|
}
|
|
|
|
return $this->previewNews($article, $contextLabel);
|
|
}
|
|
|
|
private function resolveChallengePreview(int $entityId, ?User $viewer, string $contextLabel): ?array
|
|
{
|
|
$challenge = GroupChallenge::query()->with(['group', 'outcomes'])->find($entityId);
|
|
|
|
if (! $challenge) {
|
|
return null;
|
|
}
|
|
|
|
return $this->previewChallenge($challenge, $viewer, $contextLabel);
|
|
}
|
|
|
|
private function resolveEventPreview(int $entityId, ?User $viewer, string $contextLabel): ?array
|
|
{
|
|
$event = GroupEvent::query()->with('group')->find($entityId);
|
|
|
|
if (! $event || ! $event->group || ! $event->canBeViewedBy($viewer)) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'id' => (int) $event->id,
|
|
'entity_type' => WorldRelation::TYPE_EVENT,
|
|
'entity_label' => (string) (config('worlds.relation_types.event') ?? 'Event'),
|
|
'title' => (string) $event->title,
|
|
'subtitle' => (string) $event->group->name,
|
|
'description' => Str::limit((string) ($event->summary ?: $event->description ?: ''), 140),
|
|
'url' => route('groups.events.show', ['group' => $event->group, 'event' => $event]),
|
|
'image' => $event->coverUrl(),
|
|
'avatar' => $event->group->avatarUrl(),
|
|
'context_label' => $contextLabel !== '' ? $contextLabel : 'Related event',
|
|
'meta' => array_values(array_filter([
|
|
$event->start_at?->format('d M Y H:i'),
|
|
$event->location,
|
|
])),
|
|
];
|
|
}
|
|
|
|
private function resolveReleasePreview(int $entityId, ?User $viewer, string $contextLabel): ?array
|
|
{
|
|
$release = GroupRelease::query()->with('group')->find($entityId);
|
|
|
|
if (! $release || ! $release->group || ! $release->canBeViewedBy($viewer)) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'id' => (int) $release->id,
|
|
'entity_type' => WorldRelation::TYPE_RELEASE,
|
|
'entity_label' => (string) (config('worlds.relation_types.release') ?? 'Release'),
|
|
'title' => (string) $release->title,
|
|
'subtitle' => (string) $release->group->name,
|
|
'description' => Str::limit((string) ($release->summary ?: $release->description ?: ''), 140),
|
|
'url' => route('groups.releases.show', ['group' => $release->group, 'release' => $release]),
|
|
'image' => $release->coverUrl(),
|
|
'avatar' => $release->group->avatarUrl(),
|
|
'context_label' => $contextLabel !== '' ? $contextLabel : 'Release spotlight',
|
|
'meta' => array_values(array_filter([
|
|
$release->published_at?->format('d M Y'),
|
|
Str::headline((string) $release->status),
|
|
])),
|
|
];
|
|
}
|
|
|
|
private function resolveCardPreview(int $entityId, ?User $viewer, string $contextLabel): ?array
|
|
{
|
|
$card = NovaCard::query()->with(['user.profile', 'category'])->find($entityId);
|
|
|
|
if (! $card || ! $card->canBeViewedBy($viewer)) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'id' => (int) $card->id,
|
|
'entity_type' => WorldRelation::TYPE_CARD,
|
|
'entity_label' => (string) (config('worlds.relation_types.card') ?? 'Card'),
|
|
'title' => (string) ($card->title ?: 'Untitled card'),
|
|
'subtitle' => (string) ($card->category?->name ?? ''),
|
|
'description' => Str::limit((string) ($card->description ?? $card->quote_text ?? ''), 140),
|
|
'url' => $card->publicUrl(),
|
|
'image' => $card->previewUrl(),
|
|
'avatar' => $card->user ? AvatarUrl::forUser((int) $card->user->id, $card->user->profile?->avatar_hash, 72) : null,
|
|
'context_label' => $contextLabel !== '' ? $contextLabel : 'Themed card',
|
|
'meta' => array_values(array_filter([
|
|
Str::headline((string) $card->format),
|
|
(int) $card->likes_count > 0 ? number_format((int) $card->likes_count) . ' likes' : null,
|
|
])),
|
|
];
|
|
}
|
|
|
|
private function entityMapKey(string $type, int $id): string
|
|
{
|
|
return $type . ':' . $id;
|
|
}
|
|
|
|
private function deleteWorldMediaIfReplaced(string $originalPath, string $currentPath): void
|
|
{
|
|
$trimmedOriginal = ltrim(trim($originalPath), '/');
|
|
$trimmedCurrent = ltrim(trim($currentPath), '/');
|
|
|
|
if ($trimmedOriginal === '' || $trimmedOriginal === $trimmedCurrent || ! Str::startsWith($trimmedOriginal, 'worlds/media/')) {
|
|
return;
|
|
}
|
|
|
|
Storage::disk((string) config('covers.disk', 's3'))->delete($trimmedOriginal);
|
|
}
|
|
} |