Entries
-No entries linked yet.
} -Entries
+No entries linked yet.
} +diff --git a/app/Enums/WorldRewardType.php b/app/Enums/WorldRewardType.php
new file mode 100644
index 00000000..231611e5
--- /dev/null
+++ b/app/Enums/WorldRewardType.php
@@ -0,0 +1,60 @@
+ 'Participant',
+ self::Featured => 'Featured',
+ self::Finalist => 'Finalist',
+ self::Winner => 'Winner',
+ self::Spotlight => 'Spotlight',
+ };
+ }
+
+ public function tone(): string
+ {
+ return match ($this) {
+ self::Participant => 'sky',
+ self::Featured => 'amber',
+ self::Finalist => 'violet',
+ self::Winner => 'emerald',
+ self::Spotlight => 'rose',
+ };
+ }
+
+ public function source(): string
+ {
+ return match ($this) {
+ self::Participant, self::Featured => 'automatic',
+ self::Finalist, self::Winner, self::Spotlight => 'manual',
+ };
+ }
+
+ public function isAutomatic(): bool
+ {
+ return $this->source() === 'automatic';
+ }
+
+ public function xpReward(): int
+ {
+ return match ($this) {
+ self::Participant => 20,
+ self::Featured => 40,
+ self::Finalist => 70,
+ self::Winner => 120,
+ self::Spotlight => 55,
+ };
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Controllers/Api/WorldAnalyticsEventController.php b/app/Http/Controllers/Api/WorldAnalyticsEventController.php
new file mode 100644
index 00000000..8569b95d
--- /dev/null
+++ b/app/Http/Controllers/Api/WorldAnalyticsEventController.php
@@ -0,0 +1,43 @@
+validate([
+ 'world_id' => ['required', 'integer', 'exists:worlds,id'],
+ 'event_type' => ['required', 'string', Rule::in($this->analytics->allowedEventTypes())],
+ 'section_key' => ['sometimes', 'nullable', 'string', 'max:80'],
+ 'cta_key' => ['sometimes', 'nullable', 'string', 'max:80'],
+ 'entity_type' => ['sometimes', 'nullable', 'string', 'max:40'],
+ 'entity_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
+ 'entity_title' => ['sometimes', 'nullable', 'string', 'max:180'],
+ 'challenge_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
+ 'source_surface' => ['sometimes', 'nullable', 'string', Rule::in($this->analytics->allowedSourceSurfaces())],
+ 'source_detail' => ['sometimes', 'nullable', 'string', 'max:80'],
+ 'visitor_token' => ['sometimes', 'nullable', 'string', 'max:100'],
+ 'meta' => ['sometimes', 'array'],
+ ]);
+
+ $this->analytics->recordEvent($request, $payload);
+
+ return response()->json([
+ 'ok' => true,
+ ], 202);
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Controllers/GroupChallengeController.php b/app/Http/Controllers/GroupChallengeController.php
index 78f64a34..cf5da81d 100644
--- a/app/Http/Controllers/GroupChallengeController.php
+++ b/app/Http/Controllers/GroupChallengeController.php
@@ -10,6 +10,7 @@ use App\Models\Group;
use App\Models\GroupChallenge;
use App\Services\GroupChallengeService;
use App\Services\GroupService;
+use App\Services\Worlds\WorldService;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -22,6 +23,7 @@ class GroupChallengeController extends Controller
public function __construct(
private readonly GroupService $groups,
private readonly GroupChallengeService $challenges,
+ private readonly WorldService $worlds,
) {
}
@@ -56,6 +58,7 @@ class GroupChallengeController extends Controller
return Inertia::render('Group/GroupChallengeShow', [
'group' => $groupPayload,
'challenge' => $challengePayload,
+ 'linkedWorld' => $this->worlds->linkedWorldForChallenge($challenge),
'seo' => $seo,
])->rootView('collections');
}
diff --git a/app/Http/Controllers/Studio/GroupChallengeStudioController.php b/app/Http/Controllers/Studio/GroupChallengeStudioController.php
index 91e732b9..161e8e60 100644
--- a/app/Http/Controllers/Studio/GroupChallengeStudioController.php
+++ b/app/Http/Controllers/Studio/GroupChallengeStudioController.php
@@ -11,6 +11,7 @@ use App\Http\Requests\Groups\UpdateGroupChallengeRequest;
use App\Models\Artwork;
use App\Models\Group;
use App\Models\GroupChallenge;
+use App\Models\GroupChallengeOutcome;
use App\Services\GroupChallengeService;
use App\Services\GroupService;
use Illuminate\Http\RedirectResponse;
@@ -53,6 +54,7 @@ class GroupChallengeStudioController extends Controller
'visibilityOptions' => collect((array) config('groups.challenges.visibility_options', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(),
'participationScopeOptions' => collect((array) config('groups.challenges.participation_scopes', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucwords(str_replace('_', ' ', $value))])->values()->all(),
'judgingModeOptions' => collect((array) config('groups.challenges.judging_modes', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucwords(str_replace('_', ' ', $value))])->values()->all(),
+ 'outcomeTypeOptions' => collect(GroupChallengeOutcome::supportedTypes())->map(fn (string $value): array => ['value' => $value, 'label' => GroupChallengeOutcome::labelForType($value)])->values()->all(),
'collectionOptions' => $group->collections()->orderBy('title')->get(['id', 'title'])->map(fn ($collection): array => ['id' => (int) $collection->id, 'title' => $collection->title])->values()->all(),
'projectOptions' => $group->projects()->orderBy('title')->get(['id', 'title'])->map(fn ($project): array => ['id' => (int) $project->id, 'title' => $project->title])->values()->all(),
'artworkOptions' => $group->artworks()->whereNull('deleted_at')->latest('updated_at')->limit(30)->get(['id', 'title'])->map(fn ($artwork): array => ['id' => (int) $artwork->id, 'title' => $artwork->title])->values()->all(),
@@ -84,6 +86,7 @@ class GroupChallengeStudioController extends Controller
'visibilityOptions' => collect((array) config('groups.challenges.visibility_options', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucfirst($value)])->values()->all(),
'participationScopeOptions' => collect((array) config('groups.challenges.participation_scopes', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucwords(str_replace('_', ' ', $value))])->values()->all(),
'judgingModeOptions' => collect((array) config('groups.challenges.judging_modes', []))->map(fn (string $value): array => ['value' => $value, 'label' => ucwords(str_replace('_', ' ', $value))])->values()->all(),
+ 'outcomeTypeOptions' => collect(GroupChallengeOutcome::supportedTypes())->map(fn (string $value): array => ['value' => $value, 'label' => GroupChallengeOutcome::labelForType($value)])->values()->all(),
'collectionOptions' => $group->collections()->orderBy('title')->get(['id', 'title'])->map(fn ($collection): array => ['id' => (int) $collection->id, 'title' => $collection->title])->values()->all(),
'projectOptions' => $group->projects()->orderBy('title')->get(['id', 'title'])->map(fn ($project): array => ['id' => (int) $project->id, 'title' => $project->title])->values()->all(),
'artworkOptions' => $group->artworks()->whereNull('deleted_at')->latest('updated_at')->limit(30)->get(['id', 'title'])->map(fn ($artwork): array => ['id' => (int) $artwork->id, 'title' => $artwork->title])->values()->all(),
diff --git a/app/Http/Controllers/Studio/StudioWorldController.php b/app/Http/Controllers/Studio/StudioWorldController.php
index eb365442..74248f42 100644
--- a/app/Http/Controllers/Studio/StudioWorldController.php
+++ b/app/Http/Controllers/Studio/StudioWorldController.php
@@ -4,11 +4,15 @@ declare(strict_types=1);
namespace App\Http\Controllers\Studio;
+use App\Enums\WorldRewardType;
use App\Http\Controllers\Controller;
use App\Http\Requests\Worlds\StoreWorldRequest;
use App\Http\Requests\Worlds\UpdateWorldRequest;
use App\Models\World;
use App\Models\WorldSubmission;
+use App\Services\Worlds\WorldAnalyticsService;
+use App\Services\Worlds\WorldEditorialSuggestionService;
+use App\Services\Worlds\WorldRewardService;
use App\Services\Worlds\WorldService;
use App\Services\Worlds\WorldSubmissionService;
use App\Support\Seo\SeoFactory;
@@ -22,7 +26,10 @@ final class StudioWorldController extends Controller
{
public function __construct(
private readonly WorldService $worlds,
+ private readonly WorldEditorialSuggestionService $editorialSuggestions,
private readonly WorldSubmissionService $submissions,
+ private readonly WorldRewardService $rewards,
+ private readonly WorldAnalyticsService $analytics,
)
{
}
@@ -35,6 +42,7 @@ final class StudioWorldController extends Controller
'title' => 'Worlds',
'description' => 'Create and manage seasonal, event, and campaign destinations across Skinbase Nova.',
'listing' => $this->worlds->studioListing($request->only(['q', 'status', 'type', 'per_page'])),
+ 'analytics' => $this->analytics->portfolioReport(),
'statusOptions' => [
['value' => World::STATUS_DRAFT, 'label' => 'Draft'],
['value' => World::STATUS_PUBLISHED, 'label' => 'Published'],
@@ -76,6 +84,8 @@ final class StudioWorldController extends Controller
],
'storeUrl' => route('studio.worlds.store'),
'entitySearchUrl' => route('studio.worlds.entity-search'),
+ 'suggestions' => null,
+ 'suggestionActions' => null,
'duplicateActions' => null,
'mediaSupport' => [
'picker_available' => false,
@@ -123,12 +133,23 @@ final class StudioWorldController extends Controller
'updateUrl' => route('studio.worlds.update', ['world' => $world]),
'previewUrl' => route('studio.worlds.preview', ['world' => $world]),
'publishUrl' => route('studio.worlds.publish', ['world' => $world]),
+ 'publishRecapUrl' => route('studio.worlds.recap.publish', ['world' => $world]),
'archiveUrl' => route('studio.worlds.archive', ['world' => $world]),
'entitySearchUrl' => route('studio.worlds.entity-search'),
+ 'suggestions' => $this->editorialSuggestions->editorPayload($world, $request->user()),
+ 'suggestionActions' => [
+ 'add' => route('studio.worlds.suggestions.add', ['world' => $world]),
+ 'pin' => route('studio.worlds.suggestions.pin', ['world' => $world]),
+ 'dismiss' => route('studio.worlds.suggestions.dismiss', ['world' => $world]),
+ 'notRelevant' => route('studio.worlds.suggestions.not-relevant', ['world' => $world]),
+ 'restore' => route('studio.worlds.suggestions.restore', ['world' => $world]),
+ ],
'duplicateActions' => [
'duplicateUrl' => route('studio.worlds.duplicate', ['world' => $world]),
'newEditionUrl' => route('studio.worlds.new-edition', ['world' => $world]),
'canCreateEdition' => $this->worlds->canCreateNewEdition($world),
+ 'duplicateModeOptions' => $this->worlds->duplicateModeOptions(false),
+ 'newEditionModeOptions' => $this->worlds->duplicateModeOptions(true),
],
'mediaSupport' => [
'picker_available' => false,
@@ -169,12 +190,33 @@ final class StudioWorldController extends Controller
return back()->with('success', 'World archived.');
}
+ public function publishRecap(Request $request, World $world): RedirectResponse
+ {
+ $this->authorize('update', $world);
+
+ $this->worlds->publishRecap($world);
+
+ return back()->with('success', 'World recap published.');
+ }
+
public function duplicate(Request $request, World $world): RedirectResponse
{
$this->authorize('create', World::class);
$this->authorize('update', $world);
- $duplicate = $this->worlds->duplicate($world, $request->user(), false);
+ $validated = $request->validate([
+ 'copy_mode' => ['nullable', 'string', 'in:' . implode(',', [
+ WorldService::COPY_MODE_STRUCTURE_ONLY,
+ WorldService::COPY_MODE_WITH_RELATIONS,
+ ])],
+ ]);
+
+ $duplicate = $this->worlds->duplicateWithMode(
+ $world,
+ $request->user(),
+ false,
+ (string) ($validated['copy_mode'] ?? WorldService::COPY_MODE_WITH_RELATIONS),
+ );
return redirect()->route('studio.worlds.edit', ['world' => $duplicate])->with('success', 'World duplicated into a new draft.');
}
@@ -184,7 +226,19 @@ final class StudioWorldController extends Controller
$this->authorize('create', World::class);
$this->authorize('update', $world);
- $edition = $this->worlds->duplicate($world, $request->user(), true);
+ $validated = $request->validate([
+ 'copy_mode' => ['nullable', 'string', 'in:' . implode(',', [
+ WorldService::COPY_MODE_STRUCTURE_ONLY,
+ WorldService::COPY_MODE_WITH_RELATIONS,
+ ])],
+ ]);
+
+ $edition = $this->worlds->duplicateWithMode(
+ $world,
+ $request->user(),
+ true,
+ (string) ($validated['copy_mode'] ?? WorldService::COPY_MODE_WITH_RELATIONS),
+ );
return redirect()->route('studio.worlds.edit', ['world' => $edition])->with('success', 'Next edition draft created.');
}
@@ -203,6 +257,106 @@ final class StudioWorldController extends Controller
]);
}
+ public function addSuggestion(Request $request, World $world): JsonResponse
+ {
+ $this->authorize('update', $world);
+
+ $validated = $request->validate([
+ 'related_type' => ['required', 'string'],
+ 'related_id' => ['required', 'integer', 'min:1'],
+ 'section_key' => ['required', 'string'],
+ 'is_featured' => ['nullable', 'boolean'],
+ ]);
+
+ return response()->json(
+ $this->editorialSuggestions->addSuggestionToSection(
+ $world,
+ $request->user(),
+ (string) $validated['related_type'],
+ (int) $validated['related_id'],
+ (string) $validated['section_key'],
+ (bool) ($validated['is_featured'] ?? false),
+ )
+ );
+ }
+
+ public function pinSuggestion(Request $request, World $world): JsonResponse
+ {
+ $this->authorize('update', $world);
+
+ $validated = $request->validate([
+ 'related_type' => ['required', 'string'],
+ 'related_id' => ['required', 'integer', 'min:1'],
+ 'section_key' => ['nullable', 'string'],
+ ]);
+
+ return response()->json(
+ $this->editorialSuggestions->pinSuggestion(
+ $world,
+ $request->user(),
+ (string) $validated['related_type'],
+ (int) $validated['related_id'],
+ (string) ($validated['section_key'] ?? ''),
+ )
+ );
+ }
+
+ public function dismissSuggestion(Request $request, World $world): JsonResponse
+ {
+ $this->authorize('update', $world);
+
+ $validated = $request->validate([
+ 'related_type' => ['required', 'string'],
+ 'related_id' => ['required', 'integer', 'min:1'],
+ ]);
+
+ return response()->json(
+ $this->editorialSuggestions->dismissSuggestion(
+ $world,
+ $request->user(),
+ (string) $validated['related_type'],
+ (int) $validated['related_id'],
+ )
+ );
+ }
+
+ public function markSuggestionNotRelevant(Request $request, World $world): JsonResponse
+ {
+ $this->authorize('update', $world);
+
+ $validated = $request->validate([
+ 'related_type' => ['required', 'string'],
+ 'related_id' => ['required', 'integer', 'min:1'],
+ ]);
+
+ return response()->json(
+ $this->editorialSuggestions->markSuggestionNotRelevant(
+ $world,
+ $request->user(),
+ (string) $validated['related_type'],
+ (int) $validated['related_id'],
+ )
+ );
+ }
+
+ public function restoreSuggestion(Request $request, World $world): JsonResponse
+ {
+ $this->authorize('update', $world);
+
+ $validated = $request->validate([
+ 'related_type' => ['required', 'string'],
+ 'related_id' => ['required', 'integer', 'min:1'],
+ ]);
+
+ return response()->json(
+ $this->editorialSuggestions->restoreSuggestion(
+ $world,
+ (string) $validated['related_type'],
+ (int) $validated['related_id'],
+ )
+ );
+ }
+
public function approveSubmission(Request $request, World $world, WorldSubmission $submission): RedirectResponse
{
return $this->transitionSubmission($request, $world, $submission, WorldSubmission::STATUS_LIVE, 'Submission approved and is now live.');
@@ -243,11 +397,43 @@ final class StudioWorldController extends Controller
return $this->transitionSubmission($request, $world, $submission, WorldSubmission::STATUS_PENDING, 'Submission returned to pending.');
}
+ public function grantSubmissionReward(Request $request, World $world, WorldSubmission $submission, string $rewardType): RedirectResponse
+ {
+ $this->authorize('update', $world);
+
+ abort_unless((int) $submission->world_id === (int) $world->id, 404);
+
+ $reward = WorldRewardType::tryFrom($rewardType);
+ abort_if($reward === null || $reward->isAutomatic(), 404);
+
+ $validated = $request->validate([
+ 'review_note' => ['nullable', 'string', 'max:1000'],
+ ]);
+
+ $this->rewards->grantManualReward($submission, $request->user(), $reward, (string) ($validated['review_note'] ?? ''));
+
+ return back()->with('success', $reward->label() . ' reward granted.');
+ }
+
+ public function revokeSubmissionReward(Request $request, World $world, WorldSubmission $submission, string $rewardType): RedirectResponse
+ {
+ $this->authorize('update', $world);
+
+ abort_unless((int) $submission->world_id === (int) $world->id, 404);
+
+ $reward = WorldRewardType::tryFrom($rewardType);
+ abort_if($reward === null || $reward->isAutomatic(), 404);
+
+ $this->rewards->revokeManualReward($submission, $reward);
+
+ return back()->with('success', $reward->label() . ' reward revoked.');
+ }
+
public function preview(Request $request, World $world): \Inertia\Response
{
$this->authorize('update', $world);
- $payload = $this->worlds->publicShowPayload($world, $request->user());
+ $payload = $this->worlds->publicShowPayload($world, $request->user(), true);
$seo = app(SeoFactory::class)->collectionPage(
$world->seo_title ?: ($world->title . ' — Skinbase Nova Preview'),
$world->seo_description ?: ($world->summary ?: $world->description ?: 'Preview world page'),
diff --git a/app/Http/Requests/Groups/StoreGroupChallengeRequest.php b/app/Http/Requests/Groups/StoreGroupChallengeRequest.php
index 0b7250e3..d199a266 100644
--- a/app/Http/Requests/Groups/StoreGroupChallengeRequest.php
+++ b/app/Http/Requests/Groups/StoreGroupChallengeRequest.php
@@ -32,6 +32,13 @@ class StoreGroupChallengeRequest extends FormRequest
'linked_collection_id' => ['nullable', 'integer'],
'linked_project_id' => ['nullable', 'integer'],
'featured_artwork_id' => ['nullable', 'integer'],
+ 'outcomes' => ['nullable', 'array'],
+ 'outcomes.*.artwork_id' => ['required_with:outcomes', 'integer'],
+ 'outcomes.*.outcome_type' => ['required_with:outcomes', 'in:' . implode(',', (array) config('groups.challenges.outcome_types', []))],
+ 'outcomes.*.position' => ['nullable', 'integer', 'min:1'],
+ 'outcomes.*.sort_order' => ['nullable', 'integer', 'min:0'],
+ 'outcomes.*.title_override' => ['nullable', 'string', 'max:120'],
+ 'outcomes.*.note' => ['nullable', 'string', 'max:2000'],
];
}
}
\ No newline at end of file
diff --git a/app/Http/Requests/Worlds/StoreWorldRequest.php b/app/Http/Requests/Worlds/StoreWorldRequest.php
index 7e4c60cf..c5ea9e81 100644
--- a/app/Http/Requests/Worlds/StoreWorldRequest.php
+++ b/app/Http/Requests/Worlds/StoreWorldRequest.php
@@ -5,8 +5,10 @@ declare(strict_types=1);
namespace App\Http\Requests\Worlds;
use App\Models\World;
+use App\Models\GroupChallenge;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
class StoreWorldRequest extends FormRequest
@@ -34,8 +36,11 @@ class StoreWorldRequest extends FormRequest
'slug' => ['nullable', 'string', 'max:180', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/', Rule::notIn($this->reservedSlugs())],
'tagline' => ['nullable', 'string', 'max:220'],
'summary' => ['nullable', 'string', 'max:320'],
+ 'teaser_title' => ['nullable', 'string', 'max:180'],
+ 'teaser_summary' => ['nullable', 'string', 'max:320'],
'description' => ['nullable', 'string', 'max:20000'],
'cover_path' => ['nullable', 'string', 'max:2048'],
+ 'teaser_image_path' => ['nullable', 'string', 'max:2048'],
'theme_key' => ['nullable', 'string', 'max:80'],
'accent_color' => ['nullable', 'string', 'max:16'],
'accent_color_secondary' => ['nullable', 'string', 'max:16'],
@@ -45,6 +50,8 @@ class StoreWorldRequest extends FormRequest
'type' => ['required', Rule::in([World::TYPE_SEASONAL, World::TYPE_EVENT, World::TYPE_CAMPAIGN, World::TYPE_TRIBUTE])],
'starts_at' => ['nullable', 'date'],
'ends_at' => ['nullable', 'date', 'after_or_equal:starts_at'],
+ 'promotion_starts_at' => ['nullable', 'date'],
+ 'promotion_ends_at' => ['nullable', 'date', 'after_or_equal:promotion_starts_at'],
'accepts_submissions' => ['nullable', 'boolean'],
'participation_mode' => ['nullable', Rule::in([
World::PARTICIPATION_MODE_MANUAL_APPROVAL,
@@ -57,6 +64,9 @@ class StoreWorldRequest extends FormRequest
'community_section_enabled' => ['nullable', 'boolean'],
'allow_readd_after_removal' => ['nullable', 'boolean'],
'is_featured' => ['nullable', 'boolean'],
+ 'is_active_campaign' => ['nullable', 'boolean'],
+ 'is_homepage_featured' => ['nullable', 'boolean'],
+ 'campaign_priority' => ['nullable', 'integer', 'min:0', 'max:9999'],
'is_recurring' => ['nullable', 'boolean'],
'recurrence_key' => ['nullable', 'string', 'max:120'],
'recurrence_rule' => ['nullable', 'string', 'max:160'],
@@ -64,12 +74,22 @@ class StoreWorldRequest extends FormRequest
'cta_label' => ['nullable', 'string', 'max:120'],
'cta_url' => ['nullable', 'url', 'max:2048'],
'badge_label' => ['nullable', 'string', 'max:120'],
+ 'campaign_label' => ['nullable', 'string', 'max:120'],
'badge_description' => ['nullable', 'string', 'max:2000'],
'submission_guidelines' => ['nullable', 'string', 'max:5000'],
'badge_url' => ['nullable', 'url', 'max:2048'],
'seo_title' => ['nullable', 'string', 'max:255'],
'seo_description' => ['nullable', 'string', 'max:300'],
'og_image_path' => ['nullable', 'string', 'max:2048'],
+ 'recap_status' => ['nullable', Rule::in([World::RECAP_STATUS_DRAFT, World::RECAP_STATUS_PUBLISHED])],
+ 'recap_title' => ['nullable', 'string', 'max:180'],
+ 'recap_summary' => ['nullable', 'string', 'max:320'],
+ 'recap_intro' => ['nullable', 'string', 'max:12000'],
+ 'recap_editor_note' => ['nullable', 'string', 'max:4000'],
+ 'recap_cover_path' => ['nullable', 'string', 'max:2048'],
+ 'recap_article_id' => ['nullable', 'integer', 'exists:news_articles,id'],
+ 'recap_stats_snapshot_json' => ['nullable', 'array'],
+ 'recap_published_at' => ['nullable', 'date'],
'related_tags_json' => ['nullable', 'array', 'max:12'],
'related_tags_json.*' => ['string', 'max:40'],
'section_order_json' => ['nullable', 'array'],
@@ -77,6 +97,15 @@ class StoreWorldRequest extends FormRequest
'section_visibility_json' => ['nullable', 'array'],
'section_visibility_json.*' => ['boolean'],
'parent_world_id' => ['nullable', 'integer', 'exists:worlds,id'],
+ 'linked_challenge_id' => ['nullable', 'integer', 'exists:group_challenges,id'],
+ 'show_linked_challenge_section' => ['nullable', 'boolean'],
+ 'show_linked_challenge_entries' => ['nullable', 'boolean'],
+ 'show_linked_challenge_winners' => ['nullable', 'boolean'],
+ 'show_linked_challenge_finalists' => ['nullable', 'boolean'],
+ 'auto_grant_challenge_world_rewards' => ['nullable', 'boolean'],
+ 'challenge_teaser_override' => ['nullable', 'string', 'max:2000'],
+ 'hidden_linked_challenge_artwork_ids_json' => ['nullable', 'array'],
+ 'hidden_linked_challenge_artwork_ids_json.*' => ['integer', 'distinct', 'exists:artworks,id'],
'published_at' => ['nullable', 'date'],
'relations' => ['nullable', 'array', 'max:60'],
'relations.*.section_key' => ['required_with:relations', 'string', Rule::in($sectionKeys)],
@@ -92,25 +121,32 @@ class StoreWorldRequest extends FormRequest
{
$validator->after(function ($validator): void {
$sections = (array) config('worlds.sections', []);
+ $recurrenceKey = trim((string) $this->input('recurrence_key', ''));
+ $editionYear = $this->input('edition_year');
+ $hasRecurrenceSignals = $this->boolean('is_recurring')
+ || $recurrenceKey !== ''
+ || is_numeric($editionYear)
+ || trim((string) $this->input('recurrence_rule', '')) !== '';
- if ($this->boolean('is_recurring')) {
- if (trim((string) $this->input('recurrence_key', '')) === '') {
+ if ($hasRecurrenceSignals && ! $this->boolean('is_recurring')) {
+ $validator->errors()->add('is_recurring', 'Turn on recurrence when this world belongs to a recurring campaign family.');
+ }
+
+ if ($hasRecurrenceSignals) {
+ if ($recurrenceKey === '') {
$validator->errors()->add('recurrence_key', 'Recurring worlds need a recurrence key such as halloween or retro-month.');
}
- if (! is_numeric($this->input('edition_year'))) {
+ if (! is_numeric($editionYear)) {
$validator->errors()->add('edition_year', 'Recurring worlds need an edition year.');
}
}
- $recurrenceKey = trim((string) $this->input('recurrence_key', ''));
- $editionYear = $this->input('edition_year');
-
if ($recurrenceKey !== '' && ! preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $recurrenceKey)) {
$validator->errors()->add('recurrence_key', 'Use lowercase letters, numbers, and dashes only.');
}
- if ($this->boolean('is_recurring') && $recurrenceKey !== '' && is_numeric($editionYear)) {
+ if ($hasRecurrenceSignals && $recurrenceKey !== '' && is_numeric($editionYear)) {
$worldId = $this->route('world')?->id;
$exists = World::query()
->where('recurrence_key', $recurrenceKey)
@@ -121,6 +157,25 @@ class StoreWorldRequest extends FormRequest
if ($exists) {
$validator->errors()->add('edition_year', 'That recurrence key already has an edition for this year.');
}
+
+ $startsAt = $this->filled('starts_at') ? Carbon::parse((string) $this->input('starts_at')) : null;
+ $endsAt = $this->filled('ends_at') ? Carbon::parse((string) $this->input('ends_at')) : null;
+ $wouldBeCurrentNow = (string) $this->input('status') === World::STATUS_PUBLISHED
+ && ($startsAt === null || ! $startsAt->isFuture())
+ && ($endsAt === null || ! $endsAt->isPast());
+
+ if ($wouldBeCurrentNow) {
+ $hasCurrentConflict = World::query()
+ ->published()
+ ->where('recurrence_key', $recurrenceKey)
+ ->when($worldId, fn (Builder $builder) => $builder->where('id', '!=', $worldId))
+ ->get()
+ ->contains(fn (World $world): bool => $world->isCurrent());
+
+ if ($hasCurrentConflict) {
+ $validator->errors()->add('status', 'This recurrence family already has a current published edition. Archive it or move its dates before publishing another current edition.');
+ }
+ }
}
foreach ((array) $this->input('relations', []) as $index => $relation) {
@@ -136,6 +191,87 @@ class StoreWorldRequest extends FormRequest
if ((string) $this->input('participation_mode') === World::PARTICIPATION_MODE_CLOSED && $this->boolean('accepts_submissions')) {
$validator->errors()->add('accepts_submissions', 'Closed worlds cannot accept creator submissions.');
}
+
+ if ($this->boolean('is_homepage_featured') && ! $this->boolean('is_active_campaign')) {
+ $validator->errors()->add('is_active_campaign', 'Homepage featured worlds must also be marked as active campaigns.');
+ }
+
+ $linkedChallengeId = (int) $this->input('linked_challenge_id', 0);
+
+ if ($linkedChallengeId > 0) {
+ $challenge = GroupChallenge::query()->with('group')->find($linkedChallengeId);
+
+ if (! $challenge) {
+ $validator->errors()->add('linked_challenge_id', 'Select a valid linked challenge.');
+ } elseif ((string) $challenge->status === GroupChallenge::STATUS_DRAFT && (string) $this->input('status') !== World::STATUS_DRAFT) {
+ $validator->errors()->add('linked_challenge_id', 'Draft challenges can only be linked while the world is still a draft.');
+ } elseif ((string) $this->input('status') === World::STATUS_PUBLISHED && (string) $challenge->status === GroupChallenge::STATUS_ARCHIVED) {
+ $validator->errors()->add('linked_challenge_id', 'Archived challenges cannot be linked to a live world.');
+ } elseif ((string) $this->input('status') === World::STATUS_PUBLISHED && ! $this->linkedChallengeMatchesWorldWindow($challenge)) {
+ $validator->errors()->add('linked_challenge_id', 'The linked challenge should overlap this world\'s intended campaign window.');
+ }
+
+ if ($challenge) {
+ $challengeArtworkIds = $challenge->artworkLinks()
+ ->pluck('artwork_id')
+ ->map(fn ($id): int => (int) $id)
+ ->all();
+
+ $invalidHiddenArtworkIds = collect((array) $this->input('hidden_linked_challenge_artwork_ids_json', []))
+ ->map(fn ($id): int => (int) $id)
+ ->filter(fn (int $id): bool => $id > 0)
+ ->diff($challengeArtworkIds)
+ ->values();
+
+ if ($invalidHiddenArtworkIds->isNotEmpty()) {
+ $validator->errors()->add('hidden_linked_challenge_artwork_ids_json', 'Hidden challenge entries must belong to the linked challenge.');
+ }
+ }
+ } elseif (collect((array) $this->input('hidden_linked_challenge_artwork_ids_json', []))->filter()->isNotEmpty()) {
+ $validator->errors()->add('hidden_linked_challenge_artwork_ids_json', 'Hide specific challenge entries only after linking a primary challenge.');
+ }
});
}
+
+ private function linkedChallengeMatchesWorldWindow(GroupChallenge $challenge): bool
+ {
+ $worldStartsAt = $this->dateInput('promotion_starts_at') ?? $this->dateInput('starts_at');
+ $worldEndsAt = $this->dateInput('promotion_ends_at') ?? $this->dateInput('ends_at');
+ $challengeStartsAt = $challenge->start_at;
+ $challengeEndsAt = $challenge->end_at;
+
+ if (! $worldStartsAt && ! $worldEndsAt) {
+ return true;
+ }
+
+ if (! $challengeStartsAt && ! $challengeEndsAt) {
+ return true;
+ }
+
+ $effectiveWorldStart = $worldStartsAt ?? $worldEndsAt;
+ $effectiveWorldEnd = $worldEndsAt ?? $worldStartsAt;
+ $effectiveChallengeStart = $challengeStartsAt ?? $challengeEndsAt;
+ $effectiveChallengeEnd = $challengeEndsAt ?? $challengeStartsAt;
+
+ if (! $effectiveWorldStart || ! $effectiveWorldEnd || ! $effectiveChallengeStart || ! $effectiveChallengeEnd) {
+ return true;
+ }
+
+ return $effectiveChallengeStart->lte($effectiveWorldEnd) && $effectiveChallengeEnd->gte($effectiveWorldStart);
+ }
+
+ private function dateInput(string $key): ?Carbon
+ {
+ $value = $this->input($key);
+
+ if (! is_string($value) || trim($value) === '') {
+ return null;
+ }
+
+ try {
+ return Carbon::parse($value);
+ } catch (\Throwable) {
+ return null;
+ }
+ }
}
\ No newline at end of file
diff --git a/app/Models/GroupChallenge.php b/app/Models/GroupChallenge.php
index f5aa4541..b1c43a5e 100644
--- a/app/Models/GroupChallenge.php
+++ b/app/Models/GroupChallenge.php
@@ -91,6 +91,14 @@ class GroupChallenge extends Model
return $this->hasMany(GroupChallengeArtwork::class);
}
+ public function outcomes(): HasMany
+ {
+ return $this->hasMany(GroupChallengeOutcome::class)
+ ->orderBy('sort_order')
+ ->orderBy('position')
+ ->orderBy('id');
+ }
+
public function artworks(): BelongsToMany
{
return $this->belongsToMany(Artwork::class, 'group_challenge_artworks')
diff --git a/app/Models/GroupChallengeOutcome.php b/app/Models/GroupChallengeOutcome.php
new file mode 100644
index 00000000..b9caea0b
--- /dev/null
+++ b/app/Models/GroupChallengeOutcome.php
@@ -0,0 +1,89 @@
+ 'integer',
+ 'artwork_id' => 'integer',
+ 'user_id' => 'integer',
+ 'position' => 'integer',
+ 'sort_order' => 'integer',
+ 'awarded_by_user_id' => 'integer',
+ 'awarded_at' => 'datetime',
+ ];
+ }
+
+ public static function supportedTypes(): array
+ {
+ return [
+ self::TYPE_WINNER,
+ self::TYPE_FINALIST,
+ self::TYPE_RUNNER_UP,
+ self::TYPE_HONORABLE_MENTION,
+ self::TYPE_FEATURED,
+ ];
+ }
+
+ public static function labelForType(string $type): string
+ {
+ return match ($type) {
+ self::TYPE_WINNER => 'Winner',
+ self::TYPE_FINALIST => 'Finalist',
+ self::TYPE_RUNNER_UP => 'Runner-up',
+ self::TYPE_HONORABLE_MENTION => 'Honorable Mention',
+ self::TYPE_FEATURED => 'Featured',
+ default => ucwords(str_replace('_', ' ', $type)),
+ };
+ }
+
+ public function challenge(): BelongsTo
+ {
+ return $this->belongsTo(GroupChallenge::class, 'group_challenge_id');
+ }
+
+ public function artwork(): BelongsTo
+ {
+ return $this->belongsTo(Artwork::class);
+ }
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ public function awardedBy(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'awarded_by_user_id');
+ }
+}
\ No newline at end of file
diff --git a/app/Models/World.php b/app/Models/World.php
index 79288b7d..8f5118c3 100644
--- a/app/Models/World.php
+++ b/app/Models/World.php
@@ -4,22 +4,30 @@ declare(strict_types=1);
namespace App\Models;
+use cPad\Plugins\News\Models\NewsArticle;
use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
+use Illuminate\Support\Carbon;
class World extends Model
{
use HasFactory;
use SoftDeletes;
+ protected static array $canonicalRecurrenceEditionIds = [];
+
public const STATUS_DRAFT = 'draft';
public const STATUS_PUBLISHED = 'published';
public const STATUS_ARCHIVED = 'archived';
+ public const RECAP_STATUS_DRAFT = 'draft';
+ public const RECAP_STATUS_PUBLISHED = 'published';
+
public const TYPE_SEASONAL = 'seasonal';
public const TYPE_EVENT = 'event';
public const TYPE_CAMPAIGN = 'campaign';
@@ -34,8 +42,11 @@ class World extends Model
'slug',
'tagline',
'summary',
+ 'teaser_title',
+ 'teaser_summary',
'description',
'cover_path',
+ 'teaser_image_path',
'theme_key',
'accent_color',
'accent_color_secondary',
@@ -45,9 +56,14 @@ class World extends Model
'type',
'starts_at',
'ends_at',
+ 'promotion_starts_at',
+ 'promotion_ends_at',
'submission_starts_at',
'submission_ends_at',
'is_featured',
+ 'is_active_campaign',
+ 'is_homepage_featured',
+ 'campaign_priority',
'accepts_submissions',
'participation_mode',
'submission_note_enabled',
@@ -60,6 +76,7 @@ class World extends Model
'cta_label',
'cta_url',
'badge_label',
+ 'campaign_label',
'badge_description',
'submission_guidelines',
'badge_url',
@@ -70,28 +87,71 @@ class World extends Model
'section_order_json',
'section_visibility_json',
'parent_world_id',
+ 'linked_challenge_id',
+ 'show_linked_challenge_section',
+ 'show_linked_challenge_entries',
+ 'show_linked_challenge_winners',
+ 'show_linked_challenge_finalists',
+ 'auto_grant_challenge_world_rewards',
+ 'challenge_teaser_override',
+ 'hidden_linked_challenge_artwork_ids_json',
'created_by_user_id',
'published_at',
+ 'recap_status',
+ 'recap_title',
+ 'recap_summary',
+ 'recap_intro',
+ 'recap_editor_note',
+ 'recap_cover_path',
+ 'recap_article_id',
+ 'recap_stats_snapshot_json',
+ 'recap_published_at',
];
protected $casts = [
'starts_at' => 'datetime',
'ends_at' => 'datetime',
+ 'promotion_starts_at' => 'datetime',
+ 'promotion_ends_at' => 'datetime',
'submission_starts_at' => 'datetime',
'submission_ends_at' => 'datetime',
'published_at' => 'datetime',
'is_featured' => 'boolean',
+ 'is_active_campaign' => 'boolean',
+ 'is_homepage_featured' => 'boolean',
+ 'campaign_priority' => 'integer',
'accepts_submissions' => 'boolean',
'allow_readd_after_removal' => 'boolean',
'submission_note_enabled' => 'boolean',
'community_section_enabled' => 'boolean',
'is_recurring' => 'boolean',
'edition_year' => 'integer',
+ 'linked_challenge_id' => 'integer',
+ 'show_linked_challenge_section' => 'boolean',
+ 'show_linked_challenge_entries' => 'boolean',
+ 'show_linked_challenge_winners' => 'boolean',
+ 'show_linked_challenge_finalists' => 'boolean',
+ 'auto_grant_challenge_world_rewards' => 'boolean',
+ 'hidden_linked_challenge_artwork_ids_json' => 'array',
'related_tags_json' => 'array',
'section_order_json' => 'array',
'section_visibility_json' => 'array',
+ 'recap_article_id' => 'integer',
+ 'recap_stats_snapshot_json' => 'array',
+ 'recap_published_at' => 'datetime',
];
+ protected static function booted(): void
+ {
+ $flushRecurrenceCache = static function (): void {
+ static::$canonicalRecurrenceEditionIds = [];
+ };
+
+ static::saved($flushRecurrenceCache);
+ static::deleted($flushRecurrenceCache);
+ static::restored($flushRecurrenceCache);
+ }
+
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_user_id');
@@ -102,6 +162,16 @@ class World extends Model
return $this->belongsTo(self::class, 'parent_world_id');
}
+ public function linkedChallenge(): BelongsTo
+ {
+ return $this->belongsTo(GroupChallenge::class, 'linked_challenge_id');
+ }
+
+ public function recapArticle(): BelongsTo
+ {
+ return $this->belongsTo(NewsArticle::class, 'recap_article_id');
+ }
+
public function archiveEditions(): HasMany
{
return $this->hasMany(self::class, 'parent_world_id')->orderByDesc('edition_year')->orderByDesc('starts_at');
@@ -117,6 +187,16 @@ class World extends Model
return $this->hasMany(WorldSubmission::class)->orderByDesc('reviewed_at')->orderByDesc('created_at');
}
+ public function editorialSuggestionStates(): HasMany
+ {
+ return $this->hasMany(WorldEditorialSuggestionState::class)->orderByDesc('updated_at')->orderByDesc('id');
+ }
+
+ public function worldRewardGrants(): HasMany
+ {
+ return $this->hasMany(WorldRewardGrant::class)->orderByDesc('granted_at')->orderByDesc('id');
+ }
+
public function scopePublished(Builder $query): Builder
{
return $query
@@ -159,6 +239,36 @@ class World extends Model
->where('starts_at', '>', now());
}
+ public function scopeCampaignActive(Builder $query): Builder
+ {
+ $now = now()->toDateTimeString();
+
+ return $query
+ ->published()
+ ->where('is_active_campaign', true)
+ ->where(function (Builder $builder) use ($now): void {
+ $builder->whereRaw('COALESCE(promotion_starts_at, starts_at) IS NULL')
+ ->orWhereRaw('COALESCE(promotion_starts_at, starts_at) <= ?', [$now]);
+ })
+ ->where(function (Builder $builder) use ($now): void {
+ $builder->whereRaw('COALESCE(promotion_ends_at, ends_at) IS NULL')
+ ->orWhereRaw('COALESCE(promotion_ends_at, ends_at) >= ?', [$now]);
+ });
+ }
+
+ public function scopeCampaignUpcoming(Builder $query): Builder
+ {
+ return $query
+ ->published()
+ ->where('is_active_campaign', true)
+ ->whereRaw('COALESCE(promotion_starts_at, starts_at) > ?', [now()->toDateTimeString()]);
+ }
+
+ public function scopeHomepageFeatured(Builder $query): Builder
+ {
+ return $query->where('is_homepage_featured', true);
+ }
+
public function scopeArchive(Builder $query): Builder
{
return $query
@@ -219,6 +329,135 @@ class World extends Model
return true;
}
+ public function effectivePromotionStartsAt(): ?Carbon
+ {
+ return $this->promotion_starts_at ?? $this->starts_at;
+ }
+
+ public function effectivePromotionEndsAt(): ?Carbon
+ {
+ return $this->promotion_ends_at ?? $this->ends_at;
+ }
+
+ public function isActiveCampaign(): bool
+ {
+ if (! $this->isPubliclyVisible() || ! (bool) $this->is_active_campaign || (string) $this->status !== self::STATUS_PUBLISHED) {
+ return false;
+ }
+
+ $startsAt = $this->effectivePromotionStartsAt();
+ $endsAt = $this->effectivePromotionEndsAt();
+
+ if ($startsAt && $startsAt->isFuture()) {
+ return false;
+ }
+
+ if ($endsAt && $endsAt->isPast()) {
+ return false;
+ }
+
+ return true;
+ }
+
+ public function isUpcomingCampaign(): bool
+ {
+ if (! $this->isPubliclyVisible() || ! (bool) $this->is_active_campaign || (string) $this->status !== self::STATUS_PUBLISHED) {
+ return false;
+ }
+
+ $startsAt = $this->effectivePromotionStartsAt();
+
+ return $startsAt ? $startsAt->isFuture() : false;
+ }
+
+ public function isEndingSoon(?int $days = null): bool
+ {
+ if (! $this->isActiveCampaign()) {
+ return false;
+ }
+
+ $endsAt = $this->effectivePromotionEndsAt();
+ if (! $endsAt) {
+ return false;
+ }
+
+ $threshold = now()->addDays($days ?? (int) config('worlds.campaign_ending_soon_days', 5));
+
+ return $endsAt->greaterThanOrEqualTo(now()) && $endsAt->lessThanOrEqualTo($threshold);
+ }
+
+ public function isEndedEdition(): bool
+ {
+ return (string) $this->status === self::STATUS_ARCHIVED
+ || ($this->ends_at && $this->ends_at->isPast());
+ }
+
+ public function hasPublishedRecap(): bool
+ {
+ return (string) $this->recap_status === self::RECAP_STATUS_PUBLISHED
+ && $this->recap_published_at !== null;
+ }
+
+ public function hasRecapDraftContent(): bool
+ {
+ return trim((string) ($this->recap_title ?? '')) !== ''
+ || trim((string) ($this->recap_summary ?? '')) !== ''
+ || trim((string) ($this->recap_intro ?? '')) !== ''
+ || trim((string) ($this->recap_cover_path ?? '')) !== ''
+ || (int) ($this->recap_article_id ?? 0) > 0
+ || ! empty($this->recap_stats_snapshot_json);
+ }
+
+ public function recapCoverUrl(): ?string
+ {
+ $path = trim((string) ($this->recap_cover_path ?: $this->cover_path ?: ''));
+
+ if ($path === '') {
+ return null;
+ }
+
+ if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
+ return $path;
+ }
+
+ return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
+ }
+
+ public function teaserImageUrl(): ?string
+ {
+ $path = trim((string) ($this->teaser_image_path ?: $this->cover_path ?: ''));
+
+ if ($path === '') {
+ return null;
+ }
+
+ if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
+ return $path;
+ }
+
+ return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
+ }
+
+ public function teaserTitle(): string
+ {
+ $title = trim((string) ($this->teaser_title ?? ''));
+
+ return $title !== '' ? $title : (string) $this->title;
+ }
+
+ public function teaserSummary(): ?string
+ {
+ $summary = trim((string) ($this->teaser_summary ?? ''));
+
+ if ($summary !== '') {
+ return $summary;
+ }
+
+ $fallback = trim((string) ($this->summary ?? ''));
+
+ return $fallback !== '' ? $fallback : null;
+ }
+
public function allowsCreatorParticipation(): bool
{
return in_array((string) $this->participation_mode, [
@@ -264,7 +503,47 @@ class World extends Model
public function publicUrl(): string
{
- return route('worlds.show', ['world' => $this->slug]);
+ if (! $this->is_recurring || trim((string) $this->recurrence_key) === '') {
+ return route('worlds.show', ['world' => $this->slug]);
+ }
+
+ if ($this->isCanonicalEdition()) {
+ return $this->familyUrl();
+ }
+
+ if ($this->edition_year !== null) {
+ return route('worlds.editions.show', ['world' => $this->recurrence_key, 'year' => $this->edition_year]);
+ }
+
+ return $this->familyUrl();
+ }
+
+ public function familySlug(): string
+ {
+ return trim((string) ($this->recurrence_key ?: $this->slug));
+ }
+
+ public function familyUrl(): string
+ {
+ return route('worlds.show', ['world' => $this->familySlug()]);
+ }
+
+ public function editionUrl(): ?string
+ {
+ if (! $this->is_recurring || trim((string) $this->recurrence_key) === '' || $this->edition_year === null) {
+ return null;
+ }
+
+ return route('worlds.editions.show', ['world' => $this->recurrence_key, 'year' => $this->edition_year]);
+ }
+
+ public function isCanonicalEdition(): bool
+ {
+ if (! $this->is_recurring || trim((string) $this->recurrence_key) === '') {
+ return true;
+ }
+
+ return static::canonicalEditionIdForRecurrence((string) $this->recurrence_key) === (int) $this->id;
}
public function sectionOrder(): array
@@ -287,4 +566,33 @@ class World extends Model
return array_merge($defaults, $custom);
}
+
+ private static function canonicalEditionIdForRecurrence(string $recurrenceKey): ?int
+ {
+ if (array_key_exists($recurrenceKey, static::$canonicalRecurrenceEditionIds)) {
+ return static::$canonicalRecurrenceEditionIds[$recurrenceKey];
+ }
+
+ $canonical = static::selectCanonicalEdition(
+ static::query()
+ ->publiclyVisible()
+ ->where('recurrence_key', $recurrenceKey)
+ ->get()
+ );
+
+ return static::$canonicalRecurrenceEditionIds[$recurrenceKey] = $canonical ? (int) $canonical->id : null;
+ }
+
+ private static function selectCanonicalEdition(EloquentCollection $editions): ?self
+ {
+ return $editions
+ ->sortBy([
+ fn (self $world): int => (string) $world->status === self::STATUS_PUBLISHED ? 0 : 1,
+ fn (self $world): int => $world->isCurrent() ? 0 : 1,
+ fn (self $world): int => -1 * (int) ($world->edition_year ?? 0),
+ fn (self $world): int => -1 * ($world->starts_at?->getTimestamp() ?? $world->published_at?->getTimestamp() ?? 0),
+ fn (self $world): int => -1 * (int) $world->id,
+ ])
+ ->first();
+ }
}
\ No newline at end of file
diff --git a/app/Models/WorldAnalyticsEvent.php b/app/Models/WorldAnalyticsEvent.php
new file mode 100644
index 00000000..a5edc2e8
--- /dev/null
+++ b/app/Models/WorldAnalyticsEvent.php
@@ -0,0 +1,64 @@
+ 'integer',
+ 'edition_year' => 'integer',
+ 'entity_id' => 'integer',
+ 'challenge_id' => 'integer',
+ 'user_id' => 'integer',
+ 'meta' => 'array',
+ 'occurred_at' => 'datetime',
+ ];
+ }
+
+ public function world(): BelongsTo
+ {
+ return $this->belongsTo(World::class);
+ }
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+}
\ No newline at end of file
diff --git a/app/Models/WorldEditorialSuggestionState.php b/app/Models/WorldEditorialSuggestionState.php
new file mode 100644
index 00000000..91391cf5
--- /dev/null
+++ b/app/Models/WorldEditorialSuggestionState.php
@@ -0,0 +1,45 @@
+ 'integer',
+ 'world_id' => 'integer',
+ 'acted_by_user_id' => 'integer',
+ ];
+
+ public function world(): BelongsTo
+ {
+ return $this->belongsTo(World::class);
+ }
+
+ public function actor(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'acted_by_user_id');
+ }
+}
\ No newline at end of file
diff --git a/app/Models/WorldRewardGrant.php b/app/Models/WorldRewardGrant.php
new file mode 100644
index 00000000..5aa3b0b3
--- /dev/null
+++ b/app/Models/WorldRewardGrant.php
@@ -0,0 +1,65 @@
+ 'integer',
+ 'world_id' => 'integer',
+ 'artwork_id' => 'integer',
+ 'world_submission_id' => 'integer',
+ 'granted_by_user_id' => 'integer',
+ 'reward_type' => WorldRewardType::class,
+ 'granted_at' => 'datetime',
+ ];
+ }
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'user_id');
+ }
+
+ public function world(): BelongsTo
+ {
+ return $this->belongsTo(World::class);
+ }
+
+ public function artwork(): BelongsTo
+ {
+ return $this->belongsTo(Artwork::class);
+ }
+
+ public function worldSubmission(): BelongsTo
+ {
+ return $this->belongsTo(WorldSubmission::class, 'world_submission_id');
+ }
+
+ public function grantedBy(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'granted_by_user_id');
+ }
+}
\ No newline at end of file
diff --git a/app/Models/WorldSubmission.php b/app/Models/WorldSubmission.php
index 34769811..8ce751ae 100644
--- a/app/Models/WorldSubmission.php
+++ b/app/Models/WorldSubmission.php
@@ -7,6 +7,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
class WorldSubmission extends Model
{
@@ -71,4 +72,9 @@ class WorldSubmission extends Model
{
return $this->belongsTo(User::class, 'reviewed_by_user_id');
}
+
+ public function worldRewardGrants(): HasMany
+ {
+ return $this->hasMany(WorldRewardGrant::class, 'world_submission_id')->orderByDesc('granted_at')->orderByDesc('id');
+ }
}
\ No newline at end of file
diff --git a/app/Notifications/WorldRewardGrantedNotification.php b/app/Notifications/WorldRewardGrantedNotification.php
new file mode 100644
index 00000000..e056bb81
--- /dev/null
+++ b/app/Notifications/WorldRewardGrantedNotification.php
@@ -0,0 +1,47 @@
+grant->world;
+ $rewardType = $this->grant->reward_type;
+ $title = trim(($world?->title ?? 'World') . ' ' . $rewardType->label());
+
+ return [
+ 'type' => 'world_reward_granted',
+ 'world_reward_grant_id' => (int) $this->grant->id,
+ 'world_id' => (int) ($world?->id ?? 0),
+ 'world_slug' => (string) ($world?->slug ?? ''),
+ 'reward_type' => $rewardType->value,
+ 'title' => $title,
+ 'message' => 'You earned the ' . $title . ' reward.',
+ 'xp_reward' => $rewardType->xpReward(),
+ 'url' => route('dashboard.notifications'),
+ ];
+ }
+}
\ No newline at end of file
diff --git a/app/Services/Profile/WorldProfileHistoryService.php b/app/Services/Profile/WorldProfileHistoryService.php
new file mode 100644
index 00000000..9740cc53
--- /dev/null
+++ b/app/Services/Profile/WorldProfileHistoryService.php
@@ -0,0 +1,455 @@
+buildPayload($user, false);
+ }
+
+ public function ownerPayloadForUser(User $user): array
+ {
+ return $this->buildPayload($user, true);
+ }
+
+ private function buildPayload(User $user, bool $includeOwnerContext): array
+ {
+ $submissions = $this->submissionsForUser($user);
+ $rewardGrants = $this->rewardGrantsForUser($user);
+ $challengeOutcomes = $this->challengeOutcomesForUser($user);
+ $challengeWorldMap = $this->challengeWorldMap($challengeOutcomes->pluck('group_challenge_id')->unique()->values());
+
+ $entries = [];
+ $hiddenPublicEntries = 0;
+
+ foreach ($rewardGrants as $grant) {
+ if (! $this->grantQualifiesForPublicHistory($grant, $submissions)) {
+ $hiddenPublicEntries++;
+
+ continue;
+ }
+
+ $this->addRecognition(
+ $entries,
+ $grant->world,
+ $grant->reward_type->value,
+ $grant->artwork,
+ $grant->granted_at,
+ $grant->grant_source === 'challenge' ? $this->challengeContextForWorld($grant->world) : null,
+ 'reward'
+ );
+ }
+
+ $liveSubmissions = $submissions
+ ->filter(fn (WorldSubmission $submission): bool => $this->submissionQualifiesForPublicHistory($submission));
+
+ foreach ($liveSubmissions as $submission) {
+ $recognitionKey = $submission->is_featured ? WorldRewardType::Featured->value : WorldRewardType::Participant->value;
+
+ $this->addRecognition(
+ $entries,
+ $submission->world,
+ $recognitionKey,
+ $submission->artwork,
+ $submission->featured_at ?? $submission->reviewed_at ?? $submission->created_at,
+ $this->challengeContextForWorld($submission->world),
+ 'participation'
+ );
+ }
+
+ foreach ($challengeOutcomes as $outcome) {
+ $recognitionKey = $this->recognitionKeyForOutcome($outcome->outcome_type);
+ if ($recognitionKey === null || ! $this->artworkIsPubliclyVisible($outcome->artwork)) {
+ $hiddenPublicEntries++;
+
+ continue;
+ }
+
+ $worlds = $challengeWorldMap->get((int) $outcome->group_challenge_id, Collection::make());
+
+ if ($worlds->isEmpty()) {
+ continue;
+ }
+
+ foreach ($worlds as $world) {
+ $this->addRecognition(
+ $entries,
+ $world,
+ $recognitionKey,
+ $outcome->artwork,
+ $outcome->awarded_at ?? $outcome->created_at,
+ $this->challengeContextFromChallenge($outcome->challenge),
+ 'challenge_outcome'
+ );
+ }
+ }
+
+ $normalizedEntries = Collection::make($entries)
+ ->map(fn (array $entry): array => $this->normalizeEntry($entry))
+ ->sort(function (array $left, array $right): int {
+ if ($left['occurred_at'] !== $right['occurred_at']) {
+ return strcmp((string) $right['occurred_at'], (string) $left['occurred_at']);
+ }
+
+ if ($left['primary_recognition']['priority'] !== $right['primary_recognition']['priority']) {
+ return $left['primary_recognition']['priority'] <=> $right['primary_recognition']['priority'];
+ }
+
+ return strcmp((string) $left['world']['title'], (string) $right['world']['title']);
+ })
+ ->values();
+
+ $yearValues = $normalizedEntries
+ ->pluck('world.edition_year')
+ ->filter(fn ($year): bool => is_int($year) || ctype_digit((string) $year))
+ ->map(fn ($year): int => (int) $year)
+ ->values();
+
+ $worldAppearances = $normalizedEntries->count();
+ $highlights = $normalizedEntries->take(3)->values();
+ $mostRecent = $normalizedEntries->first();
+
+ return [
+ 'summary' => [
+ 'available' => $normalizedEntries->isNotEmpty(),
+ 'world_appearances' => $worldAppearances,
+ 'worlds_joined' => $worldAppearances,
+ 'featured_appearances' => $normalizedEntries->filter(fn (array $entry): bool => in_array('featured', $entry['recognition_keys'], true))->count(),
+ 'winner_appearances' => $normalizedEntries->filter(fn (array $entry): bool => in_array('winner', $entry['recognition_keys'], true))->count(),
+ 'finalist_appearances' => $normalizedEntries->filter(fn (array $entry): bool => in_array('finalist', $entry['recognition_keys'], true))->count(),
+ 'spotlight_appearances' => $normalizedEntries->filter(fn (array $entry): bool => in_array('spotlight', $entry['recognition_keys'], true))->count(),
+ 'finalist_winner_appearances' => $normalizedEntries->filter(fn (array $entry): bool => in_array('winner', $entry['recognition_keys'], true) || in_array('finalist', $entry['recognition_keys'], true))->count(),
+ 'active_year_span' => $this->yearSpanPayload($yearValues),
+ 'most_recent_world_activity' => $mostRecent ? [
+ 'world_title' => $mostRecent['world']['title'],
+ 'primary_recognition' => $mostRecent['primary_recognition'],
+ 'recognition_label' => $mostRecent['primary_recognition']['label'],
+ 'world_url' => $mostRecent['world']['url'],
+ 'occurred_at' => $mostRecent['occurred_at'],
+ ] : null,
+ ],
+ 'highlights' => $highlights->all(),
+ 'entries' => $normalizedEntries->all(),
+ 'owner_context' => $includeOwnerContext ? [
+ 'pending_submissions' => $submissions->where('status', WorldSubmission::STATUS_PENDING)->count(),
+ 'removed_or_blocked_submissions' => $submissions->filter(fn (WorldSubmission $submission): bool => in_array((string) $submission->status, [WorldSubmission::STATUS_REMOVED, WorldSubmission::STATUS_BLOCKED], true))->count(),
+ 'hidden_public_entries' => $hiddenPublicEntries,
+ ] : null,
+ 'filters' => [
+ 'default_order' => 'recent_first',
+ 'groupable_by' => ['year', 'world_family', 'recognition_type'],
+ ],
+ ];
+ }
+
+ private function submissionsForUser(User $user): Collection
+ {
+ return WorldSubmission::query()
+ ->with([
+ 'world:id,title,slug,type,recurrence_key,edition_year,linked_challenge_id,status,published_at,deleted_at',
+ 'world.linkedChallenge.group:id,name,slug,visibility,status',
+ 'artwork:id,user_id,title,slug,hash,thumb_ext,is_public,visibility,is_approved,published_at,deleted_at',
+ ])
+ ->where('submitted_by_user_id', (int) $user->id)
+ ->whereHas('world', fn ($builder) => $builder->publiclyVisible())
+ ->whereHas('artwork', fn ($builder) => $builder->where('user_id', (int) $user->id))
+ ->get();
+ }
+
+ private function rewardGrantsForUser(User $user): Collection
+ {
+ return WorldRewardGrant::query()
+ ->with([
+ 'world:id,title,slug,type,recurrence_key,edition_year,linked_challenge_id,status,published_at,deleted_at',
+ 'world.linkedChallenge.group:id,name,slug,visibility,status',
+ 'artwork:id,user_id,title,slug,hash,thumb_ext,is_public,visibility,is_approved,published_at,deleted_at',
+ 'worldSubmission:id,world_id,artwork_id,status,is_featured,featured_at,reviewed_at,created_at',
+ ])
+ ->where('user_id', (int) $user->id)
+ ->whereHas('world', fn ($builder) => $builder->publiclyVisible())
+ ->orderByDesc('granted_at')
+ ->orderByDesc('id')
+ ->get();
+ }
+
+ private function challengeOutcomesForUser(User $user): Collection
+ {
+ return GroupChallengeOutcome::query()
+ ->with([
+ 'challenge.group:id,name,slug,visibility,status',
+ 'artwork:id,user_id,title,slug,hash,thumb_ext,is_public,visibility,is_approved,published_at,deleted_at',
+ ])
+ ->where('user_id', (int) $user->id)
+ ->whereHas('artwork', fn ($builder) => $builder->where('user_id', (int) $user->id))
+ ->get();
+ }
+
+ private function challengeWorldMap(Collection $challengeIds): Collection
+ {
+ if ($challengeIds->isEmpty()) {
+ return Collection::make();
+ }
+
+ $map = Collection::make();
+
+ World::query()
+ ->with('linkedChallenge.group')
+ ->publiclyVisible()
+ ->whereIn('linked_challenge_id', $challengeIds->all())
+ ->get()
+ ->each(function (World $world) use (&$map): void {
+ $challengeId = (int) ($world->linked_challenge_id ?? 0);
+ if ($challengeId <= 0) {
+ return;
+ }
+
+ $items = $map->get($challengeId, Collection::make());
+ $map->put($challengeId, $items->push($world)->unique('id')->values());
+ });
+
+ WorldRelation::query()
+ ->with(['world.linkedChallenge.group'])
+ ->where('related_type', WorldRelation::TYPE_CHALLENGE)
+ ->whereIn('related_id', $challengeIds->all())
+ ->whereHas('world', fn ($builder) => $builder->publiclyVisible())
+ ->get()
+ ->each(function (WorldRelation $relation) use (&$map): void {
+ $challengeId = (int) $relation->related_id;
+ $world = $relation->world;
+
+ if (! $world) {
+ return;
+ }
+
+ $items = $map->get($challengeId, Collection::make());
+ $map->put($challengeId, $items->push($world)->unique('id')->values());
+ });
+
+ return $map;
+ }
+
+ private function grantQualifiesForPublicHistory(WorldRewardGrant $grant, Collection $submissions): bool
+ {
+ if (! $grant->world || ! $this->artworkIsPubliclyVisible($grant->artwork)) {
+ return false;
+ }
+
+ return match ($grant->reward_type) {
+ WorldRewardType::Participant => $submissions->contains(fn (WorldSubmission $submission): bool => (int) $submission->world_id === (int) $grant->world_id && $this->submissionQualifiesForPublicHistory($submission)),
+ WorldRewardType::Featured => $submissions->contains(fn (WorldSubmission $submission): bool => (int) $submission->world_id === (int) $grant->world_id && $this->submissionQualifiesForPublicHistory($submission) && (bool) $submission->is_featured),
+ default => true,
+ };
+ }
+
+ private function submissionQualifiesForPublicHistory(WorldSubmission $submission): bool
+ {
+ return $submission->world !== null
+ && (string) $submission->status === WorldSubmission::STATUS_LIVE
+ && $this->artworkIsPubliclyVisible($submission->artwork);
+ }
+
+ private function artworkIsPubliclyVisible(?Artwork $artwork): bool
+ {
+ if (! $artwork) {
+ return false;
+ }
+
+ return $artwork->deleted_at === null
+ && (bool) $artwork->is_approved
+ && (bool) $artwork->is_public
+ && $artwork->published_at !== null
+ && $artwork->published_at->isPast()
+ && in_array((string) ($artwork->visibility ?? Artwork::VISIBILITY_PUBLIC), ['', Artwork::VISIBILITY_PUBLIC], true);
+ }
+
+ private function recognitionKeyForOutcome(string $outcomeType): ?string
+ {
+ return match ($outcomeType) {
+ GroupChallengeOutcome::TYPE_WINNER => 'winner',
+ GroupChallengeOutcome::TYPE_FINALIST => 'finalist',
+ GroupChallengeOutcome::TYPE_FEATURED => 'featured',
+ GroupChallengeOutcome::TYPE_RUNNER_UP => 'runner_up',
+ GroupChallengeOutcome::TYPE_HONORABLE_MENTION => 'honorable_mention',
+ default => null,
+ };
+ }
+
+ private function addRecognition(array &$entries, World $world, string $recognitionKey, ?Artwork $artwork, ?\DateTimeInterface $occurredAt, ?array $challengeContext, string $sourceType): void
+ {
+ $worldId = (int) $world->id;
+ $timestamp = $occurredAt?->format(DATE_ATOM) ?? date(DATE_ATOM);
+
+ if (! array_key_exists($worldId, $entries)) {
+ $entries[$worldId] = [
+ 'world' => $world,
+ 'recognitions' => [],
+ 'occurred_at' => $timestamp,
+ ];
+ }
+
+ if (! array_key_exists($recognitionKey, $entries[$worldId]['recognitions'])) {
+ $entries[$worldId]['recognitions'][$recognitionKey] = [
+ 'recognition' => $this->recognitionPayload($recognitionKey),
+ 'linked_artwork' => $this->artworkPayload($artwork),
+ 'challenge' => $challengeContext,
+ 'occurred_at' => $timestamp,
+ 'source_types' => [$sourceType],
+ ];
+ } else {
+ $current = $entries[$worldId]['recognitions'][$recognitionKey];
+
+ $entries[$worldId]['recognitions'][$recognitionKey] = [
+ 'recognition' => $current['recognition'],
+ 'linked_artwork' => $current['linked_artwork'] ?? $this->artworkPayload($artwork),
+ 'challenge' => $current['challenge'] ?? $challengeContext,
+ 'occurred_at' => max((string) $current['occurred_at'], $timestamp),
+ 'source_types' => array_values(array_unique([...$current['source_types'], $sourceType])),
+ ];
+ }
+
+ if ($timestamp > (string) $entries[$worldId]['occurred_at']) {
+ $entries[$worldId]['occurred_at'] = $timestamp;
+ }
+ }
+
+ private function normalizeEntry(array $entry): array
+ {
+ /** @var World $world */
+ $world = $entry['world'];
+
+ $recognitions = Collection::make($entry['recognitions'])
+ ->sort(function (array $left, array $right): int {
+ if ($left['recognition']['priority'] !== $right['recognition']['priority']) {
+ return $left['recognition']['priority'] <=> $right['recognition']['priority'];
+ }
+
+ return strcmp((string) $right['occurred_at'], (string) $left['occurred_at']);
+ })
+ ->values();
+
+ $primary = $recognitions->first();
+ $linkedArtwork = $primary['linked_artwork'] ?? $recognitions->pluck('linked_artwork')->first(fn ($item) => $item !== null);
+ $challenge = $primary['challenge'] ?? $recognitions->pluck('challenge')->first(fn ($item) => $item !== null);
+
+ return [
+ 'id' => 'world-history-' . (int) $world->id,
+ 'world' => [
+ 'id' => (int) $world->id,
+ 'title' => (string) $world->title,
+ 'slug' => (string) $world->slug,
+ 'url' => $world->publicUrl(),
+ 'type' => (string) $world->type,
+ 'type_label' => Str::headline((string) $world->type),
+ 'edition_year' => $world->edition_year ? (int) $world->edition_year : null,
+ 'family_key' => (string) ($world->recurrence_key ?: 'world-' . $world->id),
+ 'family_label' => $this->familyLabelForWorld($world),
+ ],
+ 'primary_recognition' => $primary['recognition'],
+ 'recognitions' => $recognitions->map(fn (array $recognition): array => [
+ ...$recognition['recognition'],
+ 'source_types' => $recognition['source_types'],
+ ])->all(),
+ 'recognition_keys' => $recognitions->map(fn (array $recognition): string => (string) $recognition['recognition']['key'])->values()->all(),
+ 'linked_artwork' => $linkedArtwork,
+ 'challenge' => $challenge,
+ 'occurred_at' => (string) $entry['occurred_at'],
+ 'source_types' => $recognitions->flatMap(fn (array $recognition): array => $recognition['source_types'])->unique()->values()->all(),
+ ];
+ }
+
+ private function artworkPayload(?Artwork $artwork): ?array
+ {
+ if (! $artwork || ! $this->artworkIsPubliclyVisible($artwork)) {
+ return null;
+ }
+
+ return [
+ 'id' => (int) $artwork->id,
+ 'title' => (string) ($artwork->title ?: 'Untitled artwork'),
+ 'url' => route('art.show', ['id' => (int) $artwork->id, 'slug' => $artwork->slug ?: Str::slug((string) $artwork->title)]),
+ 'thumbnail_url' => $artwork->thumb_url,
+ ];
+ }
+
+ private function challengeContextForWorld(?World $world): ?array
+ {
+ if (! $world || ! $world->linkedChallenge || ! $world->linkedChallenge->canBeViewedBy(null)) {
+ return null;
+ }
+
+ return $this->challengeContextFromChallenge($world->linkedChallenge);
+ }
+
+ private function challengeContextFromChallenge(?GroupChallenge $challenge): ?array
+ {
+ if (! $challenge || ! $challenge->group || ! $challenge->canBeViewedBy(null)) {
+ return null;
+ }
+
+ return [
+ 'id' => (int) $challenge->id,
+ 'title' => (string) $challenge->title,
+ 'url' => route('groups.challenges.show', ['group' => $challenge->group, 'challenge' => $challenge]),
+ 'group_name' => (string) $challenge->group->name,
+ ];
+ }
+
+ private function recognitionPayload(string $recognitionKey): array
+ {
+ return match ($recognitionKey) {
+ 'winner' => ['key' => 'winner', 'label' => 'Winner', 'tone' => 'emerald', 'priority' => 0],
+ 'finalist' => ['key' => 'finalist', 'label' => 'Finalist', 'tone' => 'violet', 'priority' => 1],
+ 'featured' => ['key' => 'featured', 'label' => 'Featured', 'tone' => 'amber', 'priority' => 2],
+ 'spotlight' => ['key' => 'spotlight', 'label' => 'Spotlight', 'tone' => 'rose', 'priority' => 3],
+ 'participant' => ['key' => 'participant', 'label' => 'Participant', 'tone' => 'sky', 'priority' => 4],
+ 'runner_up' => ['key' => 'runner_up', 'label' => 'Runner-up', 'tone' => 'slate', 'priority' => 5],
+ 'honorable_mention' => ['key' => 'honorable_mention', 'label' => 'Honorable Mention', 'tone' => 'slate', 'priority' => 6],
+ default => ['key' => $recognitionKey, 'label' => Str::headline(str_replace('_', ' ', $recognitionKey)), 'tone' => 'slate', 'priority' => 7],
+ };
+ }
+
+ private function familyLabelForWorld(World $world): string
+ {
+ if ($world->recurrence_key) {
+ return Str::headline(str_replace('-', ' ', (string) $world->recurrence_key));
+ }
+
+ if ($world->edition_year) {
+ return trim((string) preg_replace('/\s+' . preg_quote((string) $world->edition_year, '/') . '$/', '', (string) $world->title));
+ }
+
+ return (string) $world->title;
+ }
+
+ private function yearSpanPayload(Collection $years): ?array
+ {
+ if ($years->isEmpty()) {
+ return null;
+ }
+
+ $start = (int) $years->min();
+ $end = (int) $years->max();
+
+ return [
+ 'start' => $start,
+ 'end' => $end,
+ 'label' => $start === $end ? (string) $start : sprintf('%d-%d', $start, $end),
+ ];
+ }
+}
\ No newline at end of file
diff --git a/app/Services/Worlds/WorldAnalyticsService.php b/app/Services/Worlds/WorldAnalyticsService.php
new file mode 100644
index 00000000..e3ebdf2c
--- /dev/null
+++ b/app/Services/Worlds/WorldAnalyticsService.php
@@ -0,0 +1,847 @@
+ 7,
+ '30d' => 30,
+ 'all' => null,
+ ];
+
+ private const PORTFOLIO_RANGE_WINDOWS = [
+ '30d' => 30,
+ 'all' => null,
+ ];
+
+ public function allowedEventTypes(): array
+ {
+ return [
+ self::EVENT_SOURCE_IMPRESSION,
+ self::EVENT_VIEWED,
+ self::EVENT_SOURCE_CLICKED,
+ self::EVENT_CTA_CLICKED,
+ self::EVENT_SECTION_CLICKED,
+ self::EVENT_ENTITY_CLICKED,
+ self::EVENT_SUBMISSION_STARTED,
+ self::EVENT_SUBMISSION_CREATED,
+ self::EVENT_SUBMISSION_APPROVED,
+ self::EVENT_SUBMISSION_REMOVED,
+ self::EVENT_SUBMISSION_BLOCKED,
+ self::EVENT_SUBMISSION_FEATURED,
+ self::EVENT_CHALLENGE_CTA_CLICKED,
+ self::EVENT_REWARD_GRANTED,
+ ];
+ }
+
+ public function allowedSourceSurfaces(): array
+ {
+ return [
+ self::SOURCE_HOMEPAGE_SPOTLIGHT,
+ self::SOURCE_HOMEPAGE_WORLDS_RAIL,
+ self::SOURCE_WORLDS_INDEX,
+ self::SOURCE_NAVIGATION,
+ self::SOURCE_UPLOAD_FLOW,
+ self::SOURCE_CHALLENGE_PAGE,
+ self::SOURCE_NEWS_ARTICLE,
+ self::SOURCE_PROFILE,
+ self::SOURCE_DIRECT,
+ self::SOURCE_UNKNOWN,
+ ];
+ }
+
+ public function recordEvent(Request $request, array $payload): void
+ {
+ $world = World::query()
+ ->select(['id', 'slug', 'type', 'recurrence_key', 'edition_year'])
+ ->findOrFail((int) $payload['world_id']);
+
+ $user = $request->user();
+
+ WorldAnalyticsEvent::query()->create([
+ 'world_id' => (int) $world->id,
+ 'event_type' => (string) $payload['event_type'],
+ 'world_slug' => (string) $world->slug,
+ 'world_type' => (string) $world->type,
+ 'recurrence_key' => $this->nullableString($world->recurrence_key),
+ 'edition_year' => $world->edition_year ? (int) $world->edition_year : null,
+ 'section_key' => $this->nullableString($payload['section_key'] ?? null),
+ 'cta_key' => $this->nullableString($payload['cta_key'] ?? null),
+ 'entity_type' => $this->nullableString($payload['entity_type'] ?? null),
+ 'entity_id' => isset($payload['entity_id']) ? (int) $payload['entity_id'] : null,
+ 'entity_title' => $this->nullableString($payload['entity_title'] ?? null),
+ 'challenge_id' => isset($payload['challenge_id']) ? (int) $payload['challenge_id'] : null,
+ 'source_surface' => $this->nullableString($payload['source_surface'] ?? null),
+ 'source_detail' => $this->nullableString($payload['source_detail'] ?? null),
+ 'viewer_type' => $user ? 'user' : 'guest',
+ 'user_id' => $user ? (int) $user->id : null,
+ 'visitor_key' => $this->resolveVisitorKey($request, $user, (string) ($payload['visitor_token'] ?? '')),
+ 'meta' => $this->sanitizeMeta($payload['meta'] ?? []),
+ 'occurred_at' => now(),
+ ]);
+ }
+
+ public function recordSubmissionLifecycle(WorldSubmission $submission, string $eventType, ?User $actor = null, ?string $sourceSurface = null, array $meta = []): void
+ {
+ $submission->loadMissing(['world', 'artwork']);
+
+ $world = $submission->world;
+ if (! $world) {
+ return;
+ }
+
+ WorldAnalyticsEvent::query()->create([
+ 'world_id' => (int) $world->id,
+ 'event_type' => $eventType,
+ 'world_slug' => (string) $world->slug,
+ 'world_type' => (string) $world->type,
+ 'recurrence_key' => $this->nullableString($world->recurrence_key),
+ 'edition_year' => $world->edition_year ? (int) $world->edition_year : null,
+ 'section_key' => 'community_submissions',
+ 'entity_type' => 'artwork',
+ 'entity_id' => $submission->artwork_id ? (int) $submission->artwork_id : null,
+ 'entity_title' => $this->nullableString($submission->artwork?->title),
+ 'source_surface' => $this->nullableString($sourceSurface),
+ 'viewer_type' => $actor ? 'user' : 'guest',
+ 'user_id' => $actor ? (int) $actor->id : null,
+ 'visitor_key' => $actor ? hash('sha256', 'user:' . $actor->id) : hash('sha256', 'system:world_submission'),
+ 'meta' => $this->sanitizeMeta(array_merge([
+ 'submission_id' => (int) $submission->id,
+ 'status' => (string) $submission->status,
+ 'is_featured' => (bool) $submission->is_featured,
+ 'mode_snapshot' => (string) ($submission->mode_snapshot ?? ''),
+ ], $meta)),
+ 'occurred_at' => now(),
+ ]);
+ }
+
+ public function recordRewardGrant(WorldRewardGrant $grant): void
+ {
+ $grant->loadMissing(['world', 'artwork']);
+
+ $world = $grant->world;
+ if (! $world) {
+ return;
+ }
+
+ WorldAnalyticsEvent::query()->create([
+ 'world_id' => (int) $world->id,
+ 'event_type' => self::EVENT_REWARD_GRANTED,
+ 'world_slug' => (string) $world->slug,
+ 'world_type' => (string) $world->type,
+ 'recurrence_key' => $this->nullableString($world->recurrence_key),
+ 'edition_year' => $world->edition_year ? (int) $world->edition_year : null,
+ 'section_key' => 'rewards',
+ 'entity_type' => 'artwork',
+ 'entity_id' => $grant->artwork_id ? (int) $grant->artwork_id : null,
+ 'entity_title' => $this->nullableString($grant->artwork?->title),
+ 'viewer_type' => $grant->user_id ? 'user' : 'guest',
+ 'user_id' => $grant->user_id ? (int) $grant->user_id : null,
+ 'visitor_key' => $grant->user_id ? hash('sha256', 'user:' . $grant->user_id) : hash('sha256', 'system:world_reward'),
+ 'meta' => $this->sanitizeMeta([
+ 'reward_type' => $grant->reward_type->value,
+ 'grant_source' => (string) $grant->grant_source,
+ 'world_submission_id' => $grant->world_submission_id ? (int) $grant->world_submission_id : null,
+ ]),
+ 'occurred_at' => now(),
+ ]);
+ }
+
+ public function studioReport(World $world): array
+ {
+ $ranges = [];
+
+ foreach (self::RANGE_WINDOWS as $key => $days) {
+ $ranges[$key] = $this->rangePayload($world, $days ? now()->subDays($days) : null);
+ }
+
+ return [
+ 'default_range' => '30d',
+ 'range_options' => [
+ ['value' => '7d', 'label' => 'Last 7 days'],
+ ['value' => '30d', 'label' => 'Last 30 days'],
+ ['value' => 'all', 'label' => 'Lifetime'],
+ ],
+ 'ranges' => $ranges,
+ 'edition_comparison' => $this->editionComparisonPayload($world),
+ ];
+ }
+
+ public function portfolioReport(): array
+ {
+ $ranges = [];
+
+ foreach (self::PORTFOLIO_RANGE_WINDOWS as $key => $days) {
+ $ranges[$key] = $this->portfolioRangePayload($days ? now()->subDays($days) : null);
+ }
+
+ return [
+ 'default_range' => '30d',
+ 'range_options' => [
+ ['value' => '30d', 'label' => 'Last 30 days'],
+ ['value' => 'all', 'label' => 'Lifetime'],
+ ],
+ 'ranges' => $ranges,
+ ];
+ }
+
+ public function sourceSurfaceLabel(?string $surface): string
+ {
+ return match ((string) $surface) {
+ self::SOURCE_HOMEPAGE_SPOTLIGHT => 'Homepage spotlight',
+ self::SOURCE_HOMEPAGE_WORLDS_RAIL => 'Homepage worlds rail',
+ self::SOURCE_WORLDS_INDEX => 'Worlds index',
+ self::SOURCE_NAVIGATION => 'Navigation',
+ self::SOURCE_UPLOAD_FLOW => 'Upload flow',
+ self::SOURCE_CHALLENGE_PAGE => 'Challenge page',
+ self::SOURCE_NEWS_ARTICLE => 'News article',
+ self::SOURCE_PROFILE => 'Profile',
+ self::SOURCE_DIRECT => 'Direct',
+ default => 'Unknown',
+ };
+ }
+
+ private function rangePayload(World $world, ?Carbon $start): array
+ {
+ $eventCounts = $this->eventCounts($world, $start);
+ $currentSubmissionCounts = $this->currentSubmissionCounts($world);
+ $submissionActivity = $this->submissionActivityCounts($world, $start, $eventCounts);
+ $rewardCounts = $this->rewardCounts($world, $start);
+ $sources = $this->sourceBreakdown($world, $start);
+ $sectionPerformance = $this->sectionPerformance($world, $start);
+ $entityPerformance = $this->entityPerformance($world, $start);
+ $ctaPerformance = $this->ctaPerformance($world, $start);
+ $challengeMetrics = $this->challengeMetrics($world, $start, $eventCounts);
+ $promotionImpressions = (int) ($eventCounts[self::EVENT_SOURCE_IMPRESSION] ?? 0);
+ $sourceClicks = (int) ($eventCounts[self::EVENT_SOURCE_CLICKED] ?? 0);
+ $views = (int) ($eventCounts[self::EVENT_VIEWED] ?? 0);
+ $uniqueVisitors = $this->uniqueViewers($world, $start);
+ $ctaClicks = (int) ($eventCounts[self::EVENT_CTA_CLICKED] ?? 0);
+ $topSource = collect($sources)->sortByDesc('views')->first();
+ $topSection = collect($sectionPerformance)->sortByDesc('clicks')->first();
+ $topEntity = collect($entityPerformance)->sortByDesc('clicks')->first();
+
+ return [
+ 'summary' => [
+ 'views' => $views,
+ 'unique_visitors' => $uniqueVisitors,
+ 'promotion_impressions' => $promotionImpressions,
+ 'cta_clicks' => $ctaClicks,
+ 'submissions' => $submissionActivity['submitted'],
+ 'approved_live_participations' => $currentSubmissionCounts['live'],
+ 'featured_participations' => $currentSubmissionCounts['featured'],
+ 'reward_grants' => $rewardCounts['total'],
+ 'challenge_clicks' => $challengeMetrics['total_clicks'],
+ 'approval_rate' => $submissionActivity['approval_rate'],
+ 'promotion_clickthrough_rate' => $promotionImpressions > 0 ? round($sourceClicks / $promotionImpressions, 4) : 0.0,
+ 'view_to_submission_conversion' => $submissionActivity['view_to_submission_conversion'],
+ 'top_source_surface' => $topSource ? [
+ 'key' => $topSource['source_surface'],
+ 'label' => $this->sourceSurfaceLabel($topSource['source_surface']),
+ 'views' => $topSource['views'],
+ 'impressions' => $topSource['impressions'],
+ 'clickthrough_rate' => $topSource['clickthrough_rate'],
+ ] : null,
+ 'top_clicked_section' => $topSection,
+ 'top_clicked_entity' => $topEntity,
+ ],
+ 'traffic' => [
+ 'views' => $views,
+ 'unique_visitors' => $uniqueVisitors,
+ 'trend' => $this->trafficTrend($world, $start),
+ ],
+ 'sources' => $sources,
+ 'cta_performance' => $ctaPerformance,
+ 'section_performance' => $sectionPerformance,
+ 'entity_performance' => $entityPerformance,
+ 'participation' => [
+ ...$currentSubmissionCounts,
+ ...$submissionActivity,
+ ],
+ 'challenge' => $challengeMetrics,
+ 'rewards' => $rewardCounts,
+ ];
+ }
+
+ private function eventCounts(World $world, ?Carbon $start): array
+ {
+ return $this->baseEventQuery($world, $start)
+ ->select('event_type', DB::raw('COUNT(*) as total'))
+ ->groupBy('event_type')
+ ->pluck('total', 'event_type')
+ ->map(fn ($count): int => (int) $count)
+ ->all();
+ }
+
+ private function uniqueViewers(World $world, ?Carbon $start): int
+ {
+ return (int) $this->baseEventQuery($world, $start)
+ ->where('event_type', self::EVENT_VIEWED)
+ ->distinct('visitor_key')
+ ->count('visitor_key');
+ }
+
+ private function sourceBreakdown(World $world, ?Carbon $start): array
+ {
+ $impressions = $this->baseEventQuery($world, $start)
+ ->where('event_type', self::EVENT_SOURCE_IMPRESSION)
+ ->select('source_surface', DB::raw('COUNT(*) as impressions'))
+ ->groupBy('source_surface')
+ ->get()
+ ->keyBy(fn (WorldAnalyticsEvent $event): string => (string) ($event->source_surface ?: self::SOURCE_UNKNOWN));
+
+ $views = $this->baseEventQuery($world, $start)
+ ->where('event_type', self::EVENT_VIEWED)
+ ->select('source_surface', DB::raw('COUNT(*) as views'), DB::raw('COUNT(DISTINCT visitor_key) as unique_visitors'))
+ ->groupBy('source_surface')
+ ->get()
+ ->keyBy(fn (WorldAnalyticsEvent $event): string => (string) ($event->source_surface ?: self::SOURCE_UNKNOWN));
+
+ $clicks = $this->baseEventQuery($world, $start)
+ ->where('event_type', self::EVENT_SOURCE_CLICKED)
+ ->select('source_surface', DB::raw('COUNT(*) as clicks'))
+ ->groupBy('source_surface')
+ ->get()
+ ->keyBy(fn (WorldAnalyticsEvent $event): string => (string) ($event->source_surface ?: self::SOURCE_UNKNOWN));
+
+ return collect($this->allowedSourceSurfaces())
+ ->map(function (string $surface) use ($impressions, $views, $clicks): array {
+ $impressionRow = $impressions->get($surface);
+ $viewRow = $views->get($surface);
+ $clickRow = $clicks->get($surface);
+ $impressionCount = (int) ($impressionRow?->impressions ?? 0);
+ $clickCount = (int) ($clickRow?->clicks ?? 0);
+ $viewCount = (int) ($viewRow?->views ?? 0);
+
+ return [
+ 'source_surface' => $surface,
+ 'label' => $this->sourceSurfaceLabel($surface),
+ 'impressions' => $impressionCount,
+ 'views' => $viewCount,
+ 'unique_visitors' => (int) ($viewRow?->unique_visitors ?? 0),
+ 'clicks' => $clickCount,
+ 'clickthrough_rate' => $impressionCount > 0 ? round($clickCount / $impressionCount, 4) : 0.0,
+ 'visit_rate' => $impressionCount > 0 ? round($viewCount / $impressionCount, 4) : 0.0,
+ ];
+ })
+ ->filter(fn (array $row): bool => $row['impressions'] > 0 || $row['views'] > 0 || $row['clicks'] > 0)
+ ->sortByDesc('views')
+ ->values()
+ ->all();
+ }
+
+ private function sectionPerformance(World $world, ?Carbon $start): array
+ {
+ return $this->baseEventQuery($world, $start)
+ ->whereNotNull('section_key')
+ ->whereIn('event_type', [
+ self::EVENT_SECTION_CLICKED,
+ self::EVENT_CTA_CLICKED,
+ self::EVENT_ENTITY_CLICKED,
+ self::EVENT_CHALLENGE_CTA_CLICKED,
+ ])
+ ->select('section_key', DB::raw('COUNT(*) as clicks'))
+ ->groupBy('section_key')
+ ->orderByDesc('clicks')
+ ->get()
+ ->map(fn (WorldAnalyticsEvent $event): array => [
+ 'section_key' => (string) $event->section_key,
+ 'label' => Str::headline((string) $event->section_key),
+ 'clicks' => (int) $event->clicks,
+ ])
+ ->all();
+ }
+
+ private function entityPerformance(World $world, ?Carbon $start): array
+ {
+ return $this->baseEventQuery($world, $start)
+ ->where('event_type', self::EVENT_ENTITY_CLICKED)
+ ->whereNotNull('entity_type')
+ ->whereNotNull('entity_id')
+ ->select('section_key', 'entity_type', 'entity_id', 'entity_title', DB::raw('COUNT(*) as clicks'))
+ ->groupBy('section_key', 'entity_type', 'entity_id', 'entity_title')
+ ->orderByDesc('clicks')
+ ->limit(8)
+ ->get()
+ ->map(fn (WorldAnalyticsEvent $event): array => [
+ 'section_key' => (string) ($event->section_key ?? ''),
+ 'entity_type' => (string) ($event->entity_type ?? ''),
+ 'entity_id' => (int) ($event->entity_id ?? 0),
+ 'entity_title' => (string) ($event->entity_title ?: Str::headline((string) $event->entity_type)),
+ 'clicks' => (int) $event->clicks,
+ ])
+ ->all();
+ }
+
+ private function ctaPerformance(World $world, ?Carbon $start): array
+ {
+ return $this->baseEventQuery($world, $start)
+ ->whereIn('event_type', [self::EVENT_CTA_CLICKED, self::EVENT_CHALLENGE_CTA_CLICKED])
+ ->select('event_type', 'section_key', 'cta_key', DB::raw('COUNT(*) as clicks'))
+ ->groupBy('event_type', 'section_key', 'cta_key')
+ ->orderByDesc('clicks')
+ ->get()
+ ->map(fn (WorldAnalyticsEvent $event): array => [
+ 'event_type' => (string) $event->event_type,
+ 'section_key' => (string) ($event->section_key ?? ''),
+ 'cta_key' => (string) ($event->cta_key ?? ''),
+ 'label' => $this->ctaLabel((string) ($event->cta_key ?? '')),
+ 'clicks' => (int) $event->clicks,
+ ])
+ ->all();
+ }
+
+ private function currentSubmissionCounts(World $world): array
+ {
+ $counts = WorldSubmission::query()
+ ->where('world_id', (int) $world->id)
+ ->select('status', DB::raw('COUNT(*) as total'))
+ ->groupBy('status')
+ ->pluck('total', 'status');
+
+ $featured = (int) WorldSubmission::query()
+ ->where('world_id', (int) $world->id)
+ ->where('status', WorldSubmission::STATUS_LIVE)
+ ->where('is_featured', true)
+ ->count();
+
+ return [
+ 'pending' => (int) ($counts[WorldSubmission::STATUS_PENDING] ?? 0),
+ 'live' => (int) ($counts[WorldSubmission::STATUS_LIVE] ?? 0),
+ 'removed' => (int) ($counts[WorldSubmission::STATUS_REMOVED] ?? 0),
+ 'blocked' => (int) ($counts[WorldSubmission::STATUS_BLOCKED] ?? 0),
+ 'featured' => $featured,
+ ];
+ }
+
+ private function submissionActivityCounts(World $world, ?Carbon $start, array $eventCounts): array
+ {
+ $createdQuery = WorldSubmission::query()->where('world_id', (int) $world->id);
+ if ($start) {
+ $createdQuery->where('created_at', '>=', $start);
+ }
+
+ $submitted = (int) $createdQuery->count();
+ $approved = (int) ($eventCounts[self::EVENT_SUBMISSION_APPROVED] ?? 0);
+ $removed = (int) ($eventCounts[self::EVENT_SUBMISSION_REMOVED] ?? 0);
+ $blocked = (int) ($eventCounts[self::EVENT_SUBMISSION_BLOCKED] ?? 0);
+ $featured = (int) ($eventCounts[self::EVENT_SUBMISSION_FEATURED] ?? 0);
+ $views = max(1, (int) ($eventCounts[self::EVENT_VIEWED] ?? 0));
+
+ return [
+ 'submitted' => $submitted,
+ 'approved' => $approved,
+ 'removed_actions' => $removed,
+ 'blocked_actions' => $blocked,
+ 'featured_actions' => $featured,
+ 'approval_rate' => $submitted > 0 ? round($approved / $submitted, 4) : 0.0,
+ 'removal_rate' => $submitted > 0 ? round($removed / $submitted, 4) : 0.0,
+ 'block_rate' => $submitted > 0 ? round($blocked / $submitted, 4) : 0.0,
+ 'view_to_submission_conversion' => round($submitted / $views, 4),
+ ];
+ }
+
+ private function rewardCounts(World $world, ?Carbon $start): array
+ {
+ $query = WorldRewardGrant::query()->where('world_id', (int) $world->id);
+ if ($start) {
+ $query->where('granted_at', '>=', $start);
+ }
+
+ $counts = $query
+ ->select('reward_type', DB::raw('COUNT(*) as total'))
+ ->groupBy('reward_type')
+ ->pluck('total', 'reward_type');
+
+ return [
+ 'total' => (int) collect($counts)->sum(),
+ 'participant' => (int) ($counts[WorldRewardType::Participant->value] ?? 0),
+ 'featured' => (int) ($counts[WorldRewardType::Featured->value] ?? 0),
+ 'finalist' => (int) ($counts[WorldRewardType::Finalist->value] ?? 0),
+ 'winner' => (int) ($counts[WorldRewardType::Winner->value] ?? 0),
+ 'spotlight' => (int) ($counts[WorldRewardType::Spotlight->value] ?? 0),
+ ];
+ }
+
+ private function challengeMetrics(World $world, ?Carbon $start, array $eventCounts): array
+ {
+ $query = $this->baseEventQuery($world, $start)->whereNotNull('challenge_id');
+ $recapClicks = (clone $query)
+ ->where('event_type', self::EVENT_CHALLENGE_CTA_CLICKED)
+ ->whereIn('cta_key', ['challenge_story', 'challenge_recap'])
+ ->count();
+ $entryClicks = (clone $query)
+ ->where('event_type', self::EVENT_ENTITY_CLICKED)
+ ->where('section_key', 'challenge_entries')
+ ->count();
+ $winnerClicks = (clone $query)
+ ->where('event_type', self::EVENT_ENTITY_CLICKED)
+ ->where('section_key', 'challenge_winners')
+ ->count();
+ $finalistClicks = (clone $query)
+ ->where('event_type', self::EVENT_ENTITY_CLICKED)
+ ->where('section_key', 'challenge_finalists')
+ ->count();
+ $challengeCtaClicks = (int) ($eventCounts[self::EVENT_CHALLENGE_CTA_CLICKED] ?? 0);
+ $submissionStarts = (int) ($eventCounts[self::EVENT_SUBMISSION_STARTED] ?? 0);
+ $submissionsCreated = (int) ($eventCounts[self::EVENT_SUBMISSION_CREATED] ?? 0);
+ $totalClicks = $challengeCtaClicks + (int) $recapClicks + (int) $entryClicks + (int) $winnerClicks + (int) $finalistClicks;
+
+ return [
+ 'linked_challenge_id' => $world->linked_challenge_id ? (int) $world->linked_challenge_id : null,
+ 'challenge_cta_clicks' => $challengeCtaClicks,
+ 'recap_clicks' => (int) $recapClicks,
+ 'entry_clicks' => (int) $entryClicks,
+ 'winner_clicks' => (int) $winnerClicks,
+ 'finalist_clicks' => (int) $finalistClicks,
+ 'submission_starts' => $submissionStarts,
+ 'submissions_created' => $submissionsCreated,
+ 'click_to_submission_start_conversion' => $totalClicks > 0 ? round($submissionStarts / $totalClicks, 4) : 0.0,
+ 'click_to_submission_conversion' => $totalClicks > 0 ? round($submissionsCreated / $totalClicks, 4) : 0.0,
+ 'total_clicks' => $totalClicks,
+ ];
+ }
+
+ private function portfolioRangePayload(?Carbon $start): array
+ {
+ $worlds = World::query()
+ ->select(['id', 'title', 'slug', 'recurrence_key', 'edition_year'])
+ ->get()
+ ->keyBy('id');
+
+ $viewRows = WorldAnalyticsEvent::query()
+ ->when($start, fn (Builder $builder): Builder => $builder->where('occurred_at', '>=', $start))
+ ->where('event_type', self::EVENT_VIEWED)
+ ->select('world_id', DB::raw('COUNT(*) as views'), DB::raw('COUNT(DISTINCT visitor_key) as unique_visitors'))
+ ->groupBy('world_id')
+ ->get()
+ ->keyBy('world_id');
+
+ $impressionRows = WorldAnalyticsEvent::query()
+ ->when($start, fn (Builder $builder): Builder => $builder->where('occurred_at', '>=', $start))
+ ->where('event_type', self::EVENT_SOURCE_IMPRESSION)
+ ->select('world_id', DB::raw('COUNT(*) as impressions'))
+ ->groupBy('world_id')
+ ->get()
+ ->keyBy('world_id');
+
+ $sourceClickRows = WorldAnalyticsEvent::query()
+ ->when($start, fn (Builder $builder): Builder => $builder->where('occurred_at', '>=', $start))
+ ->where('event_type', self::EVENT_SOURCE_CLICKED)
+ ->select('world_id', DB::raw('COUNT(*) as source_clicks'))
+ ->groupBy('world_id')
+ ->get()
+ ->keyBy('world_id');
+
+ $submissionRows = WorldSubmission::query()
+ ->when($start, fn (Builder $builder): Builder => $builder->where('created_at', '>=', $start))
+ ->select('world_id', DB::raw('COUNT(*) as submissions'))
+ ->groupBy('world_id')
+ ->get()
+ ->keyBy('world_id');
+
+ $rewardRows = WorldRewardGrant::query()
+ ->when($start, fn (Builder $builder): Builder => $builder->where('granted_at', '>=', $start))
+ ->select('world_id', DB::raw('COUNT(*) as reward_grants'))
+ ->groupBy('world_id')
+ ->get()
+ ->keyBy('world_id');
+
+ $trackedWorldIds = collect()
+ ->merge($viewRows->keys())
+ ->merge($impressionRows->keys())
+ ->merge($sourceClickRows->keys())
+ ->merge($submissionRows->keys())
+ ->merge($rewardRows->keys())
+ ->map(fn ($id): int => (int) $id)
+ ->unique()
+ ->values();
+
+ $rows = $trackedWorldIds
+ ->map(function (int $worldId) use ($worlds, $viewRows, $impressionRows, $sourceClickRows, $submissionRows, $rewardRows): ?array {
+ /** @var World|null $world */
+ $world = $worlds->get($worldId);
+
+ if (! $world) {
+ return null;
+ }
+
+ $views = (int) ($viewRows->get($worldId)?->views ?? 0);
+ $uniqueVisitors = (int) ($viewRows->get($worldId)?->unique_visitors ?? 0);
+ $impressions = (int) ($impressionRows->get($worldId)?->impressions ?? 0);
+ $sourceClicks = (int) ($sourceClickRows->get($worldId)?->source_clicks ?? 0);
+ $submissions = (int) ($submissionRows->get($worldId)?->submissions ?? 0);
+ $rewardGrants = (int) ($rewardRows->get($worldId)?->reward_grants ?? 0);
+
+ return [
+ 'world_id' => (int) $world->id,
+ 'title' => (string) $world->title,
+ 'slug' => (string) $world->slug,
+ 'edition_year' => $world->edition_year ? (int) $world->edition_year : null,
+ 'recurrence_key' => $this->nullableString($world->recurrence_key),
+ 'edit_url' => route('studio.worlds.edit', ['world' => $world->id]),
+ 'public_url' => $world->publicUrl(),
+ 'views' => $views,
+ 'unique_visitors' => $uniqueVisitors,
+ 'impressions' => $impressions,
+ 'source_clicks' => $sourceClicks,
+ 'submissions' => $submissions,
+ 'reward_grants' => $rewardGrants,
+ 'view_to_submission_conversion' => $views > 0 ? round($submissions / $views, 4) : 0.0,
+ 'promotion_clickthrough_rate' => $impressions > 0 ? round($sourceClicks / $impressions, 4) : 0.0,
+ ];
+ })
+ ->filter()
+ ->values();
+
+ return [
+ 'summary' => [
+ 'tracked_worlds' => $rows->count(),
+ 'views' => (int) $rows->sum('views'),
+ 'unique_visitors' => (int) $rows->sum('unique_visitors'),
+ 'promotion_impressions' => (int) $rows->sum('impressions'),
+ 'submissions' => (int) $rows->sum('submissions'),
+ 'reward_grants' => (int) $rows->sum('reward_grants'),
+ ],
+ 'leaderboards' => [
+ 'views' => $rows->sortByDesc('views')->take(5)->values()->all(),
+ 'unique_visitors' => $rows->sortByDesc('unique_visitors')->take(5)->values()->all(),
+ 'submissions' => $rows->sortByDesc('submissions')->take(5)->values()->all(),
+ 'reward_grants' => $rows->sortByDesc('reward_grants')->take(5)->values()->all(),
+ 'conversion' => $rows
+ ->filter(fn (array $row): bool => $row['views'] > 0 || $row['submissions'] > 0)
+ ->sortByDesc('view_to_submission_conversion')
+ ->take(5)
+ ->values()
+ ->all(),
+ ],
+ ];
+ }
+
+ private function trafficTrend(World $world, ?Carbon $start): array
+ {
+ $events = $this->baseEventQuery($world, $start)
+ ->where('event_type', self::EVENT_VIEWED)
+ ->orderBy('occurred_at')
+ ->pluck('occurred_at')
+ ->map(fn ($timestamp): Carbon => Carbon::parse($timestamp));
+
+ if ($events->isEmpty()) {
+ return [];
+ }
+
+ $bucketByMonth = $start === null && $events->first()?->diffInDays($events->last()) > 90;
+
+ return $events
+ ->groupBy(fn (Carbon $date): string => $bucketByMonth ? $date->format('Y-m') : $date->toDateString())
+ ->map(fn (Collection $items, string $label): array => [
+ 'label' => $label,
+ 'views' => $items->count(),
+ ])
+ ->values()
+ ->all();
+ }
+
+ private function editionComparisonPayload(World $world): ?array
+ {
+ $recurrenceKey = trim((string) ($world->recurrence_key ?? ''));
+ if ($recurrenceKey === '') {
+ return null;
+ }
+
+ $editions = World::query()
+ ->where('recurrence_key', $recurrenceKey)
+ ->orderByDesc('edition_year')
+ ->orderByDesc('starts_at')
+ ->get();
+
+ if ($editions->count() < 2) {
+ return null;
+ }
+
+ $worldIds = $editions->pluck('id')->map(fn ($id): int => (int) $id)->all();
+
+ $viewCounts = WorldAnalyticsEvent::query()
+ ->whereIn('world_id', $worldIds)
+ ->where('event_type', self::EVENT_VIEWED)
+ ->select('world_id', DB::raw('COUNT(*) as total_views'), DB::raw('COUNT(DISTINCT visitor_key) as unique_visitors'))
+ ->groupBy('world_id')
+ ->get()
+ ->keyBy('world_id');
+
+ $submissionCounts = WorldSubmission::query()
+ ->whereIn('world_id', $worldIds)
+ ->select(
+ 'world_id',
+ DB::raw('COUNT(*) as submitted_total'),
+ DB::raw("SUM(CASE WHEN status = 'live' THEN 1 ELSE 0 END) as live_total"),
+ DB::raw("SUM(CASE WHEN is_featured = 1 AND status = 'live' THEN 1 ELSE 0 END) as featured_total")
+ )
+ ->groupBy('world_id')
+ ->get()
+ ->keyBy('world_id');
+
+ $challengeClicks = WorldAnalyticsEvent::query()
+ ->whereIn('world_id', $worldIds)
+ ->whereIn('event_type', [self::EVENT_CHALLENGE_CTA_CLICKED, self::EVENT_ENTITY_CLICKED])
+ ->where(function (Builder $builder): void {
+ $builder->whereNotNull('challenge_id')
+ ->orWhereIn('section_key', ['challenge_entries', 'challenge_winners', 'challenge_finalists', 'challenge']);
+ })
+ ->select('world_id', DB::raw('COUNT(*) as total_challenge_clicks'))
+ ->groupBy('world_id')
+ ->get()
+ ->keyBy('world_id');
+
+ $rewardCounts = WorldRewardGrant::query()
+ ->whereIn('world_id', $worldIds)
+ ->select('world_id', DB::raw('COUNT(*) as total_rewards'))
+ ->groupBy('world_id')
+ ->get()
+ ->keyBy('world_id');
+
+ return [
+ 'recurrence_key' => $recurrenceKey,
+ 'label' => trim((string) ($world->title ?: Str::headline($recurrenceKey))),
+ 'editions' => $editions->map(function (World $edition) use ($world, $viewCounts, $submissionCounts, $challengeClicks, $rewardCounts): array {
+ $views = $viewCounts->get((int) $edition->id);
+ $submissions = $submissionCounts->get((int) $edition->id);
+ $challenge = $challengeClicks->get((int) $edition->id);
+ $rewards = $rewardCounts->get((int) $edition->id);
+
+ return [
+ 'world_id' => (int) $edition->id,
+ 'title' => (string) $edition->title,
+ 'edition_year' => $edition->edition_year ? (int) $edition->edition_year : null,
+ 'public_url' => $edition->publicUrl(),
+ 'is_current_world' => (int) $edition->id === (int) $world->id,
+ 'metrics' => [
+ 'views' => (int) ($views?->total_views ?? 0),
+ 'unique_visitors' => (int) ($views?->unique_visitors ?? 0),
+ 'submissions' => (int) ($submissions?->submitted_total ?? 0),
+ 'live_participations' => (int) ($submissions?->live_total ?? 0),
+ 'featured_participations' => (int) ($submissions?->featured_total ?? 0),
+ 'challenge_clicks' => (int) ($challenge?->total_challenge_clicks ?? 0),
+ 'reward_grants' => (int) ($rewards?->total_rewards ?? 0),
+ ],
+ ];
+ })->all(),
+ ];
+ }
+
+ private function baseEventQuery(World $world, ?Carbon $start): Builder
+ {
+ return WorldAnalyticsEvent::query()
+ ->where('world_id', (int) $world->id)
+ ->when($start, fn (Builder $builder): Builder => $builder->where('occurred_at', '>=', $start));
+ }
+
+ private function resolveVisitorKey(Request $request, ?User $user, string $visitorToken): string
+ {
+ if ($user) {
+ return hash('sha256', 'user:' . $user->id);
+ }
+
+ $token = trim($visitorToken);
+ if ($token !== '') {
+ return hash('sha256', 'visitor:' . $token);
+ }
+
+ $sessionId = $request->session()->getId();
+ if ($sessionId !== '') {
+ return hash('sha256', 'session:' . $sessionId);
+ }
+
+ return hash('sha256', 'fallback:' . ($request->ip() ?? 'unknown') . '|' . Str::limit((string) $request->userAgent(), 120, ''));
+ }
+
+ private function sanitizeMeta(array $meta): ?array
+ {
+ $sanitized = collect($meta)
+ ->map(function ($value) {
+ if (is_scalar($value) || $value === null) {
+ return $value;
+ }
+
+ if (is_array($value)) {
+ return collect($value)
+ ->filter(fn ($entry) => is_scalar($entry) || $entry === null)
+ ->map(fn ($entry) => is_string($entry) ? Str::limit($entry, 200, '') : $entry)
+ ->values()
+ ->all();
+ }
+
+ return null;
+ })
+ ->filter(fn ($value) => $value !== null && $value !== '')
+ ->all();
+
+ return $sanitized === [] ? null : $sanitized;
+ }
+
+ private function ctaLabel(string $ctaKey): string
+ {
+ return match ($ctaKey) {
+ 'main_world_cta' => 'Main world CTA',
+ 'badge_cta' => 'Badge CTA',
+ 'challenge_primary' => 'Challenge primary CTA',
+ 'challenge_story' => 'Challenge story CTA',
+ 'challenge_recap' => 'Challenge recap CTA',
+ 'challenge_direct' => 'Direct challenge CTA',
+ 'linked_group' => 'Linked group CTA',
+ 'family_route' => 'Family route CTA',
+ 'edition_archive' => 'Edition archive CTA',
+ 'supporting_item' => 'Supporting item CTA',
+ default => Str::headline(str_replace('_', ' ', $ctaKey ?: 'cta')),
+ };
+ }
+
+ private function nullableString(mixed $value): ?string
+ {
+ $text = trim((string) ($value ?? ''));
+
+ return $text === '' ? null : Str::limit($text, 180, '');
+ }
+}
\ No newline at end of file
diff --git a/app/Services/Worlds/WorldEditorialSuggestionService.php b/app/Services/Worlds/WorldEditorialSuggestionService.php
new file mode 100644
index 00000000..30e59b57
--- /dev/null
+++ b/app/Services/Worlds/WorldEditorialSuggestionService.php
@@ -0,0 +1,1421 @@
+buildContext($world, $viewer);
+ $stateRows = $world->editorialSuggestionStates()->get();
+ $stateMap = $stateRows->keyBy(fn (WorldEditorialSuggestionState $state): string => $this->itemKey((string) $state->related_type, (int) $state->related_id));
+
+ $candidateGroups = [
+ 'challenge' => $this->challengeHighlightSuggestions($world, $context),
+ 'community' => $this->communitySuggestions($world, $context),
+ 'artworks' => $this->artworkSuggestions($world, $context, $viewer),
+ 'creators' => $this->creatorSuggestions($world, $context),
+ 'collections' => $this->collectionSuggestions($world, $context, $viewer),
+ 'groups' => $this->groupSuggestions($world, $context, $viewer),
+ 'news' => $this->newsSuggestions($world, $context),
+ ];
+
+ $candidateMap = collect($candidateGroups)
+ ->flatten(1)
+ ->keyBy(fn (array $item): string => (string) $item['key']);
+
+ $groups = [];
+ $seenKeys = [];
+
+ foreach (self::GROUP_ORDER as $groupKey) {
+ $definition = $this->groupDefinition($groupKey);
+ $items = collect($candidateGroups[$groupKey] ?? [])
+ ->reject(function (array $item) use ($seenKeys, $stateMap, $context): bool {
+ return in_array((string) $item['key'], $seenKeys, true)
+ || $stateMap->has((string) $item['key'])
+ || $this->isAlreadyAttached($item, $context);
+ })
+ ->take(8)
+ ->values();
+
+ $seenKeys = array_values(array_unique(array_merge($seenKeys, $items->pluck('key')->all())));
+
+ $groups[] = [
+ 'key' => $groupKey,
+ 'label' => $definition['label'],
+ 'description' => $definition['description'],
+ 'empty_label' => $definition['empty_label'],
+ 'items' => $items->all(),
+ 'count' => $items->count(),
+ ];
+ }
+
+ $pinnedItems = $stateRows
+ ->where('status', WorldEditorialSuggestionState::STATUS_PINNED)
+ ->map(fn (WorldEditorialSuggestionState $state): ?array => $this->stateBackedItem($state, $candidateMap, $viewer))
+ ->filter()
+ ->values()
+ ->all();
+
+ $suppressedItems = $stateRows
+ ->whereIn('status', [
+ WorldEditorialSuggestionState::STATUS_DISMISSED,
+ WorldEditorialSuggestionState::STATUS_NOT_RELEVANT,
+ ])
+ ->map(fn (WorldEditorialSuggestionState $state): ?array => $this->stateBackedItem($state, $candidateMap, $viewer))
+ ->filter()
+ ->values()
+ ->all();
+
+ $availableCount = (int) collect($groups)->sum('count');
+ $analyticsSignalCount = (int) collect($groups)
+ ->flatMap(fn (array $group): array => (array) ($group['items'] ?? []))
+ ->filter(fn (array $item): bool => (bool) data_get($item, 'signals.analytics_informed', false))
+ ->count();
+
+ return [
+ 'enabled' => true,
+ 'summary' => [
+ 'available_count' => $availableCount,
+ 'pinned_count' => count($pinnedItems),
+ 'suppressed_count' => count($suppressedItems),
+ 'analytics_signal_count' => $analyticsSignalCount,
+ 'world_is_recurring' => (bool) $world->is_recurring,
+ 'has_linked_challenge' => $context['linked_challenge'] instanceof GroupChallenge,
+ 'family_signal_count' => count($context['family_creator_ids']) + count($context['family_group_ids']) + count($context['family_collection_ids']),
+ 'community_submission_count' => count($context['live_submission_artwork_ids']),
+ ],
+ 'filters' => [
+ 'category_options' => array_values(array_filter(array_map(function (array $group): ?array {
+ if (($group['count'] ?? 0) < 1) {
+ return null;
+ }
+
+ return [
+ 'value' => (string) $group['key'],
+ 'label' => (string) $group['label'],
+ 'count' => (int) $group['count'],
+ ];
+ }, $groups))),
+ 'type_options' => $this->typeFilterOptions(),
+ 'section_options' => $this->sectionFilterOptions(),
+ 'sort_options' => $this->sortFilterOptions(),
+ ],
+ 'groups' => $groups,
+ 'pinned_items' => $pinnedItems,
+ 'suppressed_items' => $suppressedItems,
+ 'generated_at' => now()->toIso8601String(),
+ ];
+ }
+
+ public function addSuggestionToSection(World $world, User $actor, string $relatedType, int $relatedId, string $sectionKey, bool $featured = false): array
+ {
+ $this->assertSectionCompatibility($relatedType, $sectionKey);
+
+ $existing = $world->worldRelations()
+ ->where('related_type', $relatedType)
+ ->where('related_id', $relatedId)
+ ->first();
+
+ if ($existing) {
+ if ($featured && ! (bool) $existing->is_featured) {
+ $existing->forceFill(['is_featured' => true])->save();
+ }
+
+ $world->editorialSuggestionStates()
+ ->where('related_type', $relatedType)
+ ->where('related_id', $relatedId)
+ ->delete();
+
+ return [
+ 'message' => 'Suggestion was already attached to this world.',
+ 'relation' => $this->relationPayload($existing->fresh(), $actor),
+ 'already_attached' => true,
+ ];
+ }
+
+ $relation = $world->worldRelations()->create([
+ 'section_key' => $sectionKey,
+ 'related_type' => $relatedType,
+ 'related_id' => $relatedId,
+ 'context_label' => null,
+ 'sort_order' => (int) $world->worldRelations()->where('section_key', $sectionKey)->max('sort_order') + 1,
+ 'is_featured' => $featured,
+ ]);
+
+ $world->editorialSuggestionStates()
+ ->where('related_type', $relatedType)
+ ->where('related_id', $relatedId)
+ ->delete();
+
+ return [
+ 'message' => $featured ? 'Suggestion added to the featured section.' : 'Suggestion added to the section.',
+ 'relation' => $this->relationPayload($relation->fresh(), $actor),
+ 'already_attached' => false,
+ ];
+ }
+
+ public function pinSuggestion(World $world, User $actor, string $relatedType, int $relatedId, ?string $sectionKey = null): array
+ {
+ if ($sectionKey !== null && $sectionKey !== '') {
+ $this->assertSectionCompatibility($relatedType, $sectionKey);
+ }
+
+ $state = $world->editorialSuggestionStates()->updateOrCreate(
+ [
+ 'related_type' => $relatedType,
+ 'related_id' => $relatedId,
+ ],
+ [
+ 'status' => WorldEditorialSuggestionState::STATUS_PINNED,
+ 'section_key' => $sectionKey !== '' ? $sectionKey : null,
+ 'acted_by_user_id' => (int) $actor->id,
+ ],
+ );
+
+ return [
+ 'message' => 'Suggestion pinned for later.',
+ 'state' => [
+ 'status' => (string) $state->status,
+ 'section_key' => $state->section_key,
+ ],
+ ];
+ }
+
+ public function dismissSuggestion(World $world, User $actor, string $relatedType, int $relatedId): array
+ {
+ return $this->storeFeedbackState($world, $actor, $relatedType, $relatedId, WorldEditorialSuggestionState::STATUS_DISMISSED, 'Suggestion dismissed for this edition.');
+ }
+
+ public function markSuggestionNotRelevant(World $world, User $actor, string $relatedType, int $relatedId): array
+ {
+ return $this->storeFeedbackState($world, $actor, $relatedType, $relatedId, WorldEditorialSuggestionState::STATUS_NOT_RELEVANT, 'Suggestion marked not relevant for this edition.');
+ }
+
+ public function restoreSuggestion(World $world, string $relatedType, int $relatedId): array
+ {
+ $world->editorialSuggestionStates()
+ ->where('related_type', $relatedType)
+ ->where('related_id', $relatedId)
+ ->delete();
+
+ return [
+ 'message' => 'Suggestion restored to the review queue.',
+ ];
+ }
+
+ private function storeFeedbackState(World $world, User $actor, string $relatedType, int $relatedId, string $status, string $message): array
+ {
+ $state = $world->editorialSuggestionStates()->updateOrCreate(
+ [
+ 'related_type' => $relatedType,
+ 'related_id' => $relatedId,
+ ],
+ [
+ 'status' => $status,
+ 'section_key' => null,
+ 'acted_by_user_id' => (int) $actor->id,
+ ],
+ );
+
+ return [
+ 'message' => $message,
+ 'state' => [
+ 'status' => (string) $state->status,
+ ],
+ ];
+ }
+
+ private function buildContext(World $world, ?User $viewer): array
+ {
+ $world->loadMissing(['worldRelations', 'linkedChallenge.group', 'linkedChallenge.outcomes']);
+
+ $themeTags = collect((array) data_get(config('worlds.themes'), ($world->theme_key ?: '') . '.related_tags_json', []))
+ ->map(fn ($value): string => Str::lower(trim((string) $value)))
+ ->filter()
+ ->values()
+ ->all();
+
+ $worldTags = collect((array) ($world->related_tags_json ?? []))
+ ->map(fn ($value): string => Str::lower(trim((string) $value)))
+ ->filter()
+ ->values()
+ ->all();
+
+ $keywords = $this->keywordTokens(implode(' ', array_filter([
+ (string) $world->title,
+ (string) ($world->slug ?? ''),
+ (string) ($world->tagline ?? ''),
+ (string) ($world->summary ?? ''),
+ trim(strip_tags((string) ($world->description ?? ''))),
+ (string) ($world->campaign_label ?? ''),
+ (string) ($world->recurrence_key ?? ''),
+ (string) ($world->linkedChallenge?->title ?? ''),
+ (string) ($world->linkedChallenge?->group?->name ?? ''),
+ implode(' ', $themeTags),
+ implode(' ', $worldTags),
+ ])));
+
+ $relations = $world->worldRelations;
+ $attachedByType = $relations
+ ->groupBy('related_type')
+ ->map(fn (SupportCollection $items): array => $items->pluck('related_id')->map(fn ($id): int => (int) $id)->unique()->values()->all())
+ ->all();
+
+ $liveSubmissions = WorldSubmission::query()
+ ->where('world_id', $world->id)
+ ->where('status', WorldSubmission::STATUS_LIVE)
+ ->with(['artwork.user.profile', 'artwork.tags', 'artwork.categories.contentType', 'artwork.stats'])
+ ->orderByDesc('is_featured')
+ ->orderByDesc('featured_at')
+ ->orderByDesc('reviewed_at')
+ ->limit(18)
+ ->get();
+
+ $linkedChallenge = $world->linkedChallenge && $world->linkedChallenge->group && $world->linkedChallenge->canBeViewedBy($viewer)
+ ? $world->linkedChallenge
+ : null;
+
+ $challengeArtworks = $linkedChallenge
+ ? $this->visibleChallengeArtworkQuery($linkedChallenge, $viewer)
+ ->orderBy('group_challenge_artworks.sort_order')
+ ->limit(16)
+ ->get()
+ : collect();
+
+ $familyCreatorIds = [];
+ $familyGroupIds = [];
+ $familyCollectionIds = [];
+
+ if ((bool) $world->is_recurring && trim((string) ($world->recurrence_key ?? '')) !== '') {
+ $familyWorlds = World::query()
+ ->with('worldRelations')
+ ->where('recurrence_key', (string) $world->recurrence_key)
+ ->where('id', '!=', $world->id)
+ ->orderByDesc('edition_year')
+ ->limit(6)
+ ->get();
+
+ $familyRelationArtworkIds = $familyWorlds
+ ->flatMap(fn (World $item) => $item->worldRelations->where('related_type', WorldRelation::TYPE_ARTWORK)->pluck('related_id')->all())
+ ->map(fn ($id): int => (int) $id)
+ ->filter(fn (int $id): bool => $id > 0)
+ ->unique()
+ ->values();
+
+ $familySubmissionArtworkIds = WorldSubmission::query()
+ ->whereIn('world_id', $familyWorlds->pluck('id')->all())
+ ->where('status', WorldSubmission::STATUS_LIVE)
+ ->pluck('artwork_id')
+ ->map(fn ($id): int => (int) $id)
+ ->filter(fn (int $id): bool => $id > 0)
+ ->unique()
+ ->values();
+
+ $familyArtworks = Artwork::query()
+ ->with('tags')
+ ->whereIn('id', $familyRelationArtworkIds->merge($familySubmissionArtworkIds)->unique()->all())
+ ->get(['id', 'user_id', 'group_id']);
+
+ $familyCreatorIds = $familyWorlds
+ ->flatMap(fn (World $item) => $item->worldRelations->where('related_type', WorldRelation::TYPE_USER)->pluck('related_id')->all())
+ ->map(fn ($id): int => (int) $id)
+ ->merge($familyArtworks->pluck('user_id')->map(fn ($id): int => (int) $id))
+ ->filter(fn (int $id): bool => $id > 0)
+ ->unique()
+ ->values()
+ ->all();
+
+ $familyGroupIds = $familyWorlds
+ ->flatMap(fn (World $item) => $item->worldRelations->where('related_type', WorldRelation::TYPE_GROUP)->pluck('related_id')->all())
+ ->map(fn ($id): int => (int) $id)
+ ->merge($familyArtworks->pluck('group_id')->map(fn ($id): int => (int) $id))
+ ->filter(fn (int $id): bool => $id > 0)
+ ->unique()
+ ->values()
+ ->all();
+
+ $familyCollectionIds = $familyWorlds
+ ->flatMap(fn (World $item) => $item->worldRelations->where('related_type', WorldRelation::TYPE_COLLECTION)->pluck('related_id')->all())
+ ->map(fn ($id): int => (int) $id)
+ ->filter(fn (int $id): bool => $id > 0)
+ ->unique()
+ ->values()
+ ->all();
+ }
+
+ $analyticsReport = $this->analytics->studioReport($world);
+ $analyticsRange = (array) data_get($analyticsReport, 'ranges.30d', []);
+ $analyticsEntityClicks = collect((array) data_get($analyticsRange, 'entity_performance', []))
+ ->filter(fn (array $item): bool => trim((string) ($item['entity_type'] ?? '')) !== '' && (int) ($item['entity_id'] ?? 0) > 0)
+ ->mapWithKeys(fn (array $item): array => [
+ $this->itemKey((string) $item['entity_type'], (int) $item['entity_id']) => [
+ 'clicks' => (int) ($item['clicks'] ?? 0),
+ 'section_key' => trim((string) ($item['section_key'] ?? '')),
+ ],
+ ])
+ ->all();
+ $analyticsSectionClicks = collect((array) data_get($analyticsRange, 'section_performance', []))
+ ->mapWithKeys(fn (array $item): array => [
+ trim((string) ($item['section_key'] ?? '')) => (int) ($item['clicks'] ?? 0),
+ ])
+ ->filter(fn (int $clicks, string $sectionKey): bool => $sectionKey !== '')
+ ->all();
+ $underperformingSections = $this->underperformingSectionKeys($world, $analyticsSectionClicks, (int) data_get($analyticsRange, 'summary.views', 0));
+
+ return [
+ 'keywords' => $keywords,
+ 'tag_slugs' => array_values(array_unique(array_merge($worldTags, $themeTags))),
+ 'attached_by_type' => $attachedByType,
+ 'live_submissions' => $liveSubmissions,
+ 'live_submission_artwork_ids' => $liveSubmissions->pluck('artwork_id')->map(fn ($id): int => (int) $id)->unique()->values()->all(),
+ 'community_creator_ids' => $liveSubmissions->map(fn (WorldSubmission $submission): int => (int) ($submission->artwork?->user_id ?? 0))->filter(fn (int $id): bool => $id > 0)->unique()->values()->all(),
+ 'linked_challenge' => $linkedChallenge,
+ 'challenge_artworks' => $challengeArtworks,
+ 'challenge_artwork_ids' => $challengeArtworks->pluck('id')->map(fn ($id): int => (int) $id)->unique()->values()->all(),
+ 'challenge_creator_ids' => $challengeArtworks->pluck('user_id')->map(fn ($id): int => (int) $id)->filter(fn (int $id): bool => $id > 0)->unique()->values()->all(),
+ 'challenge_group_id' => (int) ($linkedChallenge?->group_id ?? 0),
+ 'family_creator_ids' => $familyCreatorIds,
+ 'family_group_ids' => $familyGroupIds,
+ 'family_collection_ids' => $familyCollectionIds,
+ 'analytics_entity_clicks' => $analyticsEntityClicks,
+ 'analytics_section_clicks' => $analyticsSectionClicks,
+ 'underperforming_section_keys' => $underperformingSections,
+ ];
+ }
+
+ private function artworkSuggestions(World $world, array $context, ?User $viewer): array
+ {
+ $excludedArtworkIds = array_merge(
+ $context['attached_by_type'][WorldRelation::TYPE_ARTWORK] ?? [],
+ $context['live_submission_artwork_ids'] ?? [],
+ $context['challenge_artwork_ids'] ?? [],
+ );
+
+ $query = Artwork::query()
+ ->with(['user.profile', 'tags', 'categories.contentType', 'stats'])
+ ->catalogVisible()
+ ->when($excludedArtworkIds !== [], fn (Builder $builder): Builder => $builder->whereNotIn('artworks.id', $excludedArtworkIds))
+ ->where(function (Builder $builder) use ($context): void {
+ $this->applyArtworkThemeFilters($builder, $context['keywords'], $context['tag_slugs']);
+
+ if ($context['family_creator_ids'] !== []) {
+ $builder->orWhereIn('artworks.user_id', $context['family_creator_ids']);
+ }
+
+ if ($context['community_creator_ids'] !== []) {
+ $builder->orWhereIn('artworks.user_id', $context['community_creator_ids']);
+ }
+ })
+ ->orderByDesc('published_at')
+ ->limit(28);
+
+ $this->maturity->applyViewerFilter($query, $viewer);
+
+ return $query->get()
+ ->map(fn (Artwork $artwork): ?array => $this->buildArtworkSuggestionItem($artwork, $context, 'artworks', 'Artwork suggestion'))
+ ->filter()
+ ->sortByDesc('score')
+ ->take(8)
+ ->values()
+ ->all();
+ }
+
+ private function communitySuggestions(World $world, array $context): array
+ {
+ return collect($context['live_submissions'])
+ ->map(function (WorldSubmission $submission) use ($context): ?array {
+ $artwork = $submission->artwork;
+
+ if (! $artwork || (string) $artwork->artwork_status !== 'published' || (string) $artwork->visibility !== Artwork::VISIBILITY_PUBLIC) {
+ return null;
+ }
+
+ if (in_array((int) $artwork->id, $context['attached_by_type'][WorldRelation::TYPE_ARTWORK] ?? [], true)) {
+ return null;
+ }
+
+ $reasons = [
+ $this->reason($submission->is_featured ? 'Already a featured community submission' : 'Already live in this world', $submission->is_featured ? 'amber' : 'emerald'),
+ ];
+ $score = 34 + ($submission->is_featured ? 14 : 0) + $this->artworkPerformanceScore($artwork) + $this->freshnessScore($artwork->published_at, 14, 14, 6);
+
+ if (in_array((int) $artwork->user_id, $context['family_creator_ids'], true)) {
+ $score += 8;
+ $reasons[] = $this->reason('Returning creator from this world family', 'sky');
+ }
+
+ $signals = [
+ 'challenge_linked' => false,
+ 'community_submission' => true,
+ 'recurring_history_informed' => in_array((int) $artwork->user_id, $context['family_creator_ids'], true),
+ 'analytics_informed' => false,
+ 'not_yet_featured' => true,
+ ];
+
+ $this->applyAnalyticsEntityBoost($context, WorldRelation::TYPE_ARTWORK, (int) $artwork->id, $score, $reasons, $signals, 'Already drawing clicks from this world', 4, 18);
+
+ if ($this->artworkPerformanceScore($artwork) >= 12) {
+ $reasons[] = $this->reason('Strong engagement on platform', 'rose');
+ }
+
+ $preview = $this->worlds->previewArtwork($artwork, 'Community standout');
+
+ if ($preview) {
+ $this->applyUnderperformingSectionBoost($context, $preview, $score, $reasons, $signals);
+ }
+
+ return $preview ? $this->finalizeItem($preview, 'community', $score, $reasons, $signals, [
+ 'performance_value' => $this->artworkPerformanceScore($artwork),
+ 'freshness_timestamp' => $artwork->published_at?->timestamp,
+ ]) : null;
+ })
+ ->filter()
+ ->sortByDesc('score')
+ ->take(8)
+ ->values()
+ ->all();
+ }
+
+ private function challengeHighlightSuggestions(World $world, array $context): array
+ {
+ $challenge = $context['linked_challenge'];
+
+ if (! $challenge) {
+ return [];
+ }
+
+ $winnerIds = $challenge->outcomes
+ ->where('outcome_type', GroupChallengeOutcome::TYPE_WINNER)
+ ->pluck('artwork_id')
+ ->map(fn ($id): int => (int) $id)
+ ->all();
+ $finalistIds = $challenge->outcomes
+ ->where('outcome_type', GroupChallengeOutcome::TYPE_FINALIST)
+ ->pluck('artwork_id')
+ ->map(fn ($id): int => (int) $id)
+ ->all();
+
+ return collect($context['challenge_artworks'])
+ ->reject(fn (Artwork $artwork): bool => in_array((int) $artwork->id, $context['attached_by_type'][WorldRelation::TYPE_ARTWORK] ?? [], true))
+ ->map(function (Artwork $artwork) use ($winnerIds, $finalistIds, $context): ?array {
+ $score = 28 + $this->artworkPerformanceScore($artwork) + $this->freshnessScore($artwork->published_at, 14, 12, 6);
+ $reasons = [];
+ $signals = [
+ 'challenge_linked' => true,
+ 'community_submission' => false,
+ 'recurring_history_informed' => false,
+ 'analytics_informed' => false,
+ 'not_yet_featured' => true,
+ ];
+
+ if (in_array((int) $artwork->id, $winnerIds, true)) {
+ $score += 22;
+ $reasons[] = $this->reason('Challenge winner', 'amber');
+ } elseif (in_array((int) $artwork->id, $finalistIds, true)) {
+ $score += 16;
+ $reasons[] = $this->reason('Challenge finalist', 'sky');
+ } else {
+ $reasons[] = $this->reason('Linked challenge entry', 'emerald');
+ }
+
+ if (in_array((int) $artwork->user_id, $context['family_creator_ids'], true)) {
+ $score += 8;
+ $reasons[] = $this->reason('Creator has prior world-family momentum', 'sky');
+ $signals['recurring_history_informed'] = true;
+ }
+
+ $this->applyAnalyticsEntityBoost($context, WorldRelation::TYPE_ARTWORK, (int) $artwork->id, $score, $reasons, $signals);
+
+ if ($this->artworkPerformanceScore($artwork) >= 12) {
+ $reasons[] = $this->reason('Strong engagement on platform', 'rose');
+ }
+
+ $preview = $this->worlds->previewArtwork($artwork, 'Challenge highlight');
+
+ if ($preview) {
+ $this->applyUnderperformingSectionBoost($context, $preview, $score, $reasons, $signals);
+ }
+
+ return $preview ? $this->finalizeItem($preview, 'challenge', $score, $reasons, $signals, [
+ 'performance_value' => $this->artworkPerformanceScore($artwork),
+ 'freshness_timestamp' => $artwork->published_at?->timestamp,
+ ]) : null;
+ })
+ ->filter()
+ ->sortByDesc('score')
+ ->take(8)
+ ->values()
+ ->all();
+ }
+
+ private function creatorSuggestions(World $world, array $context): array
+ {
+ $candidateUserIds = collect()
+ ->merge($context['community_creator_ids'])
+ ->merge($context['challenge_creator_ids'])
+ ->merge($context['family_creator_ids'])
+ ->merge($this->matchingArtworkCreatorIds($context))
+ ->filter(fn ($id): bool => (int) $id > 0)
+ ->unique()
+ ->values();
+
+ if ($candidateUserIds->isEmpty()) {
+ return [];
+ }
+
+ return User::query()
+ ->with(['profile', 'statistics'])
+ ->whereIn('id', $candidateUserIds->all())
+ ->whereNotIn('id', $context['attached_by_type'][WorldRelation::TYPE_USER] ?? [])
+ ->get()
+ ->map(function (User $user) use ($context): ?array {
+ if (! $user->username) {
+ return null;
+ }
+
+ $score = 0;
+ $reasons = [];
+ $signals = [
+ 'challenge_linked' => false,
+ 'community_submission' => false,
+ 'recurring_history_informed' => false,
+ 'analytics_informed' => false,
+ 'not_yet_featured' => true,
+ ];
+
+ if (in_array((int) $user->id, $context['community_creator_ids'], true)) {
+ $score += 22;
+ $reasons[] = $this->reason('Creator already active in this world', 'emerald');
+ $signals['community_submission'] = true;
+ }
+
+ if (in_array((int) $user->id, $context['challenge_creator_ids'], true)) {
+ $score += 14;
+ $reasons[] = $this->reason('Participating in the linked challenge', 'sky');
+ $signals['challenge_linked'] = true;
+ }
+
+ if (in_array((int) $user->id, $context['family_creator_ids'], true)) {
+ $score += 14;
+ $reasons[] = $this->reason('Strong in this world family', 'sky');
+ $signals['recurring_history_informed'] = true;
+ }
+
+ $followers = (int) ($user->statistics?->followers_count ?? 0);
+ if ($followers > 0) {
+ $score += min(12, (int) floor(log10(max(1, $followers)) * 4));
+ if ($followers >= 100) {
+ $reasons[] = $this->reason('Healthy follower momentum', 'rose');
+ }
+ }
+
+ if ((bool) $user->nova_featured_creator) {
+ $score += 6;
+ $reasons[] = $this->reason('Editorially featured creator', 'amber');
+ }
+
+ if ($score < 12) {
+ return null;
+ }
+
+ $preview = $this->worlds->previewUser($user, 'Creator suggestion');
+
+ if ($preview) {
+ $this->applyAnalyticsEntityBoost($context, WorldRelation::TYPE_USER, (int) $user->id, $score, $reasons, $signals);
+ $this->applyUnderperformingSectionBoost($context, $preview, $score, $reasons, $signals);
+ }
+
+ return $preview ? $this->finalizeItem($preview, 'creators', $score, $reasons, $signals, [
+ 'performance_value' => $followers,
+ ]) : null;
+ })
+ ->filter()
+ ->sortByDesc('score')
+ ->take(8)
+ ->values()
+ ->all();
+ }
+
+ private function collectionSuggestions(World $world, array $context, ?User $viewer): array
+ {
+ $candidateCreatorIds = collect($context['community_creator_ids'])
+ ->merge($context['family_creator_ids'])
+ ->merge($context['challenge_creator_ids'])
+ ->filter(fn ($id): bool => (int) $id > 0)
+ ->unique()
+ ->values()
+ ->all();
+
+ return Collection::query()
+ ->with(['user.profile', 'group', 'coverArtwork.tags'])
+ ->publicEligible()
+ ->whereNotIn('id', $context['attached_by_type'][WorldRelation::TYPE_COLLECTION] ?? [])
+ ->where(function (Builder $builder) use ($context, $candidateCreatorIds): void {
+ $this->applyTextFilters($builder, ['title', 'summary', 'description', 'subtitle', 'campaign_label', 'theme_token'], $context['keywords']);
+
+ if ($context['tag_slugs'] !== []) {
+ $builder->orWhereHas('coverArtwork.tags', fn (Builder $tagQuery): Builder => $tagQuery->whereIn('slug', $context['tag_slugs']));
+ }
+
+ if ($candidateCreatorIds !== []) {
+ $builder->orWhereIn('user_id', $candidateCreatorIds);
+ }
+
+ if ($context['family_collection_ids'] !== []) {
+ $builder->orWhereIn('id', $context['family_collection_ids']);
+ }
+ })
+ ->orderByDesc('featured_at')
+ ->orderByDesc('published_at')
+ ->limit(24)
+ ->get()
+ ->map(function (Collection $collection) use ($context, $candidateCreatorIds, $viewer): ?array {
+ $score = $this->collectionPerformanceScore($collection);
+ $reasons = [];
+ $signals = [
+ 'challenge_linked' => false,
+ 'community_submission' => false,
+ 'recurring_history_informed' => false,
+ 'analytics_informed' => false,
+ 'not_yet_featured' => true,
+ ];
+
+ if (in_array((int) $collection->user_id, $candidateCreatorIds, true)) {
+ $score += 12;
+ $reasons[] = $this->reason('Built by a relevant creator', 'emerald');
+ }
+
+ if (in_array((int) $collection->id, $context['family_collection_ids'], true)) {
+ $score += 12;
+ $reasons[] = $this->reason('Recurring-world editorial signal', 'sky');
+ $signals['recurring_history_informed'] = true;
+ }
+
+ $tagOverlap = $this->overlapCount($context['tag_slugs'], $collection->coverArtwork?->tags?->pluck('slug')->map(fn ($tag): string => Str::lower((string) $tag))->all() ?? []);
+ if ($tagOverlap > 0) {
+ $score += min(16, $tagOverlap * 8);
+ $reasons[] = $this->reason('Cover artwork matches world tags', 'sky');
+ }
+
+ if ((bool) $collection->is_featured) {
+ $score += 6;
+ $reasons[] = $this->reason('Already proven in editorial surfaces', 'amber');
+ }
+
+ if ($this->collectionPerformanceScore($collection) >= 12) {
+ $reasons[] = $this->reason('Strong collection engagement', 'rose');
+ }
+
+ if ($score < 12) {
+ return null;
+ }
+
+ $preview = $this->worlds->previewCollection($collection, $viewer, 'Collection suggestion');
+
+ if ($preview) {
+ $this->applyAnalyticsEntityBoost($context, WorldRelation::TYPE_COLLECTION, (int) $collection->id, $score, $reasons, $signals);
+ $this->applyUnderperformingSectionBoost($context, $preview, $score, $reasons, $signals);
+ }
+
+ return $preview ? $this->finalizeItem($preview, 'collections', $score, $reasons, $signals, [
+ 'performance_value' => $this->collectionPerformanceScore($collection),
+ 'freshness_timestamp' => $collection->published_at?->timestamp,
+ ]) : null;
+ })
+ ->filter()
+ ->sortByDesc('score')
+ ->take(8)
+ ->values()
+ ->all();
+ }
+
+ private function groupSuggestions(World $world, array $context, ?User $viewer): array
+ {
+ $candidateOwnerIds = collect($context['community_creator_ids'])
+ ->merge($context['challenge_creator_ids'])
+ ->merge($context['family_creator_ids'])
+ ->filter(fn ($id): bool => (int) $id > 0)
+ ->unique()
+ ->values()
+ ->all();
+
+ $priorityGroupIds = collect($context['family_group_ids'])
+ ->when(($context['challenge_group_id'] ?? 0) > 0, fn (SupportCollection $items): SupportCollection => $items->push((int) $context['challenge_group_id']))
+ ->filter(fn ($id): bool => (int) $id > 0)
+ ->unique()
+ ->values()
+ ->all();
+
+ return Group::query()
+ ->with('owner.profile')
+ ->where('visibility', Group::VISIBILITY_PUBLIC)
+ ->where('status', Group::LIFECYCLE_ACTIVE)
+ ->whereNotIn('id', $context['attached_by_type'][WorldRelation::TYPE_GROUP] ?? [])
+ ->where(function (Builder $builder) use ($context, $candidateOwnerIds, $priorityGroupIds): void {
+ $this->applyTextFilters($builder, ['name', 'headline', 'bio'], $context['keywords']);
+
+ if ($candidateOwnerIds !== []) {
+ $builder->orWhereIn('owner_user_id', $candidateOwnerIds);
+ }
+
+ if ($priorityGroupIds !== []) {
+ $builder->orWhereIn('id', $priorityGroupIds);
+ }
+ })
+ ->orderByDesc('followers_count')
+ ->orderByDesc('last_activity_at')
+ ->limit(24)
+ ->get()
+ ->map(function (Group $group) use ($context, $candidateOwnerIds, $priorityGroupIds, $viewer): ?array {
+ $score = 0;
+ $reasons = [];
+ $signals = [
+ 'challenge_linked' => false,
+ 'community_submission' => false,
+ 'recurring_history_informed' => false,
+ 'analytics_informed' => false,
+ 'not_yet_featured' => true,
+ ];
+
+ if (in_array((int) $group->id, $priorityGroupIds, true)) {
+ $score += ((int) $group->id === (int) ($context['challenge_group_id'] ?? 0)) ? 24 : 12;
+ $reasons[] = $this->reason((int) $group->id === (int) ($context['challenge_group_id'] ?? 0) ? 'Group behind the linked challenge' : 'Returning world-family group', 'sky');
+ $signals[(int) $group->id === (int) ($context['challenge_group_id'] ?? 0) ? 'challenge_linked' : 'recurring_history_informed'] = true;
+ }
+
+ if (in_array((int) $group->owner_user_id, $candidateOwnerIds, true)) {
+ $score += 10;
+ $reasons[] = $this->reason('Owned by a relevant creator', 'emerald');
+ }
+
+ if ((bool) $group->is_verified) {
+ $score += 5;
+ $reasons[] = $this->reason('Verified group', 'amber');
+ }
+
+ $engagement = min(14, (int) floor(log10(max(1, (int) $group->followers_count + (int) $group->artworks_count + (int) $group->collections_count + 1)) * 6));
+ $score += $engagement;
+ if ($engagement >= 8) {
+ $reasons[] = $this->reason('Healthy group momentum', 'rose');
+ }
+
+ if ($score < 12) {
+ return null;
+ }
+
+ $preview = $this->worlds->previewGroup($group, $viewer, 'Group suggestion');
+
+ if ($preview) {
+ $this->applyAnalyticsEntityBoost($context, WorldRelation::TYPE_GROUP, (int) $group->id, $score, $reasons, $signals);
+ $this->applyUnderperformingSectionBoost($context, $preview, $score, $reasons, $signals);
+ }
+
+ return $preview ? $this->finalizeItem($preview, 'groups', $score, $reasons, $signals, [
+ 'performance_value' => (int) $group->followers_count + (int) $group->artworks_count + (int) $group->collections_count,
+ ]) : null;
+ })
+ ->filter()
+ ->sortByDesc('score')
+ ->take(8)
+ ->values()
+ ->all();
+ }
+
+ private function newsSuggestions(World $world, array $context): array
+ {
+ return NewsArticle::query()
+ ->with(['author.profile', 'category'])
+ ->published()
+ ->whereNotIn('id', $context['attached_by_type'][WorldRelation::TYPE_NEWS] ?? [])
+ ->where(function (Builder $builder) use ($context): void {
+ $this->applyTextFilters($builder, ['title', 'excerpt', 'content'], $context['keywords']);
+ })
+ ->orderByDesc('published_at')
+ ->limit(24)
+ ->get()
+ ->map(function (NewsArticle $article) use ($world, $context): ?array {
+ $score = $this->freshnessScore($article->published_at, 10, 18, 8);
+ $reasons = [];
+ $signals = [
+ 'challenge_linked' => false,
+ 'community_submission' => false,
+ 'recurring_history_informed' => false,
+ 'analytics_informed' => false,
+ 'not_yet_featured' => true,
+ ];
+
+ $textHits = $this->textMatchCount(
+ $context['keywords'],
+ [(string) $article->title, (string) ($article->excerpt ?? ''), strip_tags((string) ($article->content ?? ''))],
+ );
+
+ if ($textHits > 0) {
+ $score += min(20, $textHits * 6);
+ $reasons[] = $this->reason('Story language lines up with this world', 'sky');
+ }
+
+ $headline = Str::lower((string) $article->title . ' ' . (string) ($article->excerpt ?? ''));
+ if ($world->isPubliclyVisible() && (Str::contains($headline, 'results') || Str::contains($headline, 'recap'))) {
+ $score += 8;
+ $reasons[] = $this->reason('Good fit for editorial follow-through', 'amber');
+ }
+
+ if ($score < 10) {
+ return null;
+ }
+
+ $preview = $this->worlds->previewNews($article, 'Related story suggestion');
+
+ if ($preview) {
+ $this->applyAnalyticsEntityBoost($context, WorldRelation::TYPE_NEWS, (int) $article->id, $score, $reasons, $signals);
+ $this->applyUnderperformingSectionBoost($context, $preview, $score, $reasons, $signals);
+ }
+
+ return $preview ? $this->finalizeItem($preview, 'news', $score, $reasons, $signals, [
+ 'performance_value' => $textHits,
+ 'freshness_timestamp' => $article->published_at?->timestamp,
+ ]) : null;
+ })
+ ->filter()
+ ->sortByDesc('score')
+ ->take(8)
+ ->values()
+ ->all();
+ }
+
+ private function buildArtworkSuggestionItem(Artwork $artwork, array $context, string $categoryKey, string $contextLabel): ?array
+ {
+ $score = 0;
+ $reasons = [];
+ $signals = [
+ 'challenge_linked' => false,
+ 'community_submission' => false,
+ 'recurring_history_informed' => false,
+ 'analytics_informed' => false,
+ 'not_yet_featured' => true,
+ ];
+
+ $tagOverlap = $this->overlapCount(
+ $context['tag_slugs'],
+ $artwork->tags->pluck('slug')->map(fn ($tag): string => Str::lower((string) $tag))->all(),
+ );
+
+ if ($tagOverlap > 0) {
+ $score += min(20, $tagOverlap * 10);
+ $reasons[] = $this->reason('Matches world tags', 'sky');
+ }
+
+ $textHits = $this->textMatchCount(
+ $context['keywords'],
+ [(string) $artwork->title, (string) ($artwork->description ?? ''), implode(' ', $artwork->tags->pluck('name')->all())],
+ );
+
+ if ($textHits > 0) {
+ $score += min(18, $textHits * 6);
+ $reasons[] = $this->reason('Theme language lines up with the brief', 'emerald');
+ }
+
+ if (in_array((int) $artwork->user_id, $context['family_creator_ids'], true)) {
+ $score += 10;
+ $reasons[] = $this->reason('Creator has prior world-family momentum', 'sky');
+ $signals['recurring_history_informed'] = true;
+ }
+
+ if (in_array((int) $artwork->user_id, $context['community_creator_ids'], true)) {
+ $score += 8;
+ $reasons[] = $this->reason('Creator is already active in this world', 'emerald');
+ $signals['community_submission'] = true;
+ }
+
+ $performance = $this->artworkPerformanceScore($artwork);
+ $score += $performance;
+ if ($performance >= 12) {
+ $reasons[] = $this->reason('Strong engagement on platform', 'rose');
+ }
+
+ $this->applyAnalyticsEntityBoost($context, WorldRelation::TYPE_ARTWORK, (int) $artwork->id, $score, $reasons, $signals);
+
+ $freshness = $this->freshnessScore($artwork->published_at, 14, 12, 6);
+ $score += $freshness;
+ if ($freshness >= 8) {
+ $reasons[] = $this->reason('Freshly published', 'amber');
+ }
+
+ if ($score < 12) {
+ return null;
+ }
+
+ $preview = $this->worlds->previewArtwork($artwork, $contextLabel);
+
+ if ($preview) {
+ $this->applyUnderperformingSectionBoost($context, $preview, $score, $reasons, $signals);
+ }
+
+ return $preview ? $this->finalizeItem($preview, $categoryKey, $score, $reasons, $signals, [
+ 'performance_value' => $performance,
+ 'freshness_timestamp' => $artwork->published_at?->timestamp,
+ ]) : null;
+ }
+
+ private function matchingArtworkCreatorIds(array $context): array
+ {
+ if ($context['keywords'] === [] && $context['tag_slugs'] === []) {
+ return [];
+ }
+
+ return Artwork::query()
+ ->with('tags')
+ ->catalogVisible()
+ ->where(function (Builder $builder) use ($context): void {
+ $this->applyArtworkThemeFilters($builder, $context['keywords'], $context['tag_slugs']);
+ })
+ ->orderByDesc('published_at')
+ ->limit(20)
+ ->get(['id', 'user_id'])
+ ->pluck('user_id')
+ ->map(fn ($id): int => (int) $id)
+ ->filter(fn (int $id): bool => $id > 0)
+ ->unique()
+ ->values()
+ ->all();
+ }
+
+ private function stateBackedItem(WorldEditorialSuggestionState $state, SupportCollection $candidateMap, ?User $viewer): ?array
+ {
+ $candidate = $candidateMap->get($this->itemKey((string) $state->related_type, (int) $state->related_id));
+
+ if (is_array($candidate)) {
+ return array_merge($candidate, [
+ 'state' => [
+ 'status' => (string) $state->status,
+ 'section_key' => $state->section_key,
+ 'label' => $this->stateLabel((string) $state->status),
+ ],
+ ]);
+ }
+
+ $preview = $this->worlds->resolveEntityPreview((string) $state->related_type, (int) $state->related_id, $viewer, (string) ($state->status === WorldEditorialSuggestionState::STATUS_PINNED ? 'Pinned for later' : 'Suppressed suggestion'));
+
+ if (! $preview) {
+ return null;
+ }
+
+ return array_merge($this->finalizeItem(
+ $preview,
+ $state->status === WorldEditorialSuggestionState::STATUS_PINNED ? 'saved' : 'suppressed',
+ 50,
+ [$this->reason($state->status === WorldEditorialSuggestionState::STATUS_PINNED ? 'Saved by an editor' : 'Suppressed for this edition', 'slate')],
+ ), [
+ 'state' => [
+ 'status' => (string) $state->status,
+ 'section_key' => $state->section_key,
+ 'label' => $this->stateLabel((string) $state->status),
+ ],
+ ]);
+ }
+
+ private function finalizeItem(array $preview, string $categoryKey, int $score, array $reasons, array $signals = [], array $ranking = []): array
+ {
+ $sectionTargets = $this->sectionTargetsForType((string) ($preview['entity_type'] ?? ''));
+ $defaultSection = $sectionTargets[0] ?? null;
+ $normalizedScore = max(0, min(99, $score));
+
+ return array_merge($preview, [
+ 'key' => $this->itemKey((string) ($preview['entity_type'] ?? ''), (int) ($preview['id'] ?? 0)),
+ 'entity_id' => (int) ($preview['id'] ?? 0),
+ 'category_key' => $categoryKey,
+ 'category_label' => $this->groupDefinition($categoryKey)['label'] ?? Str::headline($categoryKey),
+ 'score' => $normalizedScore,
+ 'score_label' => $this->scoreLabel($score),
+ 'reasons' => collect($reasons)->filter()->unique('label')->values()->take(4)->all(),
+ 'section_targets' => $sectionTargets,
+ 'default_section_key' => $defaultSection['value'] ?? null,
+ 'default_section_label' => $defaultSection['label'] ?? null,
+ 'signals' => array_merge([
+ 'challenge_linked' => false,
+ 'community_submission' => false,
+ 'recurring_history_informed' => false,
+ 'analytics_informed' => false,
+ 'not_yet_featured' => true,
+ ], $signals),
+ 'ranking' => [
+ 'score' => $normalizedScore,
+ 'performance_value' => (int) ($ranking['performance_value'] ?? $normalizedScore),
+ 'freshness_timestamp' => isset($ranking['freshness_timestamp']) ? (int) $ranking['freshness_timestamp'] : null,
+ ],
+ 'state' => [
+ 'status' => 'available',
+ 'section_key' => null,
+ 'label' => 'Available',
+ ],
+ ]);
+ }
+
+ private function relationPayload(WorldRelation $relation, ?User $viewer = null): array
+ {
+ return [
+ '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->worlds->resolveEntityPreview((string) $relation->related_type, (int) $relation->related_id, $viewer, (string) ($relation->context_label ?? '')),
+ ];
+ }
+
+ private function assertSectionCompatibility(string $relatedType, string $sectionKey): void
+ {
+ $valid = collect((array) config('worlds.sections', []))
+ ->filter(fn (array $section): bool => in_array($relatedType, (array) ($section['relation_types'] ?? []), true))
+ ->keys()
+ ->all();
+
+ if (! in_array($sectionKey, $valid, true)) {
+ abort(422, 'That suggestion cannot be attached to the requested section.');
+ }
+ }
+
+ private function applyArtworkThemeFilters(Builder $builder, array $keywords, array $tagSlugs): void
+ {
+ if ($tagSlugs !== []) {
+ $builder->whereHas('tags', fn (Builder $tagQuery): Builder => $tagQuery->whereIn('slug', $tagSlugs));
+ }
+
+ foreach ($keywords as $keyword) {
+ $builder->orWhere('artworks.title', 'like', '%' . $keyword . '%')
+ ->orWhere('artworks.description', 'like', '%' . $keyword . '%');
+ }
+ }
+
+ private function applyTextFilters(Builder $builder, array $columns, array $keywords): void
+ {
+ foreach ($keywords as $keyword) {
+ foreach ($columns as $column) {
+ $builder->orWhere($column, 'like', '%' . $keyword . '%');
+ }
+ }
+ }
+
+ private function sectionTargetsForType(string $relatedType): array
+ {
+ return collect((array) config('worlds.sections', []))
+ ->filter(fn (array $section): bool => in_array($relatedType, (array) ($section['relation_types'] ?? []), true))
+ ->map(fn (array $section, string $key): array => [
+ 'value' => $key,
+ 'label' => (string) ($section['label'] ?? Str::headline($key)),
+ ])
+ ->values()
+ ->all();
+ }
+
+ private function sectionFilterOptions(): array
+ {
+ return collect((array) config('worlds.sections', []))
+ ->map(fn (array $section, string $key): array => [
+ 'value' => $key,
+ 'label' => (string) ($section['label'] ?? Str::headline($key)),
+ ])
+ ->values()
+ ->all();
+ }
+
+ private function sortFilterOptions(): array
+ {
+ return [
+ ['value' => 'relevance', 'label' => 'Best fit'],
+ ['value' => 'newest', 'label' => 'Newest'],
+ ['value' => 'performance', 'label' => 'Highest performing'],
+ ];
+ }
+
+ private function typeFilterOptions(): array
+ {
+ return collect((array) config('worlds.relation_types', []))
+ ->map(fn (string $label, string $value): array => [
+ 'value' => $value,
+ 'label' => $label,
+ ])
+ ->values()
+ ->all();
+ }
+
+ private function groupDefinition(string $groupKey): array
+ {
+ return match ($groupKey) {
+ 'challenge' => [
+ 'label' => 'Challenge highlights',
+ 'description' => 'Winners, finalists, and standout entries pulled from the linked challenge.',
+ 'empty_label' => 'No challenge highlights are ready yet.',
+ ],
+ 'community' => [
+ 'label' => 'Community standouts',
+ 'description' => 'Strong creator submissions already live inside this world.',
+ 'empty_label' => 'No live community standouts are available yet.',
+ ],
+ 'artworks' => [
+ 'label' => 'Artwork candidates',
+ 'description' => 'Public artworks that match the world theme, freshness, and editorial quality signals.',
+ 'empty_label' => 'No extra artwork candidates rose above the current threshold.',
+ ],
+ 'creators' => [
+ 'label' => 'Creator candidates',
+ 'description' => 'Creators with relevant world, challenge, or recurring-family momentum.',
+ 'empty_label' => 'No creator suggestions are ready yet.',
+ ],
+ 'collections' => [
+ 'label' => 'Collection candidates',
+ 'description' => 'Collections that deepen the theme without requiring manual discovery sweeps.',
+ 'empty_label' => 'No collection suggestions are ready yet.',
+ ],
+ 'groups' => [
+ 'label' => 'Group candidates',
+ 'description' => 'Relevant scenes, crews, and collectives connected to this world or its challenge.',
+ 'empty_label' => 'No group suggestions are ready yet.',
+ ],
+ 'news' => [
+ 'label' => 'Related editorial content',
+ 'description' => 'Published stories and announcements that strengthen the world framing.',
+ 'empty_label' => 'No related stories are ready yet.',
+ ],
+ 'saved' => [
+ 'label' => 'Saved for later',
+ 'description' => 'Pinned items stay visible here until an editor acts on them.',
+ 'empty_label' => 'No saved suggestions yet.',
+ ],
+ default => [
+ 'label' => Str::headline($groupKey),
+ 'description' => '',
+ 'empty_label' => 'No suggestions are ready.',
+ ],
+ };
+ }
+
+ private function keywordTokens(string $value): array
+ {
+ return collect(preg_split('/[^a-z0-9]+/i', Str::lower($value)) ?: [])
+ ->map(fn ($token): string => trim((string) $token))
+ ->filter(fn (string $token): bool => strlen($token) >= 3 && ! in_array($token, self::STOP_WORDS, true))
+ ->unique()
+ ->take(12)
+ ->values()
+ ->all();
+ }
+
+ private function overlapCount(array $left, array $right): int
+ {
+ return count(array_intersect(array_map('strval', $left), array_map('strval', $right)));
+ }
+
+ private function underperformingSectionKeys(World $world, array $sectionClicks, int $viewCount): array
+ {
+ $visibleSections = collect($world->sectionOrder())
+ ->filter(fn (string $key): bool => ($world->sectionVisibility()[$key] ?? true) === true)
+ ->values();
+
+ if ($visibleSections->isEmpty()) {
+ return [];
+ }
+
+ $maxClicks = max([0, ...array_values($sectionClicks)]);
+
+ if ($maxClicks < 4 && $viewCount < 20) {
+ return [];
+ }
+
+ return $visibleSections
+ ->filter(function (string $sectionKey) use ($sectionClicks, $maxClicks): bool {
+ $clicks = (int) ($sectionClicks[$sectionKey] ?? 0);
+
+ if ($clicks === 0) {
+ return true;
+ }
+
+ if ($maxClicks >= 10 && $clicks <= (int) floor($maxClicks / 4)) {
+ return true;
+ }
+
+ return $maxClicks >= 6 && $clicks <= 2;
+ })
+ ->take(3)
+ ->all();
+ }
+
+ private function textMatchCount(array $keywords, array $haystacks): int
+ {
+ $haystack = Str::lower(implode(' ', array_filter(array_map(static fn ($value): string => trim((string) $value), $haystacks))));
+
+ return collect($keywords)
+ ->filter(fn (string $keyword): bool => $keyword !== '' && Str::contains($haystack, $keyword))
+ ->count();
+ }
+
+ private function applyAnalyticsEntityBoost(array $context, string $relatedType, int $relatedId, int &$score, array &$reasons, array &$signals, string $fallbackLabel = 'Already drawing clicks in this world', int $multiplier = 3, int $maxBoost = 16): void
+ {
+ $analytics = (array) ($context['analytics_entity_clicks'][$this->itemKey($relatedType, $relatedId)] ?? []);
+ $clicks = (int) ($analytics['clicks'] ?? 0);
+
+ if ($clicks < 1) {
+ return;
+ }
+
+ $score += min($maxBoost, $clicks * $multiplier);
+ $reasons[] = $this->reason($clicks >= 4 ? 'Top-clicked in this world' : $fallbackLabel, 'amber');
+ $signals['analytics_informed'] = true;
+ }
+
+ private function applyUnderperformingSectionBoost(array $context, array $preview, int &$score, array &$reasons, array &$signals): void
+ {
+ $sectionTarget = collect($this->sectionTargetsForType((string) ($preview['entity_type'] ?? '')))
+ ->first(fn (array $target): bool => in_array((string) ($target['value'] ?? ''), $context['underperforming_section_keys'] ?? [], true));
+
+ if (! is_array($sectionTarget)) {
+ return;
+ }
+
+ $score += 4;
+ $reasons[] = $this->reason('Can strengthen the quieter ' . Str::lower((string) ($sectionTarget['label'] ?? 'target')) . ' section', 'slate');
+ $signals['analytics_informed'] = true;
+ }
+
+ private function artworkPerformanceScore(Artwork $artwork): int
+ {
+ $views = (int) ($artwork->stats?->views ?? 0);
+ $likes = (int) ($artwork->stats?->favorites ?? 0);
+ $downloads = (int) ($artwork->stats?->downloads ?? 0);
+ $heatScore = (float) ($artwork->stats?->heat_score ?? 0);
+
+ return min(20,
+ (int) floor(log10(max(1, $views)) * 4)
+ + (int) floor(log10(max(1, $likes + 1)) * 6)
+ + (int) floor(log10(max(1, $downloads + 1)) * 4)
+ + ($heatScore >= 25 ? 4 : ($heatScore >= 8 ? 2 : 0))
+ );
+ }
+
+ private function collectionPerformanceScore(Collection $collection): int
+ {
+ $score = (int) floor(log10(max(1, (int) $collection->views_count + 1)) * 3)
+ + (int) floor(log10(max(1, (int) $collection->likes_count + 1)) * 5)
+ + (int) floor(log10(max(1, (int) $collection->saves_count + 1)) * 5)
+ + (int) floor(log10(max(1, (int) $collection->followers_count + 1)) * 4);
+
+ return min(18, $score);
+ }
+
+ private function freshnessScore(mixed $date, int $withinDays, int $freshPoints, int $stalePoints): int
+ {
+ if (! $date) {
+ return 0;
+ }
+
+ $days = now()->diffInDays($date);
+
+ if ($days <= $withinDays) {
+ return $freshPoints;
+ }
+
+ if ($days <= $withinDays * 3) {
+ return $stalePoints;
+ }
+
+ return 0;
+ }
+
+ private function reason(string $label, string $tone = 'default'): array
+ {
+ return [
+ 'label' => $label,
+ 'tone' => $tone,
+ ];
+ }
+
+ private function itemKey(string $relatedType, int $relatedId): string
+ {
+ return $relatedType . ':' . $relatedId;
+ }
+
+ private function isAlreadyAttached(array $item, array $context): bool
+ {
+ return in_array((int) ($item['entity_id'] ?? 0), $context['attached_by_type'][(string) ($item['entity_type'] ?? '')] ?? [], true);
+ }
+
+ private function scoreLabel(int $score): string
+ {
+ return match (true) {
+ $score >= 70 => 'Outstanding fit',
+ $score >= 48 => 'Strong fit',
+ $score >= 28 => 'Worth review',
+ default => 'Light signal',
+ };
+ }
+
+ private function stateLabel(string $status): string
+ {
+ return match ($status) {
+ WorldEditorialSuggestionState::STATUS_PINNED => 'Pinned',
+ WorldEditorialSuggestionState::STATUS_DISMISSED => 'Dismissed',
+ WorldEditorialSuggestionState::STATUS_NOT_RELEVANT => 'Not relevant',
+ default => 'Saved',
+ };
+ }
+
+ 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', 'tags', 'categories.contentType', 'stats'])
+ ->catalogVisible();
+
+ $this->maturity->applyViewerFilter($query, $viewer);
+
+ return $query;
+ }
+}
\ No newline at end of file
diff --git a/app/Services/Worlds/WorldRewardService.php b/app/Services/Worlds/WorldRewardService.php
new file mode 100644
index 00000000..7ccbfe48
--- /dev/null
+++ b/app/Services/Worlds/WorldRewardService.php
@@ -0,0 +1,569 @@
+loadMissing(['world', 'artwork.user.profile']);
+
+ $world = $submission->world;
+ $creator = $submission->artwork?->user;
+
+ if (! $world || ! $creator) {
+ return;
+ }
+
+ $this->syncAutomaticReward($world, $creator, WorldRewardType::Participant);
+ $this->syncAutomaticReward($world, $creator, WorldRewardType::Featured);
+ }
+
+ public function grantManualReward(WorldSubmission $submission, User $editor, WorldRewardType $rewardType, ?string $note = null): WorldRewardGrant
+ {
+ if ($rewardType->isAutomatic()) {
+ throw new \InvalidArgumentException('Automatic world rewards cannot be granted manually.');
+ }
+
+ if ((string) $submission->status !== WorldSubmission::STATUS_LIVE) {
+ throw ValidationException::withMessages([
+ 'submission' => 'Only live world submissions can receive manual rewards.',
+ ]);
+ }
+
+ $submission->loadMissing(['world', 'artwork.user.profile']);
+
+ $world = $submission->world;
+ $artwork = $submission->artwork;
+ $creator = $artwork?->user;
+
+ if (! $world || ! $artwork || ! $creator) {
+ throw new \RuntimeException('Submission is missing world, artwork, or creator context.');
+ }
+
+ $grant = WorldRewardGrant::query()->firstOrNew([
+ 'user_id' => (int) $creator->id,
+ 'world_id' => (int) $world->id,
+ 'reward_type' => $rewardType->value,
+ ]);
+
+ $wasRecentlyCreated = ! $grant->exists;
+
+ $grant->forceFill([
+ 'artwork_id' => (int) $artwork->id,
+ 'world_submission_id' => (int) $submission->id,
+ 'granted_by_user_id' => (int) $editor->id,
+ 'grant_source' => $rewardType->source(),
+ 'note' => $this->nullableText($note),
+ 'granted_at' => $grant->granted_at ?? now(),
+ ])->save();
+
+ $grant->loadMissing(['world', 'artwork', 'user.profile']);
+
+ if ($wasRecentlyCreated) {
+ $this->dispatchGrantSideEffects($grant);
+ }
+
+ return $grant;
+ }
+
+ public function revokeManualReward(WorldSubmission $submission, WorldRewardType $rewardType): void
+ {
+ if ($rewardType->isAutomatic()) {
+ throw new \InvalidArgumentException('Automatic world rewards are revoked through submission state changes.');
+ }
+
+ $submission->loadMissing(['world', 'artwork.user']);
+
+ $world = $submission->world;
+ $creator = $submission->artwork?->user;
+
+ if (! $world || ! $creator) {
+ return;
+ }
+
+ WorldRewardGrant::query()
+ ->where('user_id', (int) $creator->id)
+ ->where('world_id', (int) $world->id)
+ ->where('reward_type', $rewardType->value)
+ ->where('grant_source', $rewardType->source())
+ ->delete();
+
+ $this->activities->invalidateUserFeed((int) $creator->id);
+ }
+
+ public function summaryForUser(User $user, int $limit = 12): array
+ {
+ $recentGrants = WorldRewardGrant::query()
+ ->with(['world', 'artwork', 'user.profile'])
+ ->where('user_id', (int) $user->id)
+ ->orderByDesc('granted_at')
+ ->orderByDesc('id')
+ ->get();
+
+ $grants = $recentGrants->sortBy([
+ fn (WorldRewardGrant $grant): int => $this->sortPriority($grant->reward_type),
+ fn (WorldRewardGrant $grant): int => -1 * ($grant->granted_at?->getTimestamp() ?? 0),
+ fn (WorldRewardGrant $grant): int => -1 * (int) $grant->id,
+ ])->values();
+
+ return [
+ 'count' => $grants->count(),
+ 'counts' => [
+ 'participant' => $grants->where('reward_type', WorldRewardType::Participant)->count(),
+ 'featured' => $grants->where('reward_type', WorldRewardType::Featured)->count(),
+ 'finalist' => $grants->where('reward_type', WorldRewardType::Finalist)->count(),
+ 'winner' => $grants->where('reward_type', WorldRewardType::Winner)->count(),
+ 'spotlight' => $grants->where('reward_type', WorldRewardType::Spotlight)->count(),
+ ],
+ 'recent' => $recentGrants->take($limit)->map(fn (WorldRewardGrant $grant): array => $this->mapGrant($grant))->all(),
+ 'items' => $grants->map(fn (WorldRewardGrant $grant): array => $this->mapGrant($grant))->all(),
+ ];
+ }
+
+ public function rewardedContributorsForWorld(World $world, int $limit = 24): array
+ {
+ $baseQuery = WorldRewardGrant::query()
+ ->where('world_id', (int) $world->id);
+
+ $allGrants = (clone $baseQuery)
+ ->get(['user_id', 'reward_type']);
+
+ $grants = (clone $baseQuery)
+ ->with(['user.profile', 'artwork'])
+ ->orderByRaw($this->sortCaseSql())
+ ->orderByDesc('granted_at')
+ ->orderByDesc('id')
+ ->limit($limit)
+ ->get();
+
+ return [
+ 'count' => $allGrants->count(),
+ 'creator_count' => $allGrants->pluck('user_id')->filter()->unique()->count(),
+ 'counts' => [
+ 'participant' => $allGrants->where('reward_type', WorldRewardType::Participant->value)->count(),
+ 'featured' => $allGrants->where('reward_type', WorldRewardType::Featured->value)->count(),
+ 'finalist' => $allGrants->where('reward_type', WorldRewardType::Finalist->value)->count(),
+ 'winner' => $allGrants->where('reward_type', WorldRewardType::Winner->value)->count(),
+ 'spotlight' => $allGrants->where('reward_type', WorldRewardType::Spotlight->value)->count(),
+ ],
+ 'items' => $grants->map(fn (WorldRewardGrant $grant): array => $this->mapGrant($grant))->all(),
+ ];
+ }
+
+ public function syncLinkedChallengeRewardsForWorld(World $world): void
+ {
+ $world->loadMissing(['worldRelations', 'linkedChallenge.group', 'linkedChallenge.outcomes']);
+
+ if (! (bool) ($world->auto_grant_challenge_world_rewards ?? true)) {
+ $this->deleteChallengeOutcomeGrantsForWorld($world);
+
+ return;
+ }
+
+ $challengeIds = $world->worldRelations
+ ->where('related_type', WorldRelation::TYPE_CHALLENGE)
+ ->pluck('related_id')
+ ->map(fn ($id): int => (int) $id)
+ ->filter(fn (int $id): bool => $id > 0)
+ ->prepend((int) ($world->linked_challenge_id ?? 0))
+ ->unique()
+ ->values();
+
+ if ($challengeIds->isEmpty()) {
+ $this->deleteChallengeOutcomeGrantsForWorld($world);
+
+ return;
+ }
+
+ $challenges = GroupChallenge::query()
+ ->with(['group', 'featuredArtwork.user.profile', 'outcomes.artwork.user.profile'])
+ ->whereIn('id', $challengeIds->all())
+ ->get()
+ ->filter(fn (GroupChallenge $challenge): bool => $this->challengeCanGrantWorldOutcomeReward($challenge))
+ ->values();
+
+ $this->syncChallengeOutcomeRewardTypeForWorld($world, $challenges, WorldRewardType::Winner);
+ $this->syncChallengeOutcomeRewardTypeForWorld($world, $challenges, WorldRewardType::Finalist);
+ }
+
+ private function syncChallengeOutcomeRewardTypeForWorld(World $world, Collection $challenges, WorldRewardType $rewardType): void
+ {
+ $artworkIds = $challenges
+ ->flatMap(fn (GroupChallenge $challenge): array => $this->challengeOutcomeArtworkIds($challenge, $rewardType)->all())
+ ->unique()
+ ->values();
+
+ $submissionsByArtwork = $artworkIds->isEmpty()
+ ? collect()
+ : WorldSubmission::query()
+ ->with(['artwork.user.profile'])
+ ->where('world_id', (int) $world->id)
+ ->where('status', WorldSubmission::STATUS_LIVE)
+ ->whereIn('artwork_id', $artworkIds->all())
+ ->get()
+ ->keyBy(fn (WorldSubmission $submission): int => (int) $submission->artwork_id);
+
+ $expected = collect();
+
+ foreach ($challenges as $challenge) {
+ foreach ($this->challengeOutcomeArtworkIds($challenge, $rewardType) as $artworkId) {
+ $submission = $submissionsByArtwork->get((int) $artworkId);
+ $creator = $submission?->artwork?->user;
+
+ if (! $submission || ! $creator || $expected->has((int) $creator->id)) {
+ continue;
+ }
+
+ $expected->put((int) $creator->id, [
+ 'submission' => $submission,
+ 'challenge' => $challenge,
+ ]);
+ }
+ }
+
+ $existing = WorldRewardGrant::query()
+ ->where('world_id', (int) $world->id)
+ ->where('reward_type', $rewardType->value)
+ ->get()
+ ->keyBy(fn (WorldRewardGrant $grant): int => (int) $grant->user_id);
+
+ foreach ($expected as $userId => $payload) {
+ /** @var WorldSubmission $submission */
+ $submission = $payload['submission'];
+ /** @var GroupChallenge $challenge */
+ $challenge = $payload['challenge'];
+ $current = $existing->get((int) $userId);
+
+ if ($current && (string) $current->grant_source !== self::CHALLENGE_GRANT_SOURCE) {
+ continue;
+ }
+
+ $grant = $current ?? new WorldRewardGrant();
+ $wasRecentlyCreated = ! $grant->exists;
+
+ $grant->forceFill([
+ 'user_id' => (int) $userId,
+ 'world_id' => (int) $world->id,
+ 'artwork_id' => (int) $submission->artwork_id,
+ 'world_submission_id' => (int) $submission->id,
+ 'granted_by_user_id' => null,
+ 'reward_type' => $rewardType->value,
+ 'grant_source' => self::CHALLENGE_GRANT_SOURCE,
+ 'note' => sprintf('Synced from linked challenge %s: %s.', $rewardType->label(), $challenge->title),
+ 'granted_at' => $grant->granted_at ?? now(),
+ ])->save();
+
+ $grant->loadMissing(['world', 'artwork', 'user.profile']);
+
+ if ($wasRecentlyCreated) {
+ $this->dispatchGrantSideEffects($grant);
+ }
+ }
+
+ $expectedUserIds = $expected->keys()->map(fn ($id): int => (int) $id)->all();
+
+ $existing
+ ->filter(fn (WorldRewardGrant $grant): bool => (string) $grant->grant_source === self::CHALLENGE_GRANT_SOURCE)
+ ->reject(fn (WorldRewardGrant $grant): bool => in_array((int) $grant->user_id, $expectedUserIds, true))
+ ->each(function (WorldRewardGrant $grant): void {
+ $grant->delete();
+ $this->activities->invalidateUserFeed((int) $grant->user_id);
+ });
+ }
+
+ public function syncLinkedChallengeRewardsForChallenge(GroupChallenge $challenge): void
+ {
+ $worldIds = WorldRelation::query()
+ ->where('related_type', WorldRelation::TYPE_CHALLENGE)
+ ->where('related_id', (int) $challenge->id)
+ ->pluck('world_id')
+ ->map(fn ($id): int => (int) $id)
+ ->filter(fn (int $id): bool => $id > 0)
+ ->merge(
+ World::query()
+ ->where('linked_challenge_id', (int) $challenge->id)
+ ->pluck('id')
+ ->map(fn ($id): int => (int) $id)
+ )
+ ->unique()
+ ->all();
+
+ if ($worldIds === []) {
+ return;
+ }
+
+ World::query()
+ ->with('worldRelations')
+ ->whereIn('id', $worldIds)
+ ->get()
+ ->each(fn (World $world): bool => tap(true, fn () => $this->syncLinkedChallengeRewardsForWorld($world)));
+ }
+
+ public function creatorRewardMapForWorld(World $world): Collection
+ {
+ return WorldRewardGrant::query()
+ ->with(['artwork'])
+ ->where('world_id', (int) $world->id)
+ ->orderByRaw($this->sortCaseSql())
+ ->orderByDesc('granted_at')
+ ->get()
+ ->groupBy('user_id')
+ ->map(fn (Collection $items): array => $items->map(fn (WorldRewardGrant $grant): array => $this->mapGrant($grant, false))->all());
+ }
+
+ public function artworkRewardBadges(Artwork $artwork): array
+ {
+ return WorldRewardGrant::query()
+ ->with('world')
+ ->where('artwork_id', (int) $artwork->id)
+ ->orderByRaw($this->sortCaseSql())
+ ->orderByDesc('granted_at')
+ ->get()
+ ->map(function (WorldRewardGrant $grant): array {
+ $world = $grant->world;
+ $rewardType = $grant->reward_type;
+
+ return [
+ 'world_id' => (int) ($world?->id ?? 0),
+ 'world_title' => (string) ($world?->title ?? 'World'),
+ 'world_slug' => (string) ($world?->slug ?? ''),
+ 'world_url' => $world?->publicUrl(),
+ 'badge_label' => $this->worldRewardLabel($world, $rewardType),
+ 'status' => $rewardType->value,
+ 'status_label' => $rewardType->label(),
+ 'tone' => $rewardType->tone(),
+ 'sort_priority' => $this->sortPriority($rewardType),
+ ];
+ })
+ ->all();
+ }
+
+ private function syncAutomaticReward(World $world, User $creator, WorldRewardType $rewardType): void
+ {
+ $qualifyingSubmission = $this->qualifyingSubmission($world, $creator, $rewardType);
+
+ $existing = WorldRewardGrant::query()
+ ->where('user_id', (int) $creator->id)
+ ->where('world_id', (int) $world->id)
+ ->where('reward_type', $rewardType->value)
+ ->first();
+
+ if (! $qualifyingSubmission) {
+ if ($rewardType === WorldRewardType::Participant) {
+ return;
+ }
+
+ if ($existing && (string) $existing->grant_source === $rewardType->source()) {
+ $existing->delete();
+ $this->activities->invalidateUserFeed((int) $creator->id);
+ }
+
+ return;
+ }
+
+ if ($existing) {
+ $existing->forceFill([
+ 'artwork_id' => (int) $qualifyingSubmission->artwork_id,
+ 'world_submission_id' => (int) $qualifyingSubmission->id,
+ 'grant_source' => $rewardType->source(),
+ ])->save();
+
+ return;
+ }
+
+ $grant = WorldRewardGrant::query()->create([
+ 'user_id' => (int) $creator->id,
+ 'world_id' => (int) $world->id,
+ 'artwork_id' => (int) $qualifyingSubmission->artwork_id,
+ 'world_submission_id' => (int) $qualifyingSubmission->id,
+ 'reward_type' => $rewardType->value,
+ 'grant_source' => $rewardType->source(),
+ 'granted_at' => now(),
+ ]);
+
+ $grant->loadMissing(['world', 'artwork', 'user.profile']);
+
+ $this->dispatchGrantSideEffects($grant);
+ }
+
+ private function qualifyingSubmission(World $world, User $creator, WorldRewardType $rewardType): ?WorldSubmission
+ {
+ $query = WorldSubmission::query()
+ ->with(['world', 'artwork.user.profile'])
+ ->where('world_id', (int) $world->id)
+ ->where('status', WorldSubmission::STATUS_LIVE)
+ ->whereHas('artwork', fn (Builder $builder) => $builder->where('user_id', (int) $creator->id));
+
+ if ($rewardType === WorldRewardType::Featured) {
+ $query->where('is_featured', true)->orderByDesc('featured_at');
+ } else {
+ $query->orderByDesc('reviewed_at');
+ }
+
+ return $query->orderByDesc('id')->first();
+ }
+
+ private function dispatchGrantSideEffects(WorldRewardGrant $grant): void
+ {
+ $this->analytics->recordRewardGrant($grant);
+ $grant->user?->notify(new WorldRewardGrantedNotification($grant));
+ $this->activities->logWorldReward((int) $grant->user_id, (int) $grant->id, [
+ 'reward_type' => $grant->reward_type->value,
+ 'world_id' => (int) $grant->world_id,
+ ]);
+ $this->xp->awardWorldReward((int) $grant->user_id, $grant->reward_type, (int) $grant->world_id);
+ }
+
+ private function mapGrant(WorldRewardGrant $grant, bool $includeCreator = true): array
+ {
+ $grant->loadMissing(['world', 'artwork', 'user.profile']);
+
+ $payload = [
+ 'id' => (int) $grant->id,
+ 'reward_type' => $grant->reward_type->value,
+ 'reward_label' => $grant->reward_type->label(),
+ 'badge_label' => $this->worldRewardLabel($grant->world, $grant->reward_type),
+ 'tone' => $grant->reward_type->tone(),
+ 'grant_source' => (string) $grant->grant_source,
+ 'note' => (string) ($grant->note ?? ''),
+ 'granted_at' => $grant->granted_at?->toIso8601String(),
+ 'world' => $grant->world ? [
+ 'id' => (int) $grant->world->id,
+ 'title' => (string) $grant->world->title,
+ 'slug' => (string) $grant->world->slug,
+ 'url' => $grant->world->publicUrl(),
+ 'edition_year' => $grant->world->edition_year,
+ ] : null,
+ 'artwork' => $grant->artwork ? [
+ 'id' => (int) $grant->artwork->id,
+ 'title' => (string) ($grant->artwork->title ?: 'Untitled artwork'),
+ 'url' => route('art.show', ['id' => (int) $grant->artwork->id, 'slug' => $grant->artwork->slug ?: Str::slug((string) $grant->artwork->title)]),
+ ] : null,
+ ];
+
+ if (! $includeCreator) {
+ return $payload;
+ }
+
+ return [
+ ...$payload,
+ 'creator' => $grant->user ? [
+ 'id' => (int) $grant->user->id,
+ 'name' => (string) ($grant->user->name ?: $grant->user->username ?: 'Creator'),
+ 'username' => (string) ($grant->user->username ?? ''),
+ 'profile_url' => $grant->user->username ? route('profile.show', ['username' => strtolower((string) $grant->user->username)]) : null,
+ 'avatar_url' => AvatarUrl::forUser((int) $grant->user->id, $grant->user->profile?->avatar_hash, 96),
+ ] : null,
+ ];
+ }
+
+ private function worldRewardLabel(?World $world, WorldRewardType $rewardType): string
+ {
+ return trim(($world?->title ?? 'World') . ' ' . $rewardType->label());
+ }
+
+ private function sortCaseSql(): string
+ {
+ return "CASE reward_type WHEN 'winner' THEN 0 WHEN 'finalist' THEN 1 WHEN 'spotlight' THEN 2 WHEN 'featured' THEN 3 ELSE 4 END";
+ }
+
+ private function challengeCanGrantWorldOutcomeReward(GroupChallenge $challenge): bool
+ {
+ if ((string) $challenge->status === GroupChallenge::STATUS_DRAFT) {
+ return false;
+ }
+
+ return $challenge->canBeViewedBy(null);
+ }
+
+ private function challengeOutcomeArtworkIds(GroupChallenge $challenge, WorldRewardType $rewardType): Collection
+ {
+ $challenge->loadMissing('outcomes');
+
+ $type = match ($rewardType) {
+ WorldRewardType::Winner => GroupChallengeOutcome::TYPE_WINNER,
+ WorldRewardType::Finalist => GroupChallengeOutcome::TYPE_FINALIST,
+ default => null,
+ };
+
+ if ($type === null) {
+ return collect();
+ }
+
+ $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 ($rewardType === WorldRewardType::Winner && $ids->isEmpty() && (int) ($challenge->featured_artwork_id ?? 0) > 0) {
+ return collect([(int) $challenge->featured_artwork_id]);
+ }
+
+ return $ids;
+ }
+
+ private function deleteChallengeOutcomeGrantsForWorld(World $world, ?WorldRewardType $rewardType = null): void
+ {
+ $query = WorldRewardGrant::query()
+ ->where('world_id', (int) $world->id)
+ ->where('grant_source', self::CHALLENGE_GRANT_SOURCE)
+ ->when($rewardType !== null, fn ($builder) => $builder->where('reward_type', $rewardType->value));
+
+ $query
+ ->get()
+ ->each(function (WorldRewardGrant $grant): void {
+ $grant->delete();
+ $this->activities->invalidateUserFeed((int) $grant->user_id);
+ });
+ }
+
+ private function sortPriority(WorldRewardType $rewardType): int
+ {
+ return match ($rewardType) {
+ WorldRewardType::Winner => 0,
+ WorldRewardType::Finalist => 1,
+ WorldRewardType::Spotlight => 2,
+ WorldRewardType::Featured => 3,
+ WorldRewardType::Participant => 4,
+ };
+ }
+
+ private function nullableText(?string $value): ?string
+ {
+ $trimmed = trim((string) $value);
+
+ return $trimmed !== '' ? $trimmed : null;
+ }
+}
\ No newline at end of file
diff --git a/app/Services/Worlds/WorldService.php b/app/Services/Worlds/WorldService.php
index 39bfaba2..28903ac3 100644
--- a/app/Services/Worlds/WorldService.php
+++ b/app/Services/Worlds/WorldService.php
@@ -9,30 +9,47 @@ 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,
) {
}
@@ -77,7 +94,18 @@ final class WorldService
public function studioListing(array $filters = []): array
{
- $query = World::query()->withCount('worldRelations')->orderByDesc('is_featured')->orderByRaw("CASE WHEN status = 'published' THEN 0 WHEN status = 'draft' THEN 1 ELSE 2 END")->orderByDesc('starts_at')->orderByDesc('published_at');
+ $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'] ?? ''));
@@ -117,7 +145,12 @@ final class WorldService
public function mapStudioWorld(World $world, ?User $viewer = null): array
{
- $world->loadMissing(['createdBy.profile', 'parentWorld', 'worldRelations']);
+ $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,
@@ -125,9 +158,13 @@ final class WorldService
'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 ?? ''),
@@ -138,6 +175,8 @@ final class WorldService
'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(),
@@ -146,13 +185,26 @@ final class WorldService
'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 ?? ''),
@@ -161,15 +213,40 @@ final class WorldService
'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 => [
@@ -189,11 +266,14 @@ final class WorldService
'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]),
@@ -215,15 +295,33 @@ final class WorldService
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,
@@ -233,47 +331,90 @@ final class WorldService
'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' => $source->submission_starts_at,
- 'submission_ends_at' => $source->submission_ends_at,
+ '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_recurring' => $asNewEdition ? true : $source->is_recurring,
- 'recurrence_key' => $asNewEdition ? ($source->recurrence_key ?: Str::slug((string) $source->slug)) : $source->recurrence_key,
- 'recurrence_rule' => $source->recurrence_rule,
- 'edition_year' => $asNewEdition ? $this->nextEditionYear($source) : $source->edition_year,
+ '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' => $source->cta_url,
+ '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' => $source->badge_url,
+ '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' => $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(),
+ '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);
@@ -286,7 +427,26 @@ final class WorldService
'published_at' => $world->published_at ?? now(),
])->save();
- return $world->fresh(['createdBy.profile', 'parentWorld', 'worldRelations']);
+ 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
@@ -294,9 +454,11 @@ final class WorldService
$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']);
+ return $world->fresh(['createdBy.profile', 'parentWorld', 'worldRelations', 'linkedChallenge.group']);
}
public function searchEntities(string $type, string $query, ?User $viewer = null): array
@@ -320,40 +482,168 @@ final class WorldService
public function publicIndexPayload(?User $viewer = null): array
{
- $featuredWorld = World::query()->current()->where('is_featured', true)->latest('starts_at')->first()
- ?? World::query()->published()->where('is_featured', true)->latest('published_at')->first();
+ $spotlight = $this->primarySpotlightWorld();
- $current = World::query()->current()->orderByDesc('is_featured')->orderBy('starts_at')->limit(12)->get();
- $upcoming = World::query()->upcoming()->orderBy('starts_at')->limit(12)->get();
- $archive = World::query()->archive()->orderByDesc('ends_at')->orderByDesc('published_at')->limit(18)->get();
+ $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.',
- 'featuredWorld' => $featuredWorld ? $this->mapWorldCard($featuredWorld, 'featured') : null,
+ '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 publicShowPayload(World $world, ?User $viewer = null): array
+ public function resolvePublicWorld(string $slug): array
{
- $world->loadMissing(['createdBy.profile', 'parentWorld', 'archiveEditions', 'worldRelations']);
+ $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);
- $archiveEditions = $world->archiveEditions
- ->filter(fn (World $edition): bool => $edition->isPubliclyVisible())
- ->sortByDesc(fn (World $edition): int => (int) ($edition->edition_year ?? 0))
+ $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()
- ->when($world->recurrence_key, fn (Builder $builder) => $builder->where('recurrence_key', $world->recurrence_key))
->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()
@@ -362,45 +652,384 @@ final class WorldService
return [
'world' => $this->mapWorldDetail($world),
+ 'recap' => $recap,
'sections' => $sections,
- 'communitySubmissions' => $this->submissions->publicSectionPayload($world, $viewer),
+ '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 = World::query()->current()->where('is_featured', true)->latest('starts_at')->first()
- ?? World::query()->current()->latest('starts_at')->first()
- ?? World::query()->upcoming()->where('is_featured', true)->orderBy('starts_at')->first();
+ $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 [
- 'id' => $payload['id'],
- 'title' => $payload['title'],
- 'tagline' => $payload['tagline'],
- 'summary' => $payload['summary'],
- 'cover_url' => $payload['cover_url'],
- 'icon_name' => $payload['icon_name'],
- 'badge_label' => $payload['badge_label'],
- 'timeframe_label' => $payload['timeframe_label'],
- 'theme' => $payload['theme'],
- 'cta_label' => $payload['cta_label'] ?: 'Explore world',
- 'cta_url' => $payload['public_url'],
- 'public_url' => $payload['public_url'],
+ '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' => $world->publicUrl(),
+ ];
+ });
+ }
+
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);
@@ -411,8 +1040,11 @@ final class WorldService
'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),
@@ -422,6 +1054,8 @@ final class WorldService
'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,
@@ -430,6 +1064,9 @@ final class WorldService
'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),
@@ -437,19 +1074,50 @@ final class WorldService
'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;
}
@@ -461,11 +1129,14 @@ final class WorldService
$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']);
+ return $world->fresh(['createdBy.profile', 'parentWorld', 'worldRelations', 'linkedChallenge.group', 'recapArticle.author.profile', 'recapArticle.category']);
}
private function syncRelations(World $world, array $relations): void
@@ -694,28 +1365,58 @@ final class WorldService
return [
'id' => $detail['id'],
'title' => $detail['title'],
+ 'teaser_title' => $detail['teaser_title'],
'slug' => $detail['slug'],
'tagline' => $detail['tagline'],
- 'summary' => $detail['summary'],
- 'cover_url' => $detail['cover_url'],
+ '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,
@@ -723,48 +1424,857 @@ final class WorldService
'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' => (string) ($world->cta_label ?? ''),
- 'cta_url' => (string) ($world->cta_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,
- 'public_url' => $world->publicUrl(),
+ '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' => $world->publicUrl(),
+ '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 [
@@ -804,6 +2314,25 @@ final class WorldService
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', []));
@@ -835,7 +2364,7 @@ final class WorldService
private function themePayload(World $world): array
{
- $preset = (array) config('worlds.themes.' . $world->theme_key, []);
+ $preset = $this->resolvedThemePreset($world);
return [
'key' => $world->theme_key,
@@ -849,19 +2378,45 @@ final class WorldService
private function resolvedIconName(World $world, ?array $theme = null): string
{
- $theme ??= (array) config('worlds.themes.' . $world->theme_key, []);
+ $theme ??= $this->resolvedThemePreset($world);
- $icon = trim((string) ($world->icon_name ?? ''));
- if ($icon !== '') {
+ $icon = $this->supportedIconName($world->icon_name ?? null);
+ if ($icon !== null) {
return $icon;
}
- $themeIcon = trim((string) ($theme['icon_name'] ?? ''));
- if ($themeIcon !== '') {
+ $themeIcon = $this->supportedIconName($theme['icon_name'] ?? null);
+ if ($themeIcon !== null) {
return $themeIcon;
}
- return 'fa-solid fa-sparkles';
+ 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
@@ -881,6 +2436,287 @@ final class WorldService
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 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);
@@ -896,33 +2732,54 @@ final class WorldService
private function nextEditionTitle(World $world): string
{
$nextYear = $this->nextEditionYear($world);
- $title = trim((string) $world->title);
+ $title = $this->stripTrailingEditionYear(trim((string) $world->title));
if ($title === '') {
return 'World ' . $nextYear;
}
- if ($world->edition_year && str_contains($title, (string) $world->edition_year)) {
- return str_replace((string) $world->edition_year, (string) $nextYear, $title);
- }
-
return $title . ' ' . $nextYear;
}
private function nextEditionSlug(World $world): string
{
$nextYear = $this->nextEditionYear($world);
- $slug = trim((string) $world->slug);
+ $slug = $this->inferredRecurrenceKey($world);
if ($slug === '') {
return 'world-' . $nextYear;
}
- if ($world->edition_year && str_contains($slug, (string) $world->edition_year)) {
- return str_replace((string) $world->edition_year, (string) $nextYear, $slug);
+ return $slug . '-' . $nextYear;
+ }
+
+ private function inferredRecurrenceKey(World $world): string
+ {
+ $recurrenceKey = trim((string) ($world->recurrence_key ?? ''));
+
+ if ($recurrenceKey !== '') {
+ return $recurrenceKey;
}
- return $slug . '-' . $nextYear;
+ $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
@@ -1129,14 +2986,13 @@ final class WorldService
};
}
- private function resolveArtworkPreview(int $entityId, string $contextLabel): ?array
+ public function previewArtwork(Artwork $artwork, string $contextLabel = ''): ?array
{
- $artwork = Artwork::query()->with(['user.profile', 'categories.contentType', 'stats'])->find($entityId);
-
- if (! $artwork || (string) $artwork->artwork_status !== 'published' || (string) $artwork->visibility !== Artwork::VISIBILITY_PUBLIC) {
+ 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);
@@ -1158,11 +3014,11 @@ final class WorldService
];
}
- private function resolveCollectionPreview(int $entityId, ?User $viewer, string $contextLabel): ?array
+ public function previewCollection(Collection $collection, ?User $viewer = null, string $contextLabel = ''): ?array
{
- $collection = Collection::query()->with(['user.profile', 'group', 'coverArtwork'])->find($entityId);
+ $collection->loadMissing(['user.profile', 'group', 'coverArtwork']);
- if (! $collection || ! $collection->canBeViewedBy($viewer) || ! $collection->user?->username) {
+ if (! $collection->canBeViewedBy($viewer) || ! $collection->user?->username) {
return null;
}
@@ -1179,11 +3035,11 @@ final class WorldService
]);
}
- private function resolveUserPreview(int $entityId, string $contextLabel): ?array
+ public function previewUser(User $user, string $contextLabel = ''): ?array
{
- $user = User::query()->with(['profile', 'statistics'])->find($entityId);
+ $user->loadMissing(['profile', 'statistics']);
- if (! $user || ! $user->username) {
+ if (! $user->username) {
return null;
}
@@ -1205,29 +3061,36 @@ final class WorldService
];
}
- private function resolveGroupPreview(int $entityId, ?User $viewer, string $contextLabel): ?array
+ public function previewGroup(Group $group, ?User $viewer = null, string $contextLabel = ''): ?array
{
- $group = Group::query()->with(['owner.profile', 'recruitmentProfile', 'discoveryMetric', 'members', 'badges'])->find($entityId);
+ $group->loadMissing(['owner.profile', 'recruitmentProfile', 'discoveryMetric', 'members', 'badges']);
- if (! $group || ! $group->canBeViewedBy($viewer)) {
+ if (! $group->canBeViewedBy($viewer)) {
return null;
}
- return array_merge($this->groups->mapGroupCard($group, $viewer), [
+ $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',
]);
}
- private function resolveNewsPreview(int $entityId, string $contextLabel): ?array
+ public function previewNews(NewsArticle $article, string $contextLabel = ''): ?array
{
- $article = NewsArticle::query()->with(['author.profile', 'category'])->published()->find($entityId);
-
- if (! $article) {
- return null;
- }
-
return [
'id' => (int) $article->id,
'entity_type' => WorldRelation::TYPE_NEWS,
@@ -1246,14 +3109,38 @@ final class WorldService
];
}
- private function resolveChallengePreview(int $entityId, ?User $viewer, string $contextLabel): ?array
+ public function previewChallenge(GroupChallenge $challenge, ?User $viewer = null, string $contextLabel = ''): ?array
{
- $challenge = GroupChallenge::query()->with('group')->find($entityId);
+ $challenge->loadMissing(['group', 'outcomes']);
- if (! $challenge || ! $challenge->group || ! $challenge->canBeViewedBy($viewer)) {
+ 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,
@@ -1269,9 +3156,77 @@ final class WorldService
$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);
diff --git a/app/Services/Worlds/WorldSubmissionService.php b/app/Services/Worlds/WorldSubmissionService.php
index 84719f38..1089bbc8 100644
--- a/app/Services/Worlds/WorldSubmissionService.php
+++ b/app/Services/Worlds/WorldSubmissionService.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Services\Worlds;
+use App\Enums\WorldRewardType;
use App\Http\Resources\ArtworkListResource;
use App\Models\Artwork;
use App\Models\User;
@@ -17,14 +18,18 @@ use Illuminate\Validation\ValidationException;
final class WorldSubmissionService
{
- public function __construct(private readonly ArtworkMaturityService $maturity)
- {
+ private array $canonicalRecurringEligibilityIds = [];
+
+ public function __construct(
+ private readonly ArtworkMaturityService $maturity,
+ private readonly WorldRewardService $rewards,
+ private readonly WorldAnalyticsService $analytics,
+ ) {
}
public function eligibleWorldOptions(?User $viewer = null): array
{
- return $this->eligibleWorldsQuery()
- ->get()
+ return $this->eligibleWorlds()
->map(fn (World $world): array => $this->mapCreatorWorldOption($world, null, true))
->all();
}
@@ -37,7 +42,7 @@ final class WorldSubmissionService
->filter(fn (WorldSubmission $submission): bool => $submission->world !== null)
->keyBy(fn (WorldSubmission $submission): int => (int) $submission->world_id);
- $eligibleWorlds = $this->eligibleWorldsQuery()->get()->keyBy(fn (World $world): int => (int) $world->id);
+ $eligibleWorlds = $this->eligibleWorlds()->keyBy(fn (World $world): int => (int) $world->id);
$worlds = $eligibleWorlds;
$missingWorldIds = $existing->keys()
@@ -55,7 +60,9 @@ final class WorldSubmissionService
return $worlds
->sortBy([
fn (World $world): int => $existing->has((int) $world->id) ? 0 : 1,
- fn (World $world): int => $world->starts_at?->getTimestamp() ?? PHP_INT_MAX,
+ fn (World $world): int => $world->isActiveCampaign() ? 0 : ($world->isUpcomingCampaign() ? 1 : 2),
+ fn (World $world): int => -1 * (int) ($world->campaign_priority ?? 0),
+ fn (World $world): int => $world->effectivePromotionStartsAt()?->getTimestamp() ?? PHP_INT_MAX,
fn (World $world): string => Str::lower((string) $world->title),
])
->values()
@@ -81,6 +88,7 @@ final class WorldSubmissionService
return [
'world_id' => $worldId,
'note' => Str::limit(trim((string) ($entry['note'] ?? '')), 1000, ''),
+ 'source_surface' => trim((string) ($entry['source_surface'] ?? '')),
];
})
->filter()
@@ -143,6 +151,7 @@ final class WorldSubmissionService
}
if ($submission) {
+ $wasRemoved = (string) $submission->status === WorldSubmission::STATUS_REMOVED;
$payload = [
'mode_snapshot' => $world?->participation_mode,
'note' => $note,
@@ -164,10 +173,26 @@ final class WorldSubmissionService
$submission->forceFill($payload)->save();
+ if ($wasRemoved) {
+ $eventType = self::lifecycleEventForStatus($startingStatus);
+
+ if ($eventType !== null) {
+ $this->analytics->recordSubmissionLifecycle(
+ $submission,
+ $eventType,
+ $actor,
+ $entry['source_surface'] !== '' ? $entry['source_surface'] : null,
+ ['reactivated' => true]
+ );
+ }
+ }
+
+ $this->rewards->syncAutomaticRewardsForSubmission($submission);
+
continue;
}
- WorldSubmission::query()->create([
+ $submission = WorldSubmission::query()->create([
'world_id' => $worldId,
'artwork_id' => (int) $artwork->id,
'submitted_by_user_id' => (int) $actor->id,
@@ -177,6 +202,25 @@ final class WorldSubmissionService
'note' => $note,
'reviewed_at' => $reviewedAt,
]);
+
+ $this->analytics->recordSubmissionLifecycle(
+ $submission,
+ WorldAnalyticsService::EVENT_SUBMISSION_CREATED,
+ $actor,
+ $entry['source_surface'] !== '' ? $entry['source_surface'] : null
+ );
+
+ if ($startingStatus === WorldSubmission::STATUS_LIVE) {
+ $this->analytics->recordSubmissionLifecycle(
+ $submission,
+ WorldAnalyticsService::EVENT_SUBMISSION_APPROVED,
+ $actor,
+ $entry['source_surface'] !== '' ? $entry['source_surface'] : null,
+ ['auto_approved' => true]
+ );
+ }
+
+ $this->rewards->syncAutomaticRewardsForSubmission($submission);
}
$existing->each(function (WorldSubmission $submission, int $worldId) use ($selectedWorldIds): void {
@@ -220,6 +264,13 @@ final class WorldSubmissionService
$submission->forceFill($payload)->save();
+ $eventType = self::lifecycleEventForStatus($status);
+ if ($eventType !== null) {
+ $this->analytics->recordSubmissionLifecycle($submission, $eventType, $reviewer);
+ }
+
+ $this->rewards->syncAutomaticRewardsForSubmission($submission);
+
return $submission->fresh(['artwork.user.profile', 'artwork.stats', 'artwork.categories', 'submittedBy.profile', 'reviewer.profile']);
}
@@ -245,6 +296,12 @@ final class WorldSubmissionService
$submission->forceFill($payload)->save();
+ if ($featured) {
+ $this->analytics->recordSubmissionLifecycle($submission, WorldAnalyticsService::EVENT_SUBMISSION_FEATURED, $reviewer);
+ }
+
+ $this->rewards->syncAutomaticRewardsForSubmission($submission);
+
return $submission->fresh(['artwork.user.profile', 'artwork.stats', 'artwork.categories', 'submittedBy.profile', 'reviewer.profile']);
}
@@ -258,6 +315,8 @@ final class WorldSubmissionService
'worldSubmissions.reviewer.profile',
]);
+ $rewardMap = $this->rewards->creatorRewardMapForWorld($world);
+
$items = $world->worldSubmissions
->sortBy([
fn (WorldSubmission $submission): int => match ((string) $submission->status) {
@@ -279,7 +338,7 @@ final class WorldSubmissionService
'blocked' => $items->where('status', WorldSubmission::STATUS_BLOCKED)->count(),
'featured' => $items->where('is_featured', true)->count(),
],
- 'items' => $items->map(fn (WorldSubmission $submission): array => $this->mapStudioSubmission($submission))->all(),
+ 'items' => $items->map(fn (WorldSubmission $submission): array => $this->mapStudioSubmission($submission, $rewardMap))->all(),
];
}
@@ -334,14 +393,62 @@ final class WorldSubmissionService
$builder->whereNull('submission_ends_at')
->orWhere('submission_ends_at', '>=', now());
})
- ->orderBy('submission_ends_at')
- ->orderBy('starts_at')
+ ->orderByDesc('is_active_campaign')
+ ->orderByDesc('is_homepage_featured')
+ ->orderByRaw('COALESCE(campaign_priority, 0) DESC')
+ ->orderByRaw('COALESCE(promotion_ends_at, submission_ends_at, ends_at) ASC')
+ ->orderByRaw('COALESCE(promotion_starts_at, starts_at, submission_starts_at) ASC')
->orderBy('title');
}
+ private static function lifecycleEventForStatus(string $status): ?string
+ {
+ return match ($status) {
+ WorldSubmission::STATUS_LIVE => WorldAnalyticsService::EVENT_SUBMISSION_APPROVED,
+ WorldSubmission::STATUS_REMOVED => WorldAnalyticsService::EVENT_SUBMISSION_REMOVED,
+ WorldSubmission::STATUS_BLOCKED => WorldAnalyticsService::EVENT_SUBMISSION_BLOCKED,
+ default => null,
+ };
+ }
+
+ private function eligibleWorlds()
+ {
+ return $this->eligibleWorldsQuery()
+ ->get()
+ ->filter(fn (World $world): bool => $this->isCanonicalRecurringEdition($world))
+ ->values();
+ }
+
private function isEligibleWorld(World $world): bool
{
- return $world->isAcceptingSubmissions();
+ return $world->isAcceptingSubmissions() && $this->isCanonicalRecurringEdition($world);
+ }
+
+ private function isCanonicalRecurringEdition(World $world): bool
+ {
+ $recurrenceKey = trim((string) ($world->recurrence_key ?? ''));
+
+ if (! $world->is_recurring || $recurrenceKey === '') {
+ return true;
+ }
+
+ if (! array_key_exists($recurrenceKey, $this->canonicalRecurringEligibilityIds)) {
+ $canonical = $this->eligibleWorldsQuery()
+ ->where('recurrence_key', $recurrenceKey)
+ ->get()
+ ->sortBy([
+ fn (World $edition): int => $edition->isActiveCampaign() ? 0 : ($edition->isUpcomingCampaign() ? 1 : 2),
+ fn (World $edition): int => -1 * (int) ($edition->campaign_priority ?? 0),
+ fn (World $edition): int => -1 * (int) ($edition->edition_year ?? 0),
+ fn (World $edition): int => -1 * ($edition->starts_at?->getTimestamp() ?? $edition->published_at?->getTimestamp() ?? 0),
+ fn (World $edition): int => -1 * (int) $edition->id,
+ ])
+ ->first();
+
+ $this->canonicalRecurringEligibilityIds[$recurrenceKey] = $canonical?->id;
+ }
+
+ return (int) ($this->canonicalRecurringEligibilityIds[$recurrenceKey] ?? 0) === (int) $world->id;
}
private function mapCreatorWorldOption(World $world, ?WorldSubmission $submission, bool $eligible): array
@@ -374,17 +481,27 @@ final class WorldSubmissionService
return [
'id' => (int) $world->id,
'title' => (string) $world->title,
+ 'teaser_title' => $world->teaserTitle(),
'slug' => (string) $world->slug,
'tagline' => (string) ($world->tagline ?? ''),
'summary' => (string) ($world->summary ?? ''),
+ 'teaser_summary' => (string) ($world->teaserSummary() ?? ''),
'cover_url' => $world->coverUrl(),
+ 'teaser_image_url' => $world->teaserImageUrl(),
+ 'campaign_label' => (string) ($world->campaign_label ?? ''),
'timeframe_label' => $this->timeframeLabel($world),
+ 'promotion_window_label' => $this->promotionWindowLabel($world),
'submission_window_label' => $this->submissionWindowLabel($world),
'submission_guidelines' => (string) ($world->submission_guidelines ?? ''),
'participation_mode' => (string) ($world->participation_mode ?: World::PARTICIPATION_MODE_CLOSED),
'participation_mode_label' => $this->participationModeLabel((string) ($world->participation_mode ?: World::PARTICIPATION_MODE_CLOSED)),
'submission_note_enabled' => (bool) $world->submission_note_enabled,
'is_accepting_submissions' => $eligible,
+ 'is_active_campaign' => (bool) $world->is_active_campaign,
+ 'is_homepage_featured' => (bool) $world->is_homepage_featured,
+ 'campaign_priority' => $world->campaign_priority,
+ 'campaign_state_label' => $this->campaignStateLabel($world),
+ 'status_badges' => $this->campaignBadges($world),
'selected' => $selected,
'selection_locked' => $locked,
'selection_locked_reason' => $lockedReason,
@@ -399,15 +516,72 @@ final class WorldSubmissionService
];
}
- private function mapStudioSubmission(WorldSubmission $submission): array
+ private function campaignStateLabel(World $world): string
+ {
+ if ($world->isActiveCampaign()) {
+ return 'Live now';
+ }
+
+ if ($world->isUpcomingCampaign() || ($world->starts_at && $world->starts_at->isFuture())) {
+ return 'Upcoming';
+ }
+
+ if ((string) $world->status === World::STATUS_ARCHIVED || ($world->ends_at && $world->ends_at->isPast())) {
+ return 'Archived';
+ }
+
+ return 'Open';
+ }
+
+ private function campaignBadges(World $world): array
+ {
+ $badges = [];
+
+ if ($world->isActiveCampaign()) {
+ $badges[] = ['label' => 'Live now', 'tone' => 'emerald'];
+ } elseif ($world->isUpcomingCampaign() || ($world->starts_at && $world->starts_at->isFuture())) {
+ $badges[] = ['label' => 'Upcoming', 'tone' => 'sky'];
+ }
+
+ if ($world->isEndingSoon()) {
+ $badges[] = ['label' => 'Ending soon', 'tone' => 'amber'];
+ }
+
+ if ((bool) $world->is_homepage_featured || (bool) $world->is_featured) {
+ $badges[] = ['label' => 'Featured', 'tone' => 'rose'];
+ }
+
+ return $badges;
+ }
+
+ private function promotionWindowLabel(World $world): ?string
+ {
+ if (! $world->promotion_starts_at && ! $world->promotion_ends_at) {
+ return null;
+ }
+
+ if ($world->promotion_starts_at && $world->promotion_ends_at) {
+ return 'Promotion ' . $world->promotion_starts_at->format('d M Y') . ' - ' . $world->promotion_ends_at->format('d M Y');
+ }
+
+ if ($world->promotion_starts_at) {
+ return 'Promotion starts ' . $world->promotion_starts_at->format('d M Y');
+ }
+
+ return 'Promoted through ' . $world->promotion_ends_at?->format('d M Y');
+ }
+
+ private function mapStudioSubmission(WorldSubmission $submission, \Illuminate\Support\Collection $rewardMap): array
{
$artwork = $submission->artwork;
$views = (int) ($artwork?->stats?->views ?? 0);
+ $creatorId = (int) ($artwork?->user?->id ?? 0);
return [
'id' => (int) $submission->id,
'status' => (string) $submission->status,
'status_label' => $this->statusLabel((string) $submission->status, (bool) $submission->is_featured),
+ 'can_grant_manual_rewards' => (string) $submission->status === WorldSubmission::STATUS_LIVE,
'is_featured' => (bool) $submission->is_featured,
'note' => (string) ($submission->note ?? ''),
'reviewer_note' => (string) ($submission->moderation_reason ?: $submission->reviewer_note ?? ''),
@@ -439,6 +613,7 @@ final class WorldSubmissionService
$artwork->visibility ? Str::headline((string) $artwork->visibility) : null,
])),
] : null,
+ 'world_rewards' => $creatorId > 0 ? ($rewardMap->get($creatorId) ?? []) : [],
'actions' => [
'approve' => route('studio.worlds.submissions.approve', ['world' => $submission->world_id, 'submission' => $submission->id]),
'remove' => route('studio.worlds.submissions.remove', ['world' => $submission->world_id, 'submission' => $submission->id]),
@@ -448,6 +623,16 @@ final class WorldSubmissionService
'feature' => route('studio.worlds.submissions.feature', ['world' => $submission->world_id, 'submission' => $submission->id]),
'unfeature' => route('studio.worlds.submissions.unfeature', ['world' => $submission->world_id, 'submission' => $submission->id]),
'pending' => route('studio.worlds.submissions.pending', ['world' => $submission->world_id, 'submission' => $submission->id]),
+ 'grant_rewards' => [
+ WorldRewardType::Winner->value => route('studio.worlds.submissions.rewards.grant', ['world' => $submission->world_id, 'submission' => $submission->id, 'rewardType' => WorldRewardType::Winner->value]),
+ WorldRewardType::Finalist->value => route('studio.worlds.submissions.rewards.grant', ['world' => $submission->world_id, 'submission' => $submission->id, 'rewardType' => WorldRewardType::Finalist->value]),
+ WorldRewardType::Spotlight->value => route('studio.worlds.submissions.rewards.grant', ['world' => $submission->world_id, 'submission' => $submission->id, 'rewardType' => WorldRewardType::Spotlight->value]),
+ ],
+ 'revoke_rewards' => [
+ WorldRewardType::Winner->value => route('studio.worlds.submissions.rewards.revoke', ['world' => $submission->world_id, 'submission' => $submission->id, 'rewardType' => WorldRewardType::Winner->value]),
+ WorldRewardType::Finalist->value => route('studio.worlds.submissions.rewards.revoke', ['world' => $submission->world_id, 'submission' => $submission->id, 'rewardType' => WorldRewardType::Finalist->value]),
+ WorldRewardType::Spotlight->value => route('studio.worlds.submissions.rewards.revoke', ['world' => $submission->world_id, 'submission' => $submission->id, 'rewardType' => WorldRewardType::Spotlight->value]),
+ ],
],
];
}
diff --git a/app/Services/XPService.php b/app/Services/XPService.php
index 4d2d579a..c14d3f54 100644
--- a/app/Services/XPService.php
+++ b/app/Services/XPService.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Services;
use App\Events\Achievements\UserXpUpdated;
+use App\Enums\WorldRewardType;
use App\Models\User;
use App\Models\UserXpLog;
use Illuminate\Support\Arr;
@@ -123,6 +124,11 @@ class XPService
return $this->awardUnique($userId, 5, 'comment_created:' . $scope, $referenceId);
}
+ public function awardWorldReward(int $userId, WorldRewardType $rewardType, int $worldId): bool
+ {
+ return $this->awardUnique($userId, $rewardType->xpReward(), 'world_reward:' . $rewardType->value, $worldId);
+ }
+
public function awardArtworkViewReceived(int $userId, int $artworkId, ?int $viewerId = null, ?string $ipAddress = null): bool
{
$viewerKey = $viewerId !== null && $viewerId > 0
diff --git a/config/groups.php b/config/groups.php
index 9cc149c3..df28b683 100644
--- a/config/groups.php
+++ b/config/groups.php
@@ -71,6 +71,7 @@ return [
'visibility_options' => ['public', 'unlisted', 'private'],
'participation_scopes' => ['group_only', 'invite_only', 'public'],
'judging_modes' => ['curated', 'community_vote', 'staff_pick'],
+ 'outcome_types' => ['winner', 'finalist', 'runner_up', 'honorable_mention', 'featured'],
],
'events' => [
'types' => ['launch', 'challenge', 'livestream', 'meetup', 'milestone', 'showcase', 'internal_session', 'release_window'],
diff --git a/config/worlds.php b/config/worlds.php
index feb374c5..ccef10e9 100644
--- a/config/worlds.php
+++ b/config/worlds.php
@@ -3,6 +3,8 @@
declare(strict_types=1);
return [
+ 'campaign_ending_soon_days' => 5,
+
'default_section_order' => [
'featured_artworks',
'featured_collections',
@@ -106,6 +108,16 @@ return [
'suggested_badge_label' => 'Seasonal spotlight',
'suggested_cta_label' => 'Explore summer picks',
],
+ 'seasonal' => [
+ 'label' => 'Seasonal',
+ 'accent_color' => '#84cc16',
+ 'accent_color_secondary' => '#4d7c0f',
+ 'background_motif' => 'bloom',
+ 'icon_name' => 'fa-solid fa-seedling',
+ 'related_tags_json' => ['seasonal', 'bloom', 'spring'],
+ 'suggested_badge_label' => 'Seasonal spotlight',
+ 'suggested_cta_label' => 'Explore seasonal highlights',
+ ],
'retro-month' => [
'label' => 'Retro Month',
'accent_color' => '#fb7185',
diff --git a/database/factories/WorldFactory.php b/database/factories/WorldFactory.php
index 85492186..d7d6b69e 100644
--- a/database/factories/WorldFactory.php
+++ b/database/factories/WorldFactory.php
@@ -28,7 +28,10 @@ class WorldFactory extends Factory
'slug' => Str::slug($title),
'tagline' => $this->faker->sentence(6),
'summary' => $this->faker->sentence(12),
+ 'teaser_title' => null,
+ 'teaser_summary' => null,
'description' => $this->faker->paragraphs(2, true),
+ 'teaser_image_path' => null,
'theme_key' => 'summer',
'accent_color' => '#22c55e',
'accent_color_secondary' => '#0f172a',
@@ -38,7 +41,12 @@ class WorldFactory extends Factory
'type' => World::TYPE_SEASONAL,
'starts_at' => $startsAt,
'ends_at' => $endsAt,
+ 'promotion_starts_at' => $startsAt,
+ 'promotion_ends_at' => $endsAt,
'is_featured' => false,
+ 'is_active_campaign' => false,
+ 'is_homepage_featured' => false,
+ 'campaign_priority' => null,
'accepts_submissions' => true,
'participation_mode' => World::PARTICIPATION_MODE_MANUAL_APPROVAL,
'submission_starts_at' => $startsAt->copy()->subDay(),
@@ -53,6 +61,7 @@ class WorldFactory extends Factory
'cta_label' => 'Explore world',
'cta_url' => '/worlds',
'badge_label' => null,
+ 'campaign_label' => null,
'badge_description' => null,
'badge_url' => null,
'seo_title' => $title,
@@ -73,6 +82,27 @@ class WorldFactory extends Factory
]);
}
+ public function activeCampaign(int $priority = 100): self
+ {
+ return $this->state(fn (): array => [
+ 'is_active_campaign' => true,
+ 'campaign_priority' => $priority,
+ 'promotion_starts_at' => Carbon::now()->subDays(2),
+ 'promotion_ends_at' => Carbon::now()->addDays(5),
+ ]);
+ }
+
+ public function homepageFeatured(int $priority = 100): self
+ {
+ return $this->state(fn (): array => [
+ 'is_active_campaign' => true,
+ 'is_homepage_featured' => true,
+ 'campaign_priority' => $priority,
+ 'promotion_starts_at' => Carbon::now()->subDays(2),
+ 'promotion_ends_at' => Carbon::now()->addDays(5),
+ ]);
+ }
+
public function current(): self
{
return $this->state(fn (): array => [
diff --git a/database/migrations/2026_04_18_130000_add_activation_fields_to_worlds_table.php b/database/migrations/2026_04_18_130000_add_activation_fields_to_worlds_table.php
new file mode 100644
index 00000000..fd54c6e9
--- /dev/null
+++ b/database/migrations/2026_04_18_130000_add_activation_fields_to_worlds_table.php
@@ -0,0 +1,47 @@
+boolean('is_active_campaign')->default(false)->after('is_featured');
+ $table->boolean('is_homepage_featured')->default(false)->after('is_active_campaign');
+ $table->integer('campaign_priority')->nullable()->after('is_homepage_featured');
+ $table->string('campaign_label', 120)->nullable()->after('badge_label');
+ $table->string('teaser_title', 180)->nullable()->after('summary');
+ $table->string('teaser_summary', 320)->nullable()->after('teaser_title');
+ $table->string('teaser_image_path', 2048)->nullable()->after('cover_path');
+ $table->timestamp('promotion_starts_at')->nullable()->after('ends_at');
+ $table->timestamp('promotion_ends_at')->nullable()->after('promotion_starts_at');
+
+ $table->index(['status', 'is_active_campaign', 'campaign_priority'], 'worlds_activation_idx');
+ $table->index(['is_homepage_featured', 'campaign_priority'], 'worlds_homepage_featured_idx');
+ $table->index(['promotion_starts_at', 'promotion_ends_at'], 'worlds_promotion_window_idx');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('worlds', function (Blueprint $table): void {
+ $table->dropIndex('worlds_activation_idx');
+ $table->dropIndex('worlds_homepage_featured_idx');
+ $table->dropIndex('worlds_promotion_window_idx');
+ $table->dropColumn([
+ 'is_active_campaign',
+ 'is_homepage_featured',
+ 'campaign_priority',
+ 'campaign_label',
+ 'teaser_title',
+ 'teaser_summary',
+ 'teaser_image_path',
+ 'promotion_starts_at',
+ 'promotion_ends_at',
+ ]);
+ });
+ }
+};
\ No newline at end of file
diff --git a/database/migrations/2026_04_19_120000_create_world_reward_grants_table.php b/database/migrations/2026_04_19_120000_create_world_reward_grants_table.php
new file mode 100644
index 00000000..1204abe6
--- /dev/null
+++ b/database/migrations/2026_04_19_120000_create_world_reward_grants_table.php
@@ -0,0 +1,37 @@
+id();
+ $table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
+ $table->foreignId('world_id')->constrained('worlds')->cascadeOnDelete();
+ $table->foreignId('artwork_id')->nullable()->constrained('artworks')->nullOnDelete();
+ $table->foreignId('world_submission_id')->nullable()->constrained('world_submissions')->nullOnDelete();
+ $table->foreignId('granted_by_user_id')->nullable()->constrained('users')->nullOnDelete();
+ $table->string('reward_type', 32);
+ $table->string('grant_source', 16)->default('manual');
+ $table->text('note')->nullable();
+ $table->timestamp('granted_at')->nullable();
+ $table->timestamps();
+
+ $table->unique(['user_id', 'world_id', 'reward_type'], 'world_reward_grants_unique_reward');
+ $table->index(['world_id', 'reward_type']);
+ $table->index(['artwork_id']);
+ $table->index(['world_submission_id']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('world_reward_grants');
+ }
+};
\ No newline at end of file
diff --git a/database/migrations/2026_04_19_130000_add_unique_recurrence_year_constraint_to_worlds_table.php b/database/migrations/2026_04_19_130000_add_unique_recurrence_year_constraint_to_worlds_table.php
new file mode 100644
index 00000000..f62e4343
--- /dev/null
+++ b/database/migrations/2026_04_19_130000_add_unique_recurrence_year_constraint_to_worlds_table.php
@@ -0,0 +1,26 @@
+dropIndex('worlds_recurrence_idx');
+ $table->unique(['recurrence_key', 'edition_year'], 'worlds_recurrence_year_unique');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('worlds', function (Blueprint $table): void {
+ $table->dropUnique('worlds_recurrence_year_unique');
+ $table->index(['recurrence_key', 'edition_year'], 'worlds_recurrence_idx');
+ });
+ }
+};
\ No newline at end of file
diff --git a/database/migrations/2026_04_19_220000_add_hidden_linked_challenge_artwork_ids_to_worlds_table.php b/database/migrations/2026_04_19_220000_add_hidden_linked_challenge_artwork_ids_to_worlds_table.php
new file mode 100644
index 00000000..522c4f28
--- /dev/null
+++ b/database/migrations/2026_04_19_220000_add_hidden_linked_challenge_artwork_ids_to_worlds_table.php
@@ -0,0 +1,25 @@
+json('hidden_linked_challenge_artwork_ids_json')
+ ->nullable();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('worlds', function (Blueprint $table): void {
+ $table->dropColumn('hidden_linked_challenge_artwork_ids_json');
+ });
+ }
+};
\ No newline at end of file
diff --git a/database/migrations/2026_04_19_220000_add_recap_metadata_fields_to_worlds_table.php b/database/migrations/2026_04_19_220000_add_recap_metadata_fields_to_worlds_table.php
new file mode 100644
index 00000000..9b5dcbee
--- /dev/null
+++ b/database/migrations/2026_04_19_220000_add_recap_metadata_fields_to_worlds_table.php
@@ -0,0 +1,53 @@
+text('recap_editor_note')->nullable();
+
+ if ($afterColumn !== null) {
+ $column->after($afterColumn);
+ }
+ }
+
+ if (! $hasRecapCoverPath) {
+ $column = $table->string('recap_cover_path', 2048)->nullable();
+
+ if (! $hasRecapEditorNote) {
+ $column->after('recap_editor_note');
+ } elseif ($afterColumn !== null) {
+ $column->after($afterColumn);
+ }
+ }
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('worlds', function (Blueprint $table): void {
+ $columns = array_values(array_filter([
+ Schema::hasColumn('worlds', 'recap_editor_note') ? 'recap_editor_note' : null,
+ Schema::hasColumn('worlds', 'recap_cover_path') ? 'recap_cover_path' : null,
+ ]));
+
+ if ($columns !== []) {
+ $table->dropColumn($columns);
+ }
+ });
+ }
+};
\ No newline at end of file
diff --git a/database/migrations/2026_04_19_230000_create_world_editorial_suggestion_states_table.php b/database/migrations/2026_04_19_230000_create_world_editorial_suggestion_states_table.php
new file mode 100644
index 00000000..c3cfa241
--- /dev/null
+++ b/database/migrations/2026_04_19_230000_create_world_editorial_suggestion_states_table.php
@@ -0,0 +1,32 @@
+id();
+ $table->foreignId('world_id')->constrained()->cascadeOnDelete();
+ $table->string('related_type', 40);
+ $table->unsignedBigInteger('related_id');
+ $table->string('status', 40);
+ $table->string('section_key', 80)->nullable();
+ $table->foreignId('acted_by_user_id')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamps();
+
+ $table->unique(['world_id', 'related_type', 'related_id'], 'world_editorial_suggestion_states_unique');
+ $table->index(['world_id', 'status'], 'world_editorial_suggestion_states_status_idx');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('world_editorial_suggestion_states');
+ }
+};
\ No newline at end of file
diff --git a/database/migrations/2026_04_20_090000_add_linked_challenge_fields_to_worlds_table.php b/database/migrations/2026_04_20_090000_add_linked_challenge_fields_to_worlds_table.php
new file mode 100644
index 00000000..5ae6d8b0
--- /dev/null
+++ b/database/migrations/2026_04_20_090000_add_linked_challenge_fields_to_worlds_table.php
@@ -0,0 +1,38 @@
+foreignId('linked_challenge_id')->nullable()->after('parent_world_id')->constrained('group_challenges')->nullOnDelete();
+ $table->boolean('show_linked_challenge_section')->default(true)->after('linked_challenge_id');
+ $table->boolean('show_linked_challenge_entries')->default(true)->after('show_linked_challenge_section');
+ $table->boolean('show_linked_challenge_winners')->default(true)->after('show_linked_challenge_entries');
+ $table->boolean('show_linked_challenge_finalists')->default(true)->after('show_linked_challenge_winners');
+ $table->boolean('auto_grant_challenge_world_rewards')->default(true)->after('show_linked_challenge_finalists');
+ $table->text('challenge_teaser_override')->nullable()->after('auto_grant_challenge_world_rewards');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('worlds', function (Blueprint $table): void {
+ $table->dropConstrainedForeignId('linked_challenge_id');
+ $table->dropColumn([
+ 'show_linked_challenge_section',
+ 'show_linked_challenge_entries',
+ 'show_linked_challenge_winners',
+ 'show_linked_challenge_finalists',
+ 'auto_grant_challenge_world_rewards',
+ 'challenge_teaser_override',
+ ]);
+ });
+ }
+};
\ No newline at end of file
diff --git a/database/migrations/2026_04_20_120000_create_group_challenge_outcomes_table.php b/database/migrations/2026_04_20_120000_create_group_challenge_outcomes_table.php
new file mode 100644
index 00000000..3fea3e92
--- /dev/null
+++ b/database/migrations/2026_04_20_120000_create_group_challenge_outcomes_table.php
@@ -0,0 +1,36 @@
+id();
+ $table->foreignId('group_challenge_id')->constrained('group_challenges')->cascadeOnDelete();
+ $table->foreignId('artwork_id')->constrained('artworks')->cascadeOnDelete();
+ $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
+ $table->string('outcome_type', 32);
+ $table->unsignedInteger('position')->nullable();
+ $table->unsignedInteger('sort_order')->default(0);
+ $table->string('title_override', 120)->nullable();
+ $table->text('note')->nullable();
+ $table->foreignId('awarded_by_user_id')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamp('awarded_at')->nullable();
+ $table->timestamps();
+
+ $table->unique(['group_challenge_id', 'artwork_id', 'outcome_type'], 'group_challenge_outcomes_unique');
+ $table->index(['group_challenge_id', 'outcome_type', 'sort_order'], 'group_challenge_outcomes_type_sort_idx');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('group_challenge_outcomes');
+ }
+};
\ No newline at end of file
diff --git a/database/migrations/2026_04_20_130000_create_world_analytics_events_table.php b/database/migrations/2026_04_20_130000_create_world_analytics_events_table.php
new file mode 100644
index 00000000..226e1707
--- /dev/null
+++ b/database/migrations/2026_04_20_130000_create_world_analytics_events_table.php
@@ -0,0 +1,46 @@
+id();
+ $table->unsignedBigInteger('world_id')->index();
+ $table->string('event_type', 40)->index();
+ $table->string('world_slug', 160)->index();
+ $table->string('world_type', 40)->nullable()->index();
+ $table->string('recurrence_key', 120)->nullable()->index();
+ $table->unsignedSmallInteger('edition_year')->nullable()->index();
+ $table->string('section_key', 80)->nullable()->index();
+ $table->string('cta_key', 80)->nullable()->index();
+ $table->string('entity_type', 40)->nullable()->index();
+ $table->unsignedBigInteger('entity_id')->nullable()->index();
+ $table->string('entity_title', 180)->nullable();
+ $table->unsignedBigInteger('challenge_id')->nullable()->index();
+ $table->string('source_surface', 80)->nullable()->index();
+ $table->string('source_detail', 80)->nullable()->index();
+ $table->string('viewer_type', 16)->index();
+ $table->unsignedBigInteger('user_id')->nullable()->index();
+ $table->string('visitor_key', 64)->index();
+ $table->json('meta')->nullable();
+ $table->timestamp('occurred_at')->useCurrent()->index();
+
+ $table->index(['world_id', 'event_type', 'occurred_at'], 'world_analytics_world_event_occurred_idx');
+ $table->index(['world_id', 'source_surface', 'occurred_at'], 'world_analytics_world_source_occurred_idx');
+ $table->index(['world_id', 'section_key', 'occurred_at'], 'world_analytics_world_section_occurred_idx');
+
+ $table->foreign('world_id')->references('id')->on('worlds')->cascadeOnDelete();
+ $table->foreign('user_id')->references('id')->on('users')->nullOnDelete();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('world_analytics_events');
+ }
+};
\ No newline at end of file
diff --git a/database/migrations/2026_04_20_180000_add_recap_fields_to_worlds_table.php b/database/migrations/2026_04_20_180000_add_recap_fields_to_worlds_table.php
new file mode 100644
index 00000000..c1da0e0c
--- /dev/null
+++ b/database/migrations/2026_04_20_180000_add_recap_fields_to_worlds_table.php
@@ -0,0 +1,41 @@
+string('recap_status', 24)->default('draft')->after('published_at');
+ $table->string('recap_title', 180)->nullable()->after('recap_status');
+ $table->string('recap_summary', 320)->nullable()->after('recap_title');
+ $table->text('recap_intro')->nullable()->after('recap_summary');
+ $table->foreignId('recap_article_id')->nullable()->after('recap_intro')->constrained('news_articles')->nullOnDelete();
+ $table->json('recap_stats_snapshot_json')->nullable()->after('recap_article_id');
+ $table->timestamp('recap_published_at')->nullable()->after('recap_stats_snapshot_json');
+
+ $table->index(['recap_status', 'recap_published_at'], 'worlds_recap_status_idx');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('worlds', function (Blueprint $table): void {
+ $table->dropIndex('worlds_recap_status_idx');
+ $table->dropConstrainedForeignId('recap_article_id');
+ $table->dropColumn([
+ 'recap_status',
+ 'recap_title',
+ 'recap_summary',
+ 'recap_intro',
+ 'recap_stats_snapshot_json',
+ 'recap_published_at',
+ ]);
+ });
+ }
+};
\ No newline at end of file
diff --git a/database/seeders/WorldLaunchSeeder.php b/database/seeders/WorldLaunchSeeder.php
index fe0386ff..bb10f7bd 100644
--- a/database/seeders/WorldLaunchSeeder.php
+++ b/database/seeders/WorldLaunchSeeder.php
@@ -62,10 +62,95 @@ final class WorldLaunchSeeder extends Seeder
$now = now();
$currentYear = (int) $now->year;
+ $springVibes = $this->upsertWorld('spring-vibes-' . $currentYear, [
+ 'title' => 'Spring Vibes ' . $currentYear,
+ 'tagline' => 'Fresh palettes, softer light, and a friendly live participation moment across Skinbase.',
+ 'summary' => 'A live seasonal world for bright artwork, curated collections, creator spotlights, and active submissions.',
+ 'teaser_title' => 'Now live: Spring Vibes',
+ 'teaser_summary' => 'Fresh seasonal artwork, open submissions, and curated highlights are all running inside the current Spring Vibes campaign.',
+ 'description' => 'Spring Vibes is the first fully activated world. It is meant to feel like a live public campaign across homepage, worlds discovery, and upload participation rather than a buried editorial page.',
+ 'theme_key' => 'seasonal',
+ 'accent_color' => '#84cc16',
+ 'accent_color_secondary' => '#14532d',
+ 'background_motif' => 'bloomwave',
+ 'icon_name' => 'fa-solid fa-seedling',
+ 'status' => World::STATUS_PUBLISHED,
+ 'type' => World::TYPE_SEASONAL,
+ 'starts_at' => $now->copy()->subDays(6)->startOfDay(),
+ 'ends_at' => $now->copy()->addDays(18)->endOfDay(),
+ 'promotion_starts_at' => $now->copy()->subDays(2)->startOfDay(),
+ 'promotion_ends_at' => $now->copy()->addDays(12)->endOfDay(),
+ 'is_featured' => true,
+ 'is_active_campaign' => true,
+ 'is_homepage_featured' => true,
+ 'campaign_priority' => 900,
+ 'campaign_label' => 'Live now',
+ 'accepts_submissions' => true,
+ 'participation_mode' => World::PARTICIPATION_MODE_MANUAL_APPROVAL,
+ 'submission_starts_at' => $now->copy()->subDays(4)->startOfDay(),
+ 'submission_ends_at' => $now->copy()->addDays(10)->endOfDay(),
+ 'is_recurring' => true,
+ 'recurrence_key' => 'spring-vibes',
+ 'recurrence_rule' => 'FREQ=YEARLY;BYMONTH=' . $now->month,
+ 'edition_year' => $currentYear,
+ 'cta_label' => 'Join Spring Vibes',
+ 'cta_url' => '/worlds/spring-vibes-' . $currentYear,
+ 'badge_label' => 'Homepage spotlight',
+ 'badge_description' => 'The primary live world promoted across homepage, upload, and worlds discovery surfaces.',
+ 'badge_url' => '/worlds',
+ 'seo_title' => 'Spring Vibes ' . $currentYear . ' on Skinbase',
+ 'seo_description' => 'Spring Vibes is the live seasonal world on Skinbase right now, with open participation and editorially promoted discovery.',
+ 'related_tags_json' => ['spring', 'seasonal', 'fresh', 'bloom'],
+ 'section_order_json' => ['featured_artworks', 'featured_collections', 'featured_creators', 'featured_groups', 'news', 'cards'],
+ 'created_by_user_id' => $editor->id,
+ 'published_at' => $now->copy()->subDays(9),
+ ]);
+
+ $springArchive = $this->upsertWorld('spring-vibes-' . ($currentYear - 1), [
+ 'title' => 'Spring Vibes ' . ($currentYear - 1),
+ 'tagline' => 'Last year\'s edition of the recurring spring campaign.',
+ 'summary' => 'Archived spring edition kept visible as part of the recurring worlds record.',
+ 'teaser_title' => 'Spring Vibes archive',
+ 'teaser_summary' => 'The previous edition remains browsable so recurring worlds build continuity over time.',
+ 'description' => 'Spring Vibes archive keeps the prior edition public so the recurring seasonal world feels like a real continuing program.',
+ 'theme_key' => 'seasonal',
+ 'accent_color' => '#65a30d',
+ 'accent_color_secondary' => '#365314',
+ 'background_motif' => 'bloomwave',
+ 'icon_name' => 'fa-solid fa-seedling',
+ 'status' => World::STATUS_ARCHIVED,
+ 'type' => World::TYPE_SEASONAL,
+ 'starts_at' => $now->copy()->subYear()->subDays(6)->startOfDay(),
+ 'ends_at' => $now->copy()->subYear()->addDays(18)->endOfDay(),
+ 'is_featured' => false,
+ 'is_active_campaign' => false,
+ 'is_homepage_featured' => false,
+ 'campaign_priority' => null,
+ 'campaign_label' => 'Archive edition',
+ 'is_recurring' => true,
+ 'recurrence_key' => 'spring-vibes',
+ 'recurrence_rule' => 'FREQ=YEARLY;BYMONTH=' . $now->month,
+ 'edition_year' => $currentYear - 1,
+ 'cta_label' => 'Browse archive',
+ 'cta_url' => '/worlds/spring-vibes-' . ($currentYear - 1),
+ 'badge_label' => 'Archive edition',
+ 'badge_description' => 'The prior Spring Vibes edition remains visible for continuity.',
+ 'badge_url' => '/worlds',
+ 'seo_title' => 'Spring Vibes ' . ($currentYear - 1) . ' archive',
+ 'seo_description' => 'The previous Spring Vibes edition remains public as part of the recurring worlds archive.',
+ 'related_tags_json' => ['spring', 'archive'],
+ 'section_order_json' => ['featured_artworks', 'featured_creators', 'news'],
+ 'parent_world_id' => $springVibes->id,
+ 'created_by_user_id' => $editor->id,
+ 'published_at' => $now->copy()->subYear()->subDays(12),
+ ]);
+
$retroMonth = $this->upsertWorld('retro-month-' . $currentYear, [
'title' => 'Retro Month ' . $currentYear,
'tagline' => 'Chrome, scanlines, glossy interfaces, and warm-digital nostalgia.',
'summary' => 'A featured editorial world that packages retro-inspired artworks, collections, creators, groups, news, and Nova cards into one recurring seasonal destination.',
+ 'teaser_title' => 'Explore Retro Month',
+ 'teaser_summary' => 'A live supporting campaign for glossy nostalgia, synth palettes, and editorially curated retro culture.',
'description' => "Retro Month curates the surface language of nostalgia into a single destination. It highlights polished throwback artwork, creator identity, collaborative groups, and the editorial context that makes seasonal programming feel intentional instead of accidental.",
'theme_key' => 'retro-month',
'accent_color' => '#f97316',
@@ -76,7 +161,13 @@ final class WorldLaunchSeeder extends Seeder
'type' => World::TYPE_CAMPAIGN,
'starts_at' => $now->copy()->subDays(10)->startOfDay(),
'ends_at' => $now->copy()->addDays(18)->endOfDay(),
+ 'promotion_starts_at' => $now->copy()->subDays(5)->startOfDay(),
+ 'promotion_ends_at' => $now->copy()->addDays(9)->endOfDay(),
'is_featured' => true,
+ 'is_active_campaign' => true,
+ 'is_homepage_featured' => false,
+ 'campaign_priority' => 450,
+ 'campaign_label' => 'Supporting campaign',
'is_recurring' => true,
'recurrence_key' => 'retro-month',
'recurrence_rule' => 'FREQ=YEARLY;BYMONTH=' . $now->month,
@@ -109,6 +200,10 @@ final class WorldLaunchSeeder extends Seeder
'starts_at' => $now->copy()->subYear()->subDays(10)->startOfDay(),
'ends_at' => $now->copy()->subYear()->addDays(18)->endOfDay(),
'is_featured' => false,
+ 'is_active_campaign' => false,
+ 'is_homepage_featured' => false,
+ 'campaign_priority' => null,
+ 'campaign_label' => 'Archive edition',
'is_recurring' => true,
'recurrence_key' => 'retro-month',
'recurrence_rule' => 'FREQ=YEARLY;BYMONTH=' . $now->month,
@@ -131,6 +226,8 @@ final class WorldLaunchSeeder extends Seeder
'title' => 'Pixel Week ' . $currentYear,
'tagline' => 'Small-scale craft, tight palettes, and highly legible form.',
'summary' => 'An upcoming themed week focused on pixel art, sprites, handheld aesthetics, and compact visual systems.',
+ 'teaser_title' => 'Pixel Week is coming up',
+ 'teaser_summary' => 'The next campaign is queued with a tight pixel-art brief, creator spotlights, and themed collections.',
'description' => 'Pixel Week is scheduled as the next clear editorial world, giving the public navigation a real forward-looking destination instead of a dead module stub.',
'theme_key' => 'pixel-week',
'accent_color' => '#38bdf8',
@@ -141,7 +238,13 @@ final class WorldLaunchSeeder extends Seeder
'type' => World::TYPE_EVENT,
'starts_at' => $now->copy()->addDays(24)->startOfDay(),
'ends_at' => $now->copy()->addDays(31)->endOfDay(),
+ 'promotion_starts_at' => $now->copy()->addDays(18)->startOfDay(),
+ 'promotion_ends_at' => $now->copy()->addDays(31)->endOfDay(),
'is_featured' => false,
+ 'is_active_campaign' => true,
+ 'is_homepage_featured' => false,
+ 'campaign_priority' => 300,
+ 'campaign_label' => 'Upcoming campaign',
'is_recurring' => true,
'recurrence_key' => 'pixel-week',
'recurrence_rule' => 'FREQ=YEARLY;BYMONTH=' . $now->copy()->addDays(24)->month,
@@ -255,6 +358,22 @@ final class WorldLaunchSeeder extends Seeder
'published_at' => $now->copy()->subDays(7),
]);
+ $this->syncRelations($springVibes, [
+ $this->relation('featured_artworks', WorldRelation::TYPE_ARTWORK, $artworks[2]?->id, 'Live seasonal feature', true, 0),
+ $this->relation('featured_artworks', WorldRelation::TYPE_ARTWORK, $artworks[1]?->id, 'Community highlight', false, 1),
+ $this->relation('featured_collections', WorldRelation::TYPE_COLLECTION, $collections[1]?->id, 'Fresh picks', true, 0),
+ $this->relation('featured_creators', WorldRelation::TYPE_USER, $sceneCreator->id, 'Spring spotlight creator', true, 0),
+ $this->relation('featured_groups', WorldRelation::TYPE_GROUP, $groups[0]?->id, 'Collaborative feature', true, 0),
+ $this->relation('news', WorldRelation::TYPE_NEWS, ($newsArticles[0] ?? null)?->id, 'Launch coverage', true, 0),
+ $this->relation('cards', WorldRelation::TYPE_CARD, ($cards[0] ?? null)?->id, 'Campaign card', true, 0),
+ ]);
+
+ $this->syncRelations($springArchive, [
+ $this->relation('featured_artworks', WorldRelation::TYPE_ARTWORK, $artworks[2]?->id, 'Archive favorite', true, 0),
+ $this->relation('featured_creators', WorldRelation::TYPE_USER, $sceneCreator->id, 'Returning creator', true, 0),
+ $this->relation('news', WorldRelation::TYPE_NEWS, ($newsArticles[0] ?? null)?->id, 'Previous launch notes', true, 0),
+ ]);
+
$this->syncRelations($retroMonth, [
$this->relation('featured_artworks', WorldRelation::TYPE_ARTWORK, $artworks[0]?->id, 'Signature piece', true, 0),
$this->relation('featured_artworks', WorldRelation::TYPE_ARTWORK, $artworks[2]?->id, 'Editorial pick', false, 1),
diff --git a/resources/js/Pages/Group/GroupChallengeShow.jsx b/resources/js/Pages/Group/GroupChallengeShow.jsx
index b2bb3d90..52cf0114 100644
--- a/resources/js/Pages/Group/GroupChallengeShow.jsx
+++ b/resources/js/Pages/Group/GroupChallengeShow.jsx
@@ -1,11 +1,32 @@
import React from 'react'
import { usePage } from '@inertiajs/react'
import SeoHead from '../../components/seo/SeoHead'
+import ChallengeWorldLinkBadge from '../../components/worlds/ChallengeWorldLinkBadge'
+import WorldChallengeArtworkCard from '../../components/worlds/WorldChallengeArtworkCard'
+
+function OutcomeSection({ section }) {
+ const items = Array.isArray(section?.items) ? section.items : []
+
+ if (items.length === 0) {
+ return null
+ }
+
+ return (
+ No entries linked yet. No entries linked yet. {world.tagline} {world.summary}{section.label}
+ Entries
- : null}
-
Entries
+ : null}
+
: null}
-
-
-
{world.title}
- {world.tagline ?
Link one group challenge when this world should automatically surface challenge status, entries, and winner context.
+Hide specific linked challenge entries from the derived world feed when moderation or editorial context requires it.
+Track whether this editor is pointing at the canonical family edition, an archived edition, or a sibling inside the same recurrence group.
+Move through adjacent editions in this recurring world family without losing the archive context.
+Creators who earned visible recognition in this edition. Live participation builds history here, while featured and editorial selections raise the level of recognition.
+This edition’s rewards are edition-aware, so recognition here remains part of each creator’s recurring world history.
+Each edition stays public, but the family route always resolves to the canonical current or latest edition.
++ World participation and recognition now live in a dedicated history view so recurring editions, challenge results, and standout placements read like a creator story instead of a badge dump. +
++ {isOwner + ? (hasPrivateContext + ? 'Public world history appears here once a live, publicly visible submission or recognized placement is available. Until then, private-only world activity is still tracked for you above.' + : 'Public world history appears here once a live, publicly visible submission or recognized placement is available.') + : 'This creator does not have any public world participation or recognition to show yet.'} +
++ Your public worlds timeline stays strict about visibility, but this profile still tracks {details.join(', ')}. +
++ This timeline pulls together edition-aware world participation, featured placements, finalists, winners, and linked challenge results into one creator-facing history layer. +
++ The most recent and highest-signal world appearances surface first so recurring recognition reads like a creator recap, not just a raw list. +
++ Every public world appearance, challenge-linked outcome, and edition-aware placement remains visible here in chronological order. +
+{spotlight.title}
: null} +{spotlight.tagline}
: null} + {spotlight.summary ?{spotlight.summary}
: null} + +More live or upcoming worlds that are being actively surfaced right now.
+{world.summary}
: null} + {metaItems.length > 0 ?{world.teaser_summary || world.summary}
: null} +{notice.description}
: null} +{world.title}
: null} +{world.tagline}
: null} {world.summary ?{world.summary}
: null}{item.description}
: null} + {Array.isArray(item.meta) && item.meta.length > 0 ?{section.description}
: null} ++ {section?.description || 'Finalists from the linked challenge stay visible here so the world can carry the full result set forward as a public recap.'} +
+{section.summary}
: null} +{section.story.description}
: null} + {storyMeta.length > 0 ?{section.description}
: null} +{family.summary}
: null} + +{currentWorld.summary}
: null} +No public edition is currently available.
+ )} +The archive starts with the current edition.
+ )} +{world.tagline}
: null} - {world.summary ?{world.summary}
: null} + {world.summary ?{world.summary}
: null} {world.description ? ( ) : null}