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 ( +
+

{section.label}

+
+ {items.map((item) => )} +
+
+ ) +} export default function GroupChallengeShow() { const { props } = usePage() const group = props.group || {} const challenge = props.challenge || {} + const linkedWorld = props.linkedWorld || null + const outcomeSections = challenge.outcome_sections || {} return (
@@ -24,6 +45,7 @@ export default function GroupChallengeShow() { {challenge.start_at ? Starts {new Date(challenge.start_at).toLocaleDateString()} : null} {challenge.end_at ? Ends {new Date(challenge.end_at).toLocaleDateString()} : null} + @@ -45,17 +67,25 @@ export default function GroupChallengeShow() { ) : null} -
-

Entries

-
- {Array.isArray(challenge.artworks) && challenge.artworks.length > 0 ? challenge.artworks.map((artwork) => ( - - {artwork.thumb ? {artwork.title} : null} -
{artwork.title}
-
- )) :

No entries linked yet.

} -
-
+
+ + + + + + +
+

Entries

+
+ {Array.isArray(challenge.artworks) && challenge.artworks.length > 0 ? challenge.artworks.map((artwork) => ( + + {artwork.thumb ? {artwork.title} : null} +
{artwork.title}
+
+ )) :

No entries linked yet.

} +
+
+
diff --git a/resources/js/Pages/Home/HomeWorldSpotlight.jsx b/resources/js/Pages/Home/HomeWorldSpotlight.jsx index c12e22e4..b81be9ba 100644 --- a/resources/js/Pages/Home/HomeWorldSpotlight.jsx +++ b/resources/js/Pages/Home/HomeWorldSpotlight.jsx @@ -1,49 +1,27 @@ import React from 'react' +import ActiveWorldSpotlight from '../../components/worlds/ActiveWorldSpotlight' export default function HomeWorldSpotlight({ world }) { if (!world) { return null } + const spotlight = world.primary || world + const secondary = Array.isArray(world.secondary) ? world.secondary : [] + return (
- -
- {world.cover_url ? {world.title} : null} -
- -
-
-
- Homepage spotlight - {world.badge_label ? {world.badge_label} : null} -
-

{world.title}

- {world.tagline ?

{world.tagline}

: null} - {world.summary ?

{world.summary}

: null} -
- {world.cta_label || 'Explore world'} - -
-
- -
-
World Theme
-
- - {world.theme?.label || 'Editorial world'} -
- {world.timeframe_label ?
{world.timeframe_label}
: null} -
-
-
+
) } \ No newline at end of file diff --git a/resources/js/Pages/Profile/ProfileShow.jsx b/resources/js/Pages/Profile/ProfileShow.jsx index b427870c..c0f3b20f 100644 --- a/resources/js/Pages/Profile/ProfileShow.jsx +++ b/resources/js/Pages/Profile/ProfileShow.jsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useCallback } from 'react' import { usePage } from '@inertiajs/react' +import SeoHead from '../../components/seo/SeoHead' import ProfileHero from '../../components/profile/ProfileHero' import ProfileTabs from '../../components/profile/ProfileTabs' import TabArtworks from '../../components/profile/tabs/TabArtworks' @@ -11,9 +12,10 @@ import TabCollections from '../../components/profile/tabs/TabCollections' import TabActivity from '../../components/profile/tabs/TabActivity' import TabPosts from '../../components/profile/tabs/TabPosts' import TabStories from '../../components/profile/tabs/TabStories' +import TabWorlds from '../../components/profile/tabs/TabWorlds' import GroupProfileSummary from '../../components/groups/GroupProfileSummary' -const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'collections', 'about', 'stats', 'favourites', 'activity'] +const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'worlds', 'collections', 'about', 'stats', 'favourites', 'activity'] function getInitialTab(initialTab = 'posts') { if (typeof window === 'undefined') { @@ -62,6 +64,8 @@ export default function ProfileShow() { creatorStories, collections, achievements, + worldRewards, + worldHistory, leaderboardRank, groupContributionHistory, journey, @@ -76,6 +80,7 @@ export default function ProfileShow() { collectionsFeaturedUrl, collectionFeatureLimit, profileTabUrls, + seo, } = props const [activeTab, setActiveTab] = useState(() => getInitialTab(initialTab)) @@ -128,7 +133,9 @@ export default function ProfileShow() { : 'max-w-6xl mx-auto px-4' return ( -
+ <> + +
-
+
+ ) } diff --git a/resources/js/Pages/Studio/StudioWorldEditor.jsx b/resources/js/Pages/Studio/StudioWorldEditor.jsx index c04e421c..260cd85c 100644 --- a/resources/js/Pages/Studio/StudioWorldEditor.jsx +++ b/resources/js/Pages/Studio/StudioWorldEditor.jsx @@ -5,12 +5,16 @@ import RichTextEditor from '../../components/forum/RichTextEditor' import { Checkbox, DateTimePicker, NovaSelect } from '../../components/ui' import NovaConfirmDialog from '../../components/ui/NovaConfirmDialog' import WorldDuplicateActionMenu from '../../components/worlds/editor/WorldDuplicateActionMenu' +import WorldRecapArticlePickerModal from '../../components/worlds/editor/WorldRecapArticlePickerModal' +import WorldLinkedChallengePickerModal from '../../components/worlds/editor/WorldLinkedChallengePickerModal' import WorldMiniPreviewPanel from '../../components/worlds/editor/WorldMiniPreviewPanel' import WorldRecurrenceHelper from '../../components/worlds/editor/WorldRecurrenceHelper' import WorldRelationCard from '../../components/worlds/editor/WorldRelationCard' import WorldRelationPickerModal from '../../components/worlds/editor/WorldRelationPickerModal' import WorldSectionToggleList from '../../components/worlds/editor/WorldSectionToggleList' import WorldMediaUploadField from '../../components/worlds/editor/WorldMediaUploadField' +import WorldAnalyticsPanel from '../../components/worlds/editor/analytics/WorldAnalyticsPanel' +import WorldSuggestionsPanel from '../../components/worlds/editor/suggestions/WorldSuggestionsPanel' import WorldSummaryCard from '../../components/worlds/editor/WorldSummaryCard' import WorldThemePresetHelper from '../../components/worlds/editor/WorldThemePresetHelper' @@ -34,6 +38,28 @@ function normalizeRelations(relations) { })) } +function relationForEditor(relation) { + return { + ...relation, + sort_order: Number(relation?.sort_order || 0), + is_featured: Boolean(relation?.is_featured), + preview: relation?.preview || null, + query: relation?.query || relation?.preview?.title || '', + } +} + +function upsertRelation(relations, nextRelation) { + const items = Array.isArray(relations) ? relations : [] + const normalized = relationForEditor(nextRelation) + const existingIndex = items.findIndex((relation) => relation.related_type === normalized.related_type && Number(relation.related_id) === Number(normalized.related_id)) + + if (existingIndex === -1) { + return normalizeRelations([...items, normalized]) + } + + return normalizeRelations(items.map((relation, index) => (index === existingIndex ? relationForEditor({ ...relation, ...normalized }) : relation))) +} + function initialSectionVisibility(sectionOptions, worldVisibility) { const defaults = Object.fromEntries((Array.isArray(sectionOptions) ? sectionOptions : []).map((option) => [option.value, true])) return { ...defaults, ...(worldVisibility || {}) } @@ -70,6 +96,16 @@ function resolveMediaUrl(path, fallbackUrl = '', filesBaseUrl = '') { return path } +function formatCompactNumber(value) { + const number = Number(value || 0) + + if (!Number.isFinite(number)) { + return '0' + } + + return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(number) +} + const DEFAULT_ACTION_CONFIRM = { open: false, url: '', @@ -79,6 +115,9 @@ const DEFAULT_ACTION_CONFIRM = { cancelLabel: 'Cancel', confirmTone: 'danger', noteEnabled: false, + copyModeEnabled: false, + copyModeOptions: [], + defaultCopyMode: 'with_relations', preserveScroll: true, } @@ -92,8 +131,10 @@ function buildPreviewWorld(formData, world, themeOptions, typeOptions, filesBase summary: formData.summary, description: formData.description, cover_url: resolveMediaUrl(formData.cover_path, world?.cover_path === formData.cover_path ? world?.cover_url || '' : '', filesBaseUrl), + teaser_image_url: resolveMediaUrl(formData.teaser_image_path, world?.teaser_image_path === formData.teaser_image_path ? world?.teaser_image_url || '' : '', filesBaseUrl), type: type?.label || formData.type || 'Seasonal', badge_label: formData.badge_label, + campaign_label: formData.campaign_label, badge_description: formData.badge_description, cta_label: formData.cta_label, accent_color: formData.accent_color || theme?.accent_color || '#38bdf8', @@ -101,6 +142,8 @@ function buildPreviewWorld(formData, world, themeOptions, typeOptions, filesBase background_motif: formData.background_motif || theme?.background_motif || 'atmosphere', icon_name: formData.icon_name || theme?.icon_name || 'fa-solid fa-sparkles', is_featured: Boolean(formData.is_featured), + is_active_campaign: Boolean(formData.is_active_campaign), + is_homepage_featured: Boolean(formData.is_homepage_featured), } } @@ -117,6 +160,12 @@ const WORLD_EDITOR_TABS = [ icon: 'fa-solid fa-diagram-project', description: 'Curated relations and section ordering that shape the public world composition.', }, + { + id: 'suggestions', + label: 'Suggestions', + icon: 'fa-solid fa-wand-magic-sparkles', + description: 'Editorial assist candidates scored from theme, submissions, challenge context, and recurring-world signals.', + }, { id: 'community', label: 'Community', @@ -135,21 +184,36 @@ const WORLD_EDITOR_TABS = [ icon: 'fa-solid fa-swatchbook', description: 'Theme preset, visual identity, media assets, CTA, and badge surface copy.', }, + { + id: 'recap', + label: 'Recap', + icon: 'fa-solid fa-stars', + description: 'Shape the archive-facing recap with editorial framing, linked recap story, and publish-ready summary state.', + }, { id: 'seo', label: 'SEO', icon: 'fa-solid fa-magnifying-glass-chart', description: 'Search and social metadata that ships with the world page.', }, + { + id: 'analytics', + label: 'Analytics', + icon: 'fa-solid fa-chart-column', + description: 'Traffic, source surfaces, engagement, participation, challenge energy, and recurring-edition comparison.', + }, ] const WORLD_EDITOR_TAB_FIELDS = { basics: ['title', 'slug', 'tagline', 'summary', 'description'], - structure: ['relations', 'section_order_json', 'section_visibility_json'], + structure: ['relations', 'section_order_json', 'section_visibility_json', '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'], + suggestions: [], community: ['accepts_submissions', 'participation_mode', 'submission_starts_at', 'submission_ends_at', 'submission_note_enabled', 'community_section_enabled', 'allow_readd_after_removal', 'submission_guidelines'], - publishing: ['type', 'status', 'published_at', 'starts_at', 'ends_at', 'is_featured', 'is_recurring', 'recurrence_key', 'recurrence_rule', 'edition_year'], - presentation: ['theme_key', 'accent_color', 'accent_color_secondary', 'background_motif', 'icon_name', 'cover_path', 'og_image_path', 'cta_label', 'cta_url', 'badge_label', 'badge_description', 'badge_url', 'related_tags_json'], + publishing: ['type', 'status', 'published_at', 'starts_at', 'ends_at', 'promotion_starts_at', 'promotion_ends_at', 'is_featured', 'is_active_campaign', 'is_homepage_featured', 'campaign_priority', 'is_recurring', 'recurrence_key', 'recurrence_rule', 'edition_year'], + presentation: ['theme_key', 'accent_color', 'accent_color_secondary', 'background_motif', 'icon_name', 'cover_path', 'teaser_image_path', 'og_image_path', 'teaser_title', 'teaser_summary', 'cta_label', 'cta_url', 'badge_label', 'campaign_label', 'badge_description', 'badge_url', 'related_tags_json'], + recap: ['recap_status', 'recap_title', 'recap_summary', 'recap_intro', 'recap_editor_note', 'recap_cover_path', 'recap_article_id'], seo: ['seo_title', 'seo_description'], + analytics: [], } const PARTICIPATION_MODE_OPTIONS = [ @@ -179,7 +243,44 @@ function WorldEditorSection({ title, description, actions = null, children }) { ) } -function WorldEditorActionPanel({ formProcessing, isEditing, publishUrl, archiveUrl, publicUrl }) { +function LinkedChallengeCard({ challenge, onChange, onClear }) { + return ( +
+ {challenge ? ( +
+
+
+ {challenge.image ? :
} +
+
+
+
{challenge.title}
+ {challenge.entity_label ? {challenge.entity_label} : null} +
+ {challenge.subtitle ?
{challenge.subtitle}
: null} + {challenge.description ?
{challenge.description}
: null} + {Array.isArray(challenge.meta) && challenge.meta.length > 0 ?
{challenge.meta.map((entry) => {entry})}
: null} +
+
+
+ + +
+
+ ) : ( +
+
+
No primary challenge linked
+

Link one group challenge when this world should automatically surface challenge status, entries, and winner context.

+
+ +
+ )} +
+ ) +} + +function WorldEditorActionPanel({ formProcessing, isEditing, publishUrl, publishRecapUrl, canPublishRecap, recapStatusLabel, archiveUrl, publicUrl }) { return (
@@ -193,6 +294,7 @@ function WorldEditorActionPanel({ formProcessing, isEditing, publishUrl, archive
{publishUrl ? : null} + {publishRecapUrl ? : null} {archiveUrl ? : null} {publicUrl ? Public page : null}
@@ -200,6 +302,53 @@ function WorldEditorActionPanel({ formProcessing, isEditing, publishUrl, archive ) } +function LinkedChallengeEntryVisibilityManager({ challenge, hiddenIds, onToggle, error = '' }) { + const items = Array.isArray(challenge?.entry_preview_items) ? challenge.entry_preview_items : [] + + if (!challenge || items.length === 0) { + return null + } + + return ( +
+
+
+
Entry visibility overrides
+

Hide specific linked challenge entries from the derived world feed when moderation or editorial context requires it.

+
+
{hiddenIds.length} hidden
+
+ +
+ {items.map((item) => { + const hidden = hiddenIds.includes(item.id) + const statusLabel = item.status === 'winner' ? 'Winner' : item.status === 'finalist' ? 'Finalist' : 'Entry' + + return ( + + ) + })} +
+ + {error ?
{error}
: null} +
+ ) +} + export default function StudioWorldEditor() { const { props } = usePage() const world = props.world || null @@ -209,9 +358,10 @@ export default function StudioWorldEditor() { const themeOptions = props.themeOptions || [] const typeOptions = props.typeOptions || [] const duplicateActions = props.duplicateActions || null + const suggestions = props.suggestions || null const reviewQueue = world?.submission_review_queue || { counts: { pending: 0, live: 0, removed: 0, blocked: 0, featured: 0 }, items: [] } - const initialRelations = Array.isArray(world?.relations) ? normalizeRelations(world.relations.map((relation) => ({ + const initialRelations = Array.isArray(world?.relations) ? normalizeRelations(world.relations.map((relation) => relationForEditor({ section_key: relation.section_key || sectionOptions?.[0]?.value || 'featured_artworks', related_type: relation.related_type || relationTypeOptions?.[0]?.value || 'artwork', related_id: relation.related_id || '', @@ -219,7 +369,6 @@ export default function StudioWorldEditor() { sort_order: relation.sort_order || 0, is_featured: Boolean(relation.is_featured), preview: relation.preview || null, - query: relation.preview?.title || '', }))) : [] const form = useForm({ @@ -227,8 +376,11 @@ export default function StudioWorldEditor() { slug: world?.slug || '', tagline: world?.tagline || '', summary: world?.summary || '', + teaser_title: world?.teaser_title || '', + teaser_summary: world?.teaser_summary || '', description: world?.description || '', cover_path: world?.cover_path || '', + teaser_image_path: world?.teaser_image_path || '', theme_key: world?.theme_key ?? '', accent_color: world?.accent_color || '', accent_color_secondary: world?.accent_color_secondary || '', @@ -239,6 +391,8 @@ export default function StudioWorldEditor() { published_at: toDateTimeLocal(world?.published_at), starts_at: toDateTimeLocal(world?.starts_at), ends_at: toDateTimeLocal(world?.ends_at), + promotion_starts_at: toDateTimeLocal(world?.promotion_starts_at), + promotion_ends_at: toDateTimeLocal(world?.promotion_ends_at), accepts_submissions: Boolean(world?.accepts_submissions), participation_mode: world?.participation_mode || (world?.accepts_submissions ? 'manual_approval' : 'closed'), submission_starts_at: toDateTimeLocal(world?.submission_starts_at), @@ -247,6 +401,9 @@ export default function StudioWorldEditor() { community_section_enabled: world?.community_section_enabled !== false, allow_readd_after_removal: world?.allow_readd_after_removal !== false, is_featured: Boolean(world?.is_featured), + is_active_campaign: Boolean(world?.is_active_campaign), + is_homepage_featured: Boolean(world?.is_homepage_featured), + campaign_priority: world?.campaign_priority ?? '', is_recurring: Boolean(world?.is_recurring), recurrence_key: world?.recurrence_key || '', recurrence_rule: world?.recurrence_rule || '', @@ -254,12 +411,28 @@ export default function StudioWorldEditor() { cta_label: world?.cta_label || '', cta_url: world?.cta_url || '', badge_label: world?.badge_label || '', + campaign_label: world?.campaign_label || '', badge_description: world?.badge_description || '', submission_guidelines: world?.submission_guidelines || '', badge_url: world?.badge_url || '', seo_title: world?.seo_title || '', seo_description: world?.seo_description || '', og_image_path: world?.og_image_path || '', + recap_status: world?.recap_status || 'draft', + recap_title: world?.recap_title || '', + recap_summary: world?.recap_summary || '', + recap_intro: world?.recap_intro || '', + recap_editor_note: world?.recap_editor_note || '', + recap_cover_path: world?.recap_cover_path || '', + recap_article_id: world?.recap_article_id || '', + linked_challenge_id: world?.linked_challenge_id || '', + show_linked_challenge_section: world?.show_linked_challenge_section !== false, + show_linked_challenge_entries: world?.show_linked_challenge_entries !== false, + show_linked_challenge_winners: world?.show_linked_challenge_winners !== false, + show_linked_challenge_finalists: world?.show_linked_challenge_finalists !== false, + auto_grant_challenge_world_rewards: world?.auto_grant_challenge_world_rewards !== false, + challenge_teaser_override: world?.challenge_teaser_override || '', + hidden_linked_challenge_artwork_ids_json: Array.isArray(world?.hidden_linked_challenge_artwork_ids_json) ? world.hidden_linked_challenge_artwork_ids_json : [], related_tags_json: Array.isArray(world?.related_tags_json) ? world.related_tags_json : [], section_order_json: Array.isArray(world?.section_order_json) && world.section_order_json.length > 0 ? world.section_order_json : sectionOptions.map((option) => option.value), section_visibility_json: initialSectionVisibility(sectionOptions, world?.section_visibility_json), @@ -267,21 +440,31 @@ export default function StudioWorldEditor() { }) const [pickerState, setPickerState] = useState({ open: false, index: null }) + const [linkedChallengePickerOpen, setLinkedChallengePickerOpen] = useState(false) + const [linkedChallengePreview, setLinkedChallengePreview] = useState(world?.linked_challenge || null) + const [recapArticlePickerOpen, setRecapArticlePickerOpen] = useState(false) + const [recapArticlePreview, setRecapArticlePreview] = useState(world?.recap_article || null) const [activeTab, setActiveTab] = useState('basics') const [temporaryMediaPaths, setTemporaryMediaPaths] = useState({ cover: '', + teaser: '', og: '', + recap: '', }) const themeMap = useMemo(() => Object.fromEntries(themeOptions.map((option) => [option.value, option])), [themeOptions]) const sectionMap = useMemo(() => Object.fromEntries(sectionOptions.map((option) => [option.value, option])), [sectionOptions]) const tagString = useMemo(() => (Array.isArray(form.data.related_tags_json) ? form.data.related_tags_json.join(', ') : ''), [form.data.related_tags_json]) + const linkedChallengeEntryPreviewItems = useMemo(() => Array.isArray(linkedChallengePreview?.entry_preview_items) ? linkedChallengePreview.entry_preview_items : [], [linkedChallengePreview]) const selectedTheme = form.data.theme_key ? themeMap[form.data.theme_key] : null const previewWorld = useMemo(() => buildPreviewWorld(form.data, world, themeOptions, typeOptions, filesBaseUrl), [filesBaseUrl, form.data, world, themeOptions, typeOptions]) + const recapCoverPreviewUrl = useMemo(() => resolveMediaUrl(form.data.recap_cover_path, world?.recap_cover_path === form.data.recap_cover_path ? world?.recap_cover_url || '' : '', filesBaseUrl), [filesBaseUrl, form.data.recap_cover_path, world?.recap_cover_path, world?.recap_cover_url]) const relationCounts = useMemo(() => form.data.relations.reduce((counts, relation) => ({ ...counts, [relation.section_key]: (counts[relation.section_key] || 0) + 1 }), {}), [form.data.relations]) const enabledSectionsCount = useMemo(() => Object.values(form.data.section_visibility_json || {}).filter(Boolean).length, [form.data.section_visibility_json]) + const defaultAnalyticsRange = world?.analytics?.default_range || '30d' + const analyticsSummary = world?.analytics?.ranges?.[defaultAnalyticsRange]?.summary || null const previewSections = useMemo(() => { const visibleKeys = (form.data.section_order_json || []).filter((key) => form.data.section_visibility_json?.[key] !== false) @@ -293,6 +476,10 @@ export default function StudioWorldEditor() { })) }, [form.data.section_order_json, form.data.section_visibility_json, form.data.relations, sectionMap]) + const recapStatsSnapshot = world?.recap_stats_snapshot || null + const recapStatsSummary = recapStatsSnapshot?.summary || null + const canPublishRecap = Boolean(world && (world.status === 'archived' || (world.ends_at && new Date(world.ends_at) < new Date()))) + const errorEntries = Object.entries(form.errors || {}) const tabErrorCounts = useMemo(() => WORLD_EDITOR_TABS.reduce((counts, tab) => ({ ...counts, @@ -308,6 +495,11 @@ export default function StudioWorldEditor() { case 'structure': meta = form.data.relations.length > 0 ? `${form.data.relations.length} relation${form.data.relations.length === 1 ? '' : 's'}` : 'No relations yet' break + case 'suggestions': + meta = !world + ? 'Save to unlock' + : `${suggestions?.summary?.available_count || 0} ready · ${suggestions?.summary?.pinned_count || 0} pinned` + break case 'community': meta = form.data.participation_mode === 'closed' ? 'Closed to creators' @@ -321,9 +513,17 @@ export default function StudioWorldEditor() { case 'presentation': meta = selectedTheme?.label || 'Custom identity' break + case 'recap': + meta = form.data.recap_title || recapArticlePreview?.title + ? `${form.data.recap_status === 'published' ? 'Published' : 'Draft'} recap` + : 'Optional archive layer' + break case 'seo': meta = form.data.seo_title || form.data.seo_description ? 'Configured' : 'Optional metadata' break + case 'analytics': + meta = analyticsSummary?.views > 0 ? `${analyticsSummary.views} tracked views` : (world ? 'Ready for measurement' : 'Available after create') + break default: meta = '' } @@ -333,13 +533,16 @@ export default function StudioWorldEditor() { meta, errorCount: tabErrorCounts[tab.id] || 0, } - }), [form.data.participation_mode, form.data.title, form.data.relations.length, form.data.is_recurring, form.data.seo_description, form.data.seo_title, reviewQueue?.counts?.live, reviewQueue?.counts?.pending, selectedTheme?.label, tabErrorCounts]) + }), [analyticsSummary?.views, form.data.participation_mode, form.data.recap_status, form.data.recap_title, form.data.title, form.data.relations.length, form.data.is_recurring, form.data.seo_description, form.data.seo_title, recapArticlePreview?.title, reviewQueue?.counts?.live, reviewQueue?.counts?.pending, selectedTheme?.label, suggestions?.summary?.available_count, suggestions?.summary?.pinned_count, tabErrorCounts, world]) const firstErrorTab = useMemo(() => editorTabs.find((tab) => tab.errorCount > 0) || null, [editorTabs]) const currentTab = editorTabs.find((tab) => tab.id === activeTab) || editorTabs[0] const editingRelation = pickerState.index === null ? buildDefaultRelation(sectionOptions, relationTypeOptions, form.data.relations.length) : form.data.relations[pickerState.index] const [actionConfirm, setActionConfirm] = useState(DEFAULT_ACTION_CONFIRM) const [actionReviewNote, setActionReviewNote] = useState('') + const [actionCopyMode, setActionCopyMode] = useState(DEFAULT_ACTION_CONFIRM.defaultCopyMode) const [actionBusy, setActionBusy] = useState(false) + const [suggestionBusyKey, setSuggestionBusyKey] = useState('') + const [suggestionNotice, setSuggestionNotice] = useState('') useEffect(() => { if (firstErrorTab && firstErrorTab.id !== activeTab) { @@ -370,7 +573,7 @@ export default function StudioWorldEditor() { const options = { onSuccess: () => { - setTemporaryMediaPaths({ cover: '', og: '' }) + setTemporaryMediaPaths({ cover: '', teaser: '', og: '', recap: '' }) }, } @@ -479,12 +682,57 @@ export default function StudioWorldEditor() { if (actionBusy) return setActionConfirm(DEFAULT_ACTION_CONFIRM) setActionReviewNote('') + setActionCopyMode(DEFAULT_ACTION_CONFIRM.defaultCopyMode) + } + + const saveLinkedChallenge = (challenge) => { + setLinkedChallengePreview(challenge) + form.setData({ + ...form.data, + linked_challenge_id: challenge?.id || '', + hidden_linked_challenge_artwork_ids_json: [], + }) + setLinkedChallengePickerOpen(false) + } + + + const saveRecapArticle = (article) => { + setRecapArticlePreview(article) + form.setData({ + ...form.data, + recap_article_id: article?.id || '', + }) + setRecapArticlePickerOpen(false) + } + + const clearRecapArticle = () => { + setRecapArticlePreview(null) + form.setData('recap_article_id', '') + } + const clearLinkedChallenge = () => { + setLinkedChallengePreview(null) + form.setData({ + ...form.data, + linked_challenge_id: '', + challenge_teaser_override: '', + hidden_linked_challenge_artwork_ids_json: [], + }) + } + + const toggleHiddenLinkedChallengeEntry = (artworkId) => { + const currentIds = Array.isArray(form.data.hidden_linked_challenge_artwork_ids_json) ? form.data.hidden_linked_challenge_artwork_ids_json : [] + const nextIds = currentIds.includes(artworkId) + ? currentIds.filter((id) => id !== artworkId) + : [...currentIds, artworkId] + + form.setData('hidden_linked_challenge_artwork_ids_json', nextIds) } const openActionConfirm = (config) => { if (!config?.url) return setActionReviewNote('') + setActionCopyMode(config.defaultCopyMode || DEFAULT_ACTION_CONFIRM.defaultCopyMode) setActionConfirm({ ...DEFAULT_ACTION_CONFIRM, ...config, @@ -495,7 +743,10 @@ export default function StudioWorldEditor() { const confirmAction = () => { if (!actionConfirm.url || actionBusy) return - const payload = actionConfirm.noteEnabled ? { review_note: actionReviewNote } : {} + const payload = { + ...(actionConfirm.noteEnabled ? { review_note: actionReviewNote } : {}), + ...(actionConfirm.copyModeEnabled ? { copy_mode: actionCopyMode } : {}), + } setActionBusy(true) router.post(actionConfirm.url, payload, { @@ -510,7 +761,7 @@ export default function StudioWorldEditor() { }) } - const runDuplicateAction = (url, promptText) => { + const runDuplicateAction = (url, promptText, copyModeOptions = []) => { openActionConfirm({ url, title: 'Duplicate world?', @@ -518,6 +769,9 @@ export default function StudioWorldEditor() { confirmLabel: 'Continue', cancelLabel: 'Cancel', confirmTone: 'accent', + copyModeEnabled: copyModeOptions.length > 0, + copyModeOptions, + defaultCopyMode: copyModeOptions.find((option) => option.value === 'with_relations')?.value || copyModeOptions[0]?.value || 'with_relations', preserveScroll: false, }) } @@ -543,6 +797,82 @@ export default function StudioWorldEditor() { }) } + const postSuggestionAction = async (url, payload) => { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '', + Accept: 'application/json', + }, + credentials: 'same-origin', + body: JSON.stringify(payload), + }) + + const data = await response.json().catch(() => ({})) + + if (!response.ok) { + throw new Error(data?.message || 'Suggestion action failed.') + } + + return data + } + + const refreshSuggestions = () => { + if (!world) return + + router.reload({ + only: ['suggestions'], + preserveScroll: true, + preserveState: true, + }) + } + + const handleSuggestionAction = async (item, action, payload = {}, afterSuccess = null) => { + const url = props.suggestionActions?.[action] + if (!url || !item) return + + setSuggestionBusyKey(item.key) + + try { + const response = await postSuggestionAction(url, { + related_type: item.entity_type, + related_id: item.entity_id, + ...payload, + }) + + if (typeof afterSuccess === 'function') { + afterSuccess(response) + } + + setSuggestionNotice(response?.message || 'Suggestion updated.') + refreshSuggestions() + } catch (error) { + setSuggestionNotice(error?.message || 'Suggestion action failed.') + } finally { + setSuggestionBusyKey('') + } + } + + const addSuggestionRelation = (item, sectionKey, isFeatured) => handleSuggestionAction(item, 'add', { + section_key: sectionKey, + is_featured: isFeatured, + }, (response) => { + if (response?.relation) { + form.setData('relations', upsertRelation(form.data.relations, response.relation)) + } + }) + + const pinSuggestion = (item, sectionKey) => handleSuggestionAction(item, 'pin', { + section_key: sectionKey, + }) + + const dismissSuggestion = (item) => handleSuggestionAction(item, 'dismiss') + + const markSuggestionNotRelevant = (item) => handleSuggestionAction(item, 'notRelevant') + + const restoreSuggestion = (item) => handleSuggestionAction(item, 'restore') + return (
@@ -657,6 +987,62 @@ export default function StudioWorldEditor() { onMove={moveRelation} /> )) :
No curated relations attached yet. Add relations to turn the world into a real campaign hub instead of a static shell.
} + + {activeTab === 'suggestions' ? ( + addSuggestionRelation(item, sectionKey, true)} + onAddSection={(item, sectionKey, isFeatured = false) => addSuggestionRelation(item, sectionKey, isFeatured)} + onPin={pinSuggestion} + onDismiss={dismissSuggestion} + onNotRelevant={markSuggestionNotRelevant} + onRestore={restoreSuggestion} + /> + ) : null} +
+ + + +
+ setLinkedChallengePickerOpen(true)} onClear={clearLinkedChallenge} /> + +
+
+ form.setData('show_linked_challenge_section', event.target.checked)} label="Show challenge panel on the world page" size={20} variant="accent" /> +
+
+ form.setData('show_linked_challenge_entries', event.target.checked)} label="Show linked challenge entries rail" size={20} variant="accent" /> +
+
+ form.setData('show_linked_challenge_winners', event.target.checked)} label="Show linked challenge winner section" size={20} variant="accent" /> +
+
+ form.setData('auto_grant_challenge_world_rewards', event.target.checked)} label="Auto-grant winner and finalist rewards from the linked challenge" size={20} variant="accent" /> +
+
+ +
+ form.setData('show_linked_challenge_finalists', event.target.checked)} label="Show linked challenge finalists when they exist" size={20} variant="accent" /> +
Finalists now come from structured challenge outcomes, so worlds can surface them automatically without waiting for manual recap edits.
+
+ +