Build world campaigns rewards and recaps
This commit is contained in:
60
app/Enums/WorldRewardType.php
Normal file
60
app/Enums/WorldRewardType.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum WorldRewardType: string
|
||||
{
|
||||
case Participant = 'participant';
|
||||
case Featured = 'featured';
|
||||
case Finalist = 'finalist';
|
||||
case Winner = 'winner';
|
||||
case Spotlight = 'spotlight';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Participant => '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,
|
||||
};
|
||||
}
|
||||
}
|
||||
43
app/Http/Controllers/Api/WorldAnalyticsEventController.php
Normal file
43
app/Http/Controllers/Api/WorldAnalyticsEventController.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Worlds\WorldAnalyticsService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class WorldAnalyticsEventController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WorldAnalyticsService $analytics,
|
||||
) {
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
89
app/Models/GroupChallengeOutcome.php
Normal file
89
app/Models/GroupChallengeOutcome.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class GroupChallengeOutcome extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const TYPE_WINNER = 'winner';
|
||||
public const TYPE_FINALIST = 'finalist';
|
||||
public const TYPE_RUNNER_UP = 'runner_up';
|
||||
public const TYPE_HONORABLE_MENTION = 'honorable_mention';
|
||||
public const TYPE_FEATURED = 'featured';
|
||||
|
||||
protected $fillable = [
|
||||
'group_challenge_id',
|
||||
'artwork_id',
|
||||
'user_id',
|
||||
'outcome_type',
|
||||
'position',
|
||||
'sort_order',
|
||||
'title_override',
|
||||
'note',
|
||||
'awarded_by_user_id',
|
||||
'awarded_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'group_challenge_id' => '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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
64
app/Models/WorldAnalyticsEvent.php
Normal file
64
app/Models/WorldAnalyticsEvent.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WorldAnalyticsEvent extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
const CREATED_AT = 'occurred_at';
|
||||
const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'world_id',
|
||||
'event_type',
|
||||
'world_slug',
|
||||
'world_type',
|
||||
'recurrence_key',
|
||||
'edition_year',
|
||||
'section_key',
|
||||
'cta_key',
|
||||
'entity_type',
|
||||
'entity_id',
|
||||
'entity_title',
|
||||
'challenge_id',
|
||||
'source_surface',
|
||||
'source_detail',
|
||||
'viewer_type',
|
||||
'user_id',
|
||||
'visitor_key',
|
||||
'meta',
|
||||
'occurred_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'world_id' => '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);
|
||||
}
|
||||
}
|
||||
45
app/Models/WorldEditorialSuggestionState.php
Normal file
45
app/Models/WorldEditorialSuggestionState.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WorldEditorialSuggestionState extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_PINNED = 'pinned';
|
||||
|
||||
public const STATUS_DISMISSED = 'dismissed';
|
||||
|
||||
public const STATUS_NOT_RELEVANT = 'not_relevant';
|
||||
|
||||
protected $fillable = [
|
||||
'world_id',
|
||||
'related_type',
|
||||
'related_id',
|
||||
'status',
|
||||
'section_key',
|
||||
'acted_by_user_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'related_id' => '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');
|
||||
}
|
||||
}
|
||||
65
app/Models/WorldRewardGrant.php
Normal file
65
app/Models/WorldRewardGrant.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\WorldRewardType;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WorldRewardGrant extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'world_id',
|
||||
'artwork_id',
|
||||
'world_submission_id',
|
||||
'granted_by_user_id',
|
||||
'reward_type',
|
||||
'grant_source',
|
||||
'note',
|
||||
'granted_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => '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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
47
app/Notifications/WorldRewardGrantedNotification.php
Normal file
47
app/Notifications/WorldRewardGrantedNotification.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\WorldRewardGrant;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class WorldRewardGrantedNotification extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(private readonly WorldRewardGrant $grant)
|
||||
{
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
public function databaseType(object $notifiable): string
|
||||
{
|
||||
return 'world_reward_granted';
|
||||
}
|
||||
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
$world = $this->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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
455
app/Services/Profile/WorldProfileHistoryService.php
Normal file
455
app/Services/Profile/WorldProfileHistoryService.php
Normal file
@@ -0,0 +1,455 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Profile;
|
||||
|
||||
use App\Enums\WorldRewardType;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\GroupChallenge;
|
||||
use App\Models\GroupChallengeOutcome;
|
||||
use App\Models\User;
|
||||
use App\Models\World;
|
||||
use App\Models\WorldRelation;
|
||||
use App\Models\WorldRewardGrant;
|
||||
use App\Models\WorldSubmission;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class WorldProfileHistoryService
|
||||
{
|
||||
public function publicPayloadForUser(User $user): array
|
||||
{
|
||||
return $this->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),
|
||||
];
|
||||
}
|
||||
}
|
||||
847
app/Services/Worlds/WorldAnalyticsService.php
Normal file
847
app/Services/Worlds/WorldAnalyticsService.php
Normal file
@@ -0,0 +1,847 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Worlds;
|
||||
|
||||
use App\Enums\WorldRewardType;
|
||||
use App\Models\User;
|
||||
use App\Models\World;
|
||||
use App\Models\WorldAnalyticsEvent;
|
||||
use App\Models\WorldRewardGrant;
|
||||
use App\Models\WorldSubmission;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class WorldAnalyticsService
|
||||
{
|
||||
public const EVENT_SOURCE_IMPRESSION = 'world_source_impression';
|
||||
public const EVENT_VIEWED = 'world_viewed';
|
||||
public const EVENT_SOURCE_CLICKED = 'world_source_clicked';
|
||||
public const EVENT_CTA_CLICKED = 'world_cta_clicked';
|
||||
public const EVENT_SECTION_CLICKED = 'world_section_clicked';
|
||||
public const EVENT_ENTITY_CLICKED = 'world_entity_clicked';
|
||||
public const EVENT_SUBMISSION_STARTED = 'world_submission_started';
|
||||
public const EVENT_SUBMISSION_CREATED = 'world_submission_created';
|
||||
public const EVENT_SUBMISSION_APPROVED = 'world_submission_approved';
|
||||
public const EVENT_SUBMISSION_REMOVED = 'world_submission_removed';
|
||||
public const EVENT_SUBMISSION_BLOCKED = 'world_submission_blocked';
|
||||
public const EVENT_SUBMISSION_FEATURED = 'world_submission_featured';
|
||||
public const EVENT_CHALLENGE_CTA_CLICKED = 'world_challenge_cta_clicked';
|
||||
public const EVENT_REWARD_GRANTED = 'world_reward_granted';
|
||||
|
||||
public const SOURCE_HOMEPAGE_SPOTLIGHT = 'homepage_spotlight';
|
||||
public const SOURCE_HOMEPAGE_WORLDS_RAIL = 'homepage_worlds_rail';
|
||||
public const SOURCE_WORLDS_INDEX = 'worlds_index';
|
||||
public const SOURCE_NAVIGATION = 'navigation';
|
||||
public const SOURCE_UPLOAD_FLOW = 'upload_flow';
|
||||
public const SOURCE_CHALLENGE_PAGE = 'challenge_page';
|
||||
public const SOURCE_NEWS_ARTICLE = 'news_article';
|
||||
public const SOURCE_PROFILE = 'profile';
|
||||
public const SOURCE_DIRECT = 'direct';
|
||||
public const SOURCE_UNKNOWN = 'unknown';
|
||||
|
||||
private const RANGE_WINDOWS = [
|
||||
'7d' => 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, '');
|
||||
}
|
||||
}
|
||||
1421
app/Services/Worlds/WorldEditorialSuggestionService.php
Normal file
1421
app/Services/Worlds/WorldEditorialSuggestionService.php
Normal file
File diff suppressed because it is too large
Load Diff
569
app/Services/Worlds/WorldRewardService.php
Normal file
569
app/Services/Worlds/WorldRewardService.php
Normal file
@@ -0,0 +1,569 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Worlds;
|
||||
|
||||
use App\Enums\WorldRewardType;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\GroupChallenge;
|
||||
use App\Models\GroupChallengeOutcome;
|
||||
use App\Models\User;
|
||||
use App\Models\World;
|
||||
use App\Models\WorldRelation;
|
||||
use App\Models\WorldRewardGrant;
|
||||
use App\Models\WorldSubmission;
|
||||
use App\Notifications\WorldRewardGrantedNotification;
|
||||
use App\Services\Activity\UserActivityService;
|
||||
use App\Services\XPService;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class WorldRewardService
|
||||
{
|
||||
private const CHALLENGE_GRANT_SOURCE = 'challenge';
|
||||
|
||||
public function __construct(
|
||||
private readonly UserActivityService $activities,
|
||||
private readonly XPService $xp,
|
||||
private readonly WorldAnalyticsService $analytics,
|
||||
) {
|
||||
}
|
||||
|
||||
public function syncAutomaticRewardsForSubmission(WorldSubmission $submission): void
|
||||
{
|
||||
$submission->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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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]),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 => [
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('worlds', function (Blueprint $table): void {
|
||||
$table->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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('world_reward_grants', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('worlds', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('worlds', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$afterColumn = Schema::hasColumn('worlds', 'recap_intro')
|
||||
? 'recap_intro'
|
||||
: (Schema::hasColumn('worlds', 'published_at') ? 'published_at' : null);
|
||||
$hasRecapEditorNote = Schema::hasColumn('worlds', 'recap_editor_note');
|
||||
$hasRecapCoverPath = Schema::hasColumn('worlds', 'recap_cover_path');
|
||||
|
||||
Schema::table('worlds', function (Blueprint $table) use ($afterColumn, $hasRecapEditorNote, $hasRecapCoverPath): void {
|
||||
if (! $hasRecapEditorNote) {
|
||||
$column = $table->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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('world_editorial_suggestion_states', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('worlds', function (Blueprint $table): void {
|
||||
$table->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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('group_challenge_outcomes', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('world_analytics_events', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('worlds', function (Blueprint $table): void {
|
||||
$table->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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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),
|
||||
|
||||
@@ -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 className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">{section.label}</h2>
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
|
||||
{items.map((item) => <WorldChallengeArtworkCard key={item.id} item={item} featured={item.outcome_type === 'winner'} />)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(234,179,8,0.15),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
|
||||
@@ -24,6 +45,7 @@ export default function GroupChallengeShow() {
|
||||
{challenge.start_at ? <span>Starts {new Date(challenge.start_at).toLocaleDateString()}</span> : null}
|
||||
{challenge.end_at ? <span>Ends {new Date(challenge.end_at).toLocaleDateString()}</span> : null}
|
||||
</div>
|
||||
<ChallengeWorldLinkBadge world={linkedWorld} className="mt-5" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -45,17 +67,25 @@ export default function GroupChallengeShow() {
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">Entries</h2>
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
|
||||
{Array.isArray(challenge.artworks) && challenge.artworks.length > 0 ? challenge.artworks.map((artwork) => (
|
||||
<a key={artwork.id} href={artwork.url} className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20">
|
||||
{artwork.thumb ? <img src={artwork.thumb} alt={artwork.title} className="aspect-[4/3] w-full object-cover" /> : null}
|
||||
<div className="p-4 text-white">{artwork.title}</div>
|
||||
</a>
|
||||
)) : <p className="text-sm text-slate-400">No entries linked yet.</p>}
|
||||
</div>
|
||||
</section>
|
||||
<div className="space-y-8">
|
||||
<OutcomeSection section={outcomeSections.winner} />
|
||||
<OutcomeSection section={outcomeSections.finalist} />
|
||||
<OutcomeSection section={outcomeSections.runner_up} />
|
||||
<OutcomeSection section={outcomeSections.honorable_mention} />
|
||||
<OutcomeSection section={outcomeSections.featured} />
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">Entries</h2>
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
|
||||
{Array.isArray(challenge.artworks) && challenge.artworks.length > 0 ? challenge.artworks.map((artwork) => (
|
||||
<a key={artwork.id} href={artwork.url} className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20">
|
||||
{artwork.thumb ? <img src={artwork.thumb} alt={artwork.title} className="aspect-[4/3] w-full object-cover" /> : null}
|
||||
<div className="p-4 text-white">{artwork.title}</div>
|
||||
</a>
|
||||
)) : <p className="text-sm text-slate-400">No entries linked yet.</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -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 (
|
||||
<section className="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<a
|
||||
href={world.public_url}
|
||||
className="group relative block overflow-hidden rounded-[32px] border border-white/10 bg-slate-950/70"
|
||||
style={{
|
||||
'--world-accent': world.theme?.accent_color || '#f97316',
|
||||
'--world-accent-secondary': world.theme?.accent_color_secondary || '#0f172a',
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_color-mix(in_srgb,var(--world-accent)_30%,transparent),_transparent_36%),linear-gradient(135deg,_color-mix(in_srgb,var(--world-accent-secondary)_92%,black),_rgba(2,6,23,0.98))]" />
|
||||
{world.cover_url ? <img src={world.cover_url} alt={world.title} className="absolute inset-0 h-full w-full object-cover opacity-20 transition duration-500 group-hover:scale-[1.03]" /> : null}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-slate-950 via-slate-950/82 to-slate-950/35" />
|
||||
|
||||
<div className="relative grid gap-8 px-6 py-7 sm:px-8 lg:grid-cols-[minmax(0,1.2fr)_18rem] lg:items-end lg:px-10">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-white/70">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1">Homepage spotlight</span>
|
||||
{world.badge_label ? <span className="rounded-full border border-white/15 bg-black/25 px-3 py-1">{world.badge_label}</span> : null}
|
||||
</div>
|
||||
<h2 className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white sm:text-4xl">{world.title}</h2>
|
||||
{world.tagline ? <p className="mt-2 text-sm uppercase tracking-[0.18em] text-white/55">{world.tagline}</p> : null}
|
||||
{world.summary ? <p className="mt-4 max-w-3xl text-sm leading-7 text-slate-200/88">{world.summary}</p> : null}
|
||||
<div className="mt-6 inline-flex items-center gap-2 rounded-full bg-white px-4 py-2.5 text-sm font-semibold text-slate-950 transition group-hover:bg-sky-100">
|
||||
{world.cta_label || 'Explore world'}
|
||||
<i className="fa-solid fa-arrow-right" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/12 bg-black/25 p-5 text-white backdrop-blur-sm">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">World Theme</div>
|
||||
<div className="mt-2 flex items-center gap-3 text-lg font-semibold">
|
||||
<i className={world.icon_name || 'fa-solid fa-globe'} />
|
||||
<span>{world.theme?.label || 'Editorial world'}</span>
|
||||
</div>
|
||||
{world.timeframe_label ? <div className="mt-4 text-sm text-slate-300">{world.timeframe_label}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<ActiveWorldSpotlight
|
||||
spotlight={spotlight}
|
||||
secondary={secondary}
|
||||
indexUrl={world.index_url || '/worlds'}
|
||||
eyebrow="Homepage spotlight"
|
||||
secondaryTitle="More live worlds"
|
||||
sourceSurface="homepage_spotlight"
|
||||
sourceDetail="primary"
|
||||
secondarySourceSurface="homepage_worlds_rail"
|
||||
secondarySourceDetail="secondary"
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="relative min-h-screen overflow-hidden pb-16">
|
||||
<>
|
||||
<SeoHead seo={seo} />
|
||||
<div className="relative min-h-screen overflow-hidden pb-16">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[34rem] opacity-90"
|
||||
@@ -208,7 +215,10 @@ export default function ProfileShow() {
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'achievements' && (
|
||||
<TabAchievements achievements={achievements} />
|
||||
<TabAchievements achievements={achievements} worldRewards={worldRewards} worldHistory={worldHistory} onTabChange={handleTabChange} />
|
||||
)}
|
||||
{activeTab === 'worlds' && (
|
||||
<TabWorlds worldHistory={worldHistory} isOwner={isOwner} />
|
||||
)}
|
||||
{activeTab === 'collections' && (
|
||||
<TabCollections
|
||||
@@ -226,6 +236,7 @@ export default function ProfileShow() {
|
||||
profile={profile}
|
||||
stats={stats}
|
||||
achievements={achievements}
|
||||
worldRewards={worldRewards}
|
||||
artworks={artworkList}
|
||||
creatorStories={creatorStories}
|
||||
profileComments={profileComments}
|
||||
@@ -264,6 +275,7 @@ export default function ProfileShow() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,31 +1,14 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import WorldCard from '../../components/worlds/WorldCard'
|
||||
|
||||
function WorldRail({ title, description, items }) {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-10">
|
||||
<div className="mb-5 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">{title}</h2>
|
||||
{description ? <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">{description}</p> : null}
|
||||
</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">{items.length} worlds</div>
|
||||
</div>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{items.map((world) => <WorldCard key={world.id} world={world} compact />)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
import ActiveWorldSpotlight from '../../components/worlds/ActiveWorldSpotlight'
|
||||
import WorldFamilyCard from '../../components/worlds/WorldFamilyCard'
|
||||
import WorldsIndexSection from '../../components/worlds/WorldsIndexSection'
|
||||
|
||||
export default function WorldIndex() {
|
||||
const { props } = usePage()
|
||||
const hasSpotlight = Boolean(props.spotlightWorld)
|
||||
const featuredFallback = !hasSpotlight && Array.isArray(props.featuredWorlds) ? props.featuredWorlds : []
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(249,115,22,0.12),_transparent_28%),radial-gradient(circle_at_top_right,_rgba(56,189,248,0.12),_transparent_32%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
|
||||
@@ -39,24 +22,68 @@ export default function WorldIndex() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{props.featuredWorld ? <section className="mt-8"><WorldCard world={props.featuredWorld} /></section> : null}
|
||||
{hasSpotlight ? (
|
||||
<section className="mt-8">
|
||||
<ActiveWorldSpotlight
|
||||
spotlight={props.spotlightWorld}
|
||||
secondary={Array.isArray(props.featuredWorlds) ? props.featuredWorlds.slice(0, 3) : []}
|
||||
indexUrl="/worlds"
|
||||
eyebrow="Active world spotlight"
|
||||
secondaryTitle="Featured worlds"
|
||||
sourceSurface="worlds_index"
|
||||
sourceDetail="spotlight"
|
||||
secondarySourceSurface="worlds_index"
|
||||
secondarySourceDetail="featured"
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<WorldRail
|
||||
{!hasSpotlight ? (
|
||||
<WorldsIndexSection
|
||||
title="Featured Worlds"
|
||||
description="Editorially promoted worlds stay visible here even when there is no live campaign spotlighting the homepage moment."
|
||||
items={featuredFallback}
|
||||
sourceSurface="worlds_index"
|
||||
sourceDetail="featured"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<WorldsIndexSection
|
||||
title="Active Worlds"
|
||||
description="Campaigns and seasonal surfaces currently live across the platform."
|
||||
description="Live worlds and currently running campaign surfaces across Skinbase Nova."
|
||||
items={props.activeWorlds}
|
||||
emptyMessage="No worlds are currently live. Check upcoming programming below."
|
||||
sourceSurface="worlds_index"
|
||||
sourceDetail="active"
|
||||
/>
|
||||
|
||||
<WorldRail
|
||||
<WorldsIndexSection
|
||||
title="Upcoming Worlds"
|
||||
description="Scheduled worlds in the pipeline, ready to anchor the next publishing moment."
|
||||
description="Scheduled campaign moments and future worlds lined up for the next launch window."
|
||||
items={props.upcomingWorlds}
|
||||
emptyMessage="No upcoming worlds are scheduled right now."
|
||||
sourceSurface="worlds_index"
|
||||
sourceDetail="upcoming"
|
||||
/>
|
||||
|
||||
<WorldRail
|
||||
<WorldsIndexSection
|
||||
title="Recurring Worlds"
|
||||
description="Long-running campaign families keep a canonical current edition while preserving a browsable yearly archive."
|
||||
items={props.recurringWorldFamilies}
|
||||
emptyMessage="Recurring families will appear here as worlds begin building an archive across editions."
|
||||
countLabel="families"
|
||||
renderItem={(family, sourceProps) => <WorldFamilyCard key={family.id} family={family} {...sourceProps} />}
|
||||
sourceSurface="worlds_index"
|
||||
sourceDetail="recurring"
|
||||
/>
|
||||
|
||||
<WorldsIndexSection
|
||||
title="Archive Editions"
|
||||
description="Past worlds stay available as browsable records of recurring culture and editorial programming."
|
||||
items={props.archivedWorlds}
|
||||
emptyMessage="Archived worlds will appear here as campaigns finish and move into the public record."
|
||||
sourceSurface="worlds_index"
|
||||
sourceDetail="archive"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import WorldArchiveNotice from '../../components/worlds/WorldArchiveNotice'
|
||||
import WorldChallengeEntriesRail from '../../components/worlds/WorldChallengeEntriesRail'
|
||||
import WorldChallengeFinalistsGrid from '../../components/worlds/WorldChallengeFinalistsGrid'
|
||||
import WorldChallengePanel from '../../components/worlds/WorldChallengePanel'
|
||||
import WorldChallengeWinnersPanel from '../../components/worlds/WorldChallengeWinnersPanel'
|
||||
import WorldHero from '../../components/worlds/WorldHero'
|
||||
import WorldCommunitySubmissionsSection from '../../components/worlds/WorldCommunitySubmissionsSection'
|
||||
import WorldRecapArticleCard from '../../components/worlds/WorldRecapArticleCard'
|
||||
import WorldRecapCommunityHighlights from '../../components/worlds/WorldRecapCommunityHighlights'
|
||||
import WorldRecapCreatorsPanel from '../../components/worlds/WorldRecapCreatorsPanel'
|
||||
import WorldRecapFeaturedArtworks from '../../components/worlds/WorldRecapFeaturedArtworks'
|
||||
import WorldRecapHero from '../../components/worlds/WorldRecapHero'
|
||||
import WorldRecapStatsGrid from '../../components/worlds/WorldRecapStatsGrid'
|
||||
import WorldRecapSummaryCard from '../../components/worlds/WorldRecapSummaryCard'
|
||||
import WorldSection from '../../components/worlds/WorldSection'
|
||||
import WorldCard from '../../components/worlds/WorldCard'
|
||||
import WorldFamilyCard from '../../components/worlds/WorldFamilyCard'
|
||||
import { resolveWorldLandingSource, trackWorldAnalytics, trackWorldSourceClick, withWorldSource } from '../../lib/worldAnalytics'
|
||||
|
||||
function SupportingRail({ title, description, items }) {
|
||||
function SupportingRail({ title, description, items, sourceDetail = 'navigation_rail' }) {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return null
|
||||
}
|
||||
@@ -20,7 +34,110 @@ function SupportingRail({ title, description, items }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{items.map((item) => <WorldCard key={item.id} world={item} compact />)}
|
||||
{items.map((item) => <WorldCard key={item.id} world={item} compact sourceSurface="navigation" sourceDetail={sourceDetail} />)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function EditionNavigation({ previousEdition, nextEdition }) {
|
||||
if (!previousEdition && !nextEdition) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-10 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="mb-5">
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">Edition Navigation</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">Move through adjacent editions in this recurring world family without losing the archive context.</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<a href={previousEdition ? withWorldSource(previousEdition.public_url, 'navigation', 'previous_edition') : '#'} onClick={() => previousEdition && trackWorldSourceClick({ worldId: previousEdition.id, worldTitle: previousEdition.title, sourceSurface: 'navigation', sourceDetail: 'previous_edition' })} className={`rounded-[24px] border border-white/10 bg-black/20 p-4 ${previousEdition ? 'text-white hover:bg-white/[0.06]' : 'pointer-events-none text-slate-500'}`}>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Previous edition</div>
|
||||
<div className="mt-2 text-lg font-semibold">{previousEdition?.title || 'No earlier edition'}</div>
|
||||
{previousEdition?.edition_year ? <div className="mt-1 text-sm text-slate-300">{previousEdition.edition_year}</div> : null}
|
||||
</a>
|
||||
<a href={nextEdition ? withWorldSource(nextEdition.public_url, 'navigation', 'next_edition') : '#'} onClick={() => nextEdition && trackWorldSourceClick({ worldId: nextEdition.id, worldTitle: nextEdition.title, sourceSurface: 'navigation', sourceDetail: 'next_edition' })} className={`rounded-[24px] border border-white/10 bg-black/20 p-4 ${nextEdition ? 'text-white hover:bg-white/[0.06]' : 'pointer-events-none text-slate-500'}`}>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Next edition</div>
|
||||
<div className="mt-2 text-lg font-semibold">{nextEdition?.title || 'No newer archive edition'}</div>
|
||||
{nextEdition?.edition_year ? <div className="mt-1 text-sm text-slate-300">{nextEdition.edition_year}</div> : null}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function RewardedContributors({ section, world }) {
|
||||
const items = Array.isArray(section?.items) ? section.items : []
|
||||
const counts = section?.counts || {}
|
||||
const summaryChips = [
|
||||
section?.creator_count ? `${section.creator_count} rewarded creators` : null,
|
||||
world?.live_submission_count ? `${world.live_submission_count} live submissions` : null,
|
||||
world?.featured_submission_count ? `${world.featured_submission_count} featured artworks` : null,
|
||||
].filter(Boolean)
|
||||
const rewardTypeChips = [
|
||||
counts.winner ? `${counts.winner} winner${counts.winner === 1 ? '' : 's'}` : null,
|
||||
counts.finalist ? `${counts.finalist} finalist${counts.finalist === 1 ? '' : 's'}` : null,
|
||||
counts.spotlight ? `${counts.spotlight} spotlight` : null,
|
||||
counts.featured ? `${counts.featured} featured` : null,
|
||||
counts.participant ? `${counts.participant} participant` : null,
|
||||
].filter(Boolean)
|
||||
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-10 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="mb-5">
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">Rewarded Contributors</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">Creators who earned visible recognition in this edition. Live participation builds history here, while featured and editorial selections raise the level of recognition.</p>
|
||||
</div>
|
||||
|
||||
{summaryChips.length > 0 || rewardTypeChips.length > 0 || world?.cta_url ? (
|
||||
<div className="mb-5 rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
{summaryChips.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{summaryChips.map((item) => (
|
||||
<span key={item} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-200">{item}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{rewardTypeChips.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{rewardTypeChips.map((item) => (
|
||||
<span key={item} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-sky-100">{item}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{world?.cta_url ? (
|
||||
<div className="mt-4">
|
||||
<a href={world.cta_url} data-world-event="world_cta_clicked" data-world-section-key="rewards" data-world-cta-key="rewards_join_world" className="inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/[0.06] px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white transition hover:bg-white/[0.1]">
|
||||
{world.cta_label || 'Join this world'}
|
||||
<i className="fa-solid fa-arrow-right" />
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-5">
|
||||
<p className="max-w-3xl text-sm leading-6 text-slate-400">This edition’s rewards are edition-aware, so recognition here remains part of each creator’s recurring world history.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<a key={item.id} href={item.creator?.profile_url || item.world?.url || '#'} data-world-event="world_entity_clicked" data-world-section-key="rewards" data-world-entity-type="creator" data-world-entity-id={item.creator?.id || 0} data-world-entity-title={item.creator?.name || item.creator?.username || 'Creator'} className="rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/15 hover:bg-white/[0.06]">
|
||||
<div className="flex items-center gap-3">
|
||||
{item.creator?.avatar_url ? <img src={item.creator.avatar_url} alt={item.creator.username || item.creator.name} className="h-12 w-12 rounded-2xl object-cover ring-1 ring-white/10" /> : <div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-500"><i className="fa-solid fa-user" /></div>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-white">{item.creator?.name || item.creator?.username || 'Creator'}</div>
|
||||
<div className="truncate text-xs uppercase tracking-[0.16em] text-slate-500">{item.badge_label}</div>
|
||||
</div>
|
||||
</div>
|
||||
{item.artwork?.title ? <div className="mt-3 text-sm text-slate-300">{item.artwork.title}</div> : null}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
@@ -29,12 +146,87 @@ function SupportingRail({ title, description, items }) {
|
||||
export default function WorldShow() {
|
||||
const { props } = usePage()
|
||||
const world = props.world
|
||||
const recap = props.recap || null
|
||||
const sections = Array.isArray(props.sections) ? props.sections : []
|
||||
const linkedChallenge = props.linkedChallenge || null
|
||||
const linkedChallengeEntries = props.linkedChallengeEntries || null
|
||||
const linkedChallengeWinners = props.linkedChallengeWinners || null
|
||||
const linkedChallengeFinalists = props.linkedChallengeFinalists || null
|
||||
const communitySubmissions = props.communitySubmissions || null
|
||||
const rewardedContributors = props.rewardedContributors || null
|
||||
const previewMode = Boolean(props.previewMode)
|
||||
const archiveNotice = props.archiveNotice || null
|
||||
const familySummary = props.familySummary || null
|
||||
const currentEdition = props.currentEdition || null
|
||||
const previousEdition = props.previousEdition || null
|
||||
const nextEdition = props.nextEdition || null
|
||||
const archiveTitle = currentEdition ? 'Previous Editions' : 'Archive Editions'
|
||||
const archiveDescription = currentEdition
|
||||
? 'Earlier editions remain public so the recurring family keeps its full history accessible.'
|
||||
: 'Past iterations remain accessible so recurring worlds can build continuity over time.'
|
||||
const rootRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (previewMode || !world?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const landing = resolveWorldLandingSource()
|
||||
|
||||
trackWorldAnalytics('world_viewed', {
|
||||
world_id: world.id,
|
||||
source_surface: landing.sourceSurface,
|
||||
source_detail: landing.sourceDetail,
|
||||
})
|
||||
}, [previewMode, world?.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (previewMode || !world?.id || !rootRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const landing = resolveWorldLandingSource()
|
||||
const container = rootRef.current
|
||||
|
||||
const clickHandler = (event) => {
|
||||
const target = event.target.closest('[data-world-event], [data-world-section-key]')
|
||||
if (!target || !container.contains(target)) {
|
||||
return
|
||||
}
|
||||
|
||||
const sectionKey = target.dataset.worldSectionKey || ''
|
||||
const challengeId = Number(target.dataset.worldChallengeId || 0)
|
||||
const entityId = Number(target.dataset.worldEntityId || 0)
|
||||
const payload = {
|
||||
world_id: world.id,
|
||||
source_surface: landing.sourceSurface,
|
||||
source_detail: landing.sourceDetail,
|
||||
...(sectionKey ? { section_key: sectionKey } : {}),
|
||||
...(target.dataset.worldCtaKey ? { cta_key: target.dataset.worldCtaKey } : {}),
|
||||
...(target.dataset.worldEntityType ? { entity_type: target.dataset.worldEntityType } : {}),
|
||||
...(entityId > 0 ? { entity_id: entityId } : {}),
|
||||
...(target.dataset.worldEntityTitle ? { entity_title: target.dataset.worldEntityTitle } : {}),
|
||||
...(challengeId > 0 ? { challenge_id: challengeId } : {}),
|
||||
}
|
||||
|
||||
if (sectionKey) {
|
||||
trackWorldAnalytics('world_section_clicked', payload)
|
||||
}
|
||||
|
||||
if (target.dataset.worldEvent) {
|
||||
trackWorldAnalytics(target.dataset.worldEvent, payload)
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('click', clickHandler)
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('click', clickHandler)
|
||||
}
|
||||
}, [previewMode, world?.id])
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.12),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
|
||||
<main ref={rootRef} className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.12),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
|
||||
<SeoHead title={props.seo?.title || `${world?.title || 'World'} - Skinbase Nova`} description={props.seo?.description || world?.summary} image={props.seo?.image} />
|
||||
<div className="mx-auto max-w-7xl">
|
||||
{previewMode ? (
|
||||
@@ -49,26 +241,65 @@ export default function WorldShow() {
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<WorldHero world={world} previewMode={previewMode} />
|
||||
<WorldArchiveNotice notice={archiveNotice} />
|
||||
|
||||
{sections.length > 0 ? sections.map((section) => <WorldSection key={section.key} section={section} />) : (
|
||||
<section className="mt-10 rounded-[28px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">
|
||||
This world has been themed and published, but no curated sections have been attached yet.
|
||||
{recap ? <WorldRecapHero world={world} recap={recap} previewMode={previewMode} /> : <WorldHero world={world} previewMode={previewMode} />}
|
||||
|
||||
{recap ? (
|
||||
<>
|
||||
<WorldRecapSummaryCard recap={recap} />
|
||||
<WorldRecapStatsGrid stats={recap.stats} />
|
||||
<WorldRecapArticleCard article={recap.article} />
|
||||
<WorldRecapFeaturedArtworks section={recap.featured_artworks} />
|
||||
<WorldChallengePanel section={linkedChallenge} />
|
||||
</>
|
||||
) : <WorldChallengePanel section={linkedChallenge} />}
|
||||
|
||||
{familySummary ? (
|
||||
<section className="mt-10">
|
||||
<div className="mb-5">
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">Recurring Family</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">Each edition stays public, but the family route always resolves to the canonical current or latest edition.</p>
|
||||
</div>
|
||||
<WorldFamilyCard family={familySummary} sourceSurface="navigation" sourceDetail="family_summary" />
|
||||
</section>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<WorldCommunitySubmissionsSection section={communitySubmissions} />
|
||||
{sections.length > 0 ? sections.map((section) => <WorldSection key={section.key} section={section} />) : null}
|
||||
|
||||
<WorldChallengeEntriesRail section={linkedChallengeEntries} challengeId={linkedChallenge?.id || null} />
|
||||
|
||||
<WorldChallengeWinnersPanel section={linkedChallengeWinners} challengeId={linkedChallenge?.id || null} />
|
||||
|
||||
<WorldChallengeFinalistsGrid panel={linkedChallenge} section={linkedChallengeFinalists} />
|
||||
|
||||
{recap ? <WorldRecapCommunityHighlights section={recap.community_highlights} /> : <RewardedContributors section={rewardedContributors} world={world} />}
|
||||
|
||||
{recap ? <WorldRecapCreatorsPanel section={recap.creators} /> : <WorldCommunitySubmissionsSection section={communitySubmissions} />}
|
||||
|
||||
{currentEdition ? (
|
||||
<SupportingRail
|
||||
title="Current Edition"
|
||||
description="This recurring family has a newer public edition. Use the family route to follow the current canonical page."
|
||||
items={[currentEdition]}
|
||||
sourceDetail="current_edition"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<EditionNavigation previousEdition={previousEdition} nextEdition={nextEdition} />
|
||||
|
||||
<SupportingRail
|
||||
title="Archive Editions"
|
||||
description="Past iterations remain accessible so recurring worlds can build continuity over time."
|
||||
title={archiveTitle}
|
||||
description={archiveDescription}
|
||||
items={props.archiveEditions}
|
||||
sourceDetail="archive_editions"
|
||||
/>
|
||||
|
||||
<SupportingRail
|
||||
title="Related Worlds"
|
||||
description="Other worlds sharing the same recurrence, theme, or editorial lineage."
|
||||
description="Other worlds with adjacent themes, related editorial mood, or connected programming context."
|
||||
items={props.relatedWorlds}
|
||||
sourceDetail="related_worlds"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -8,6 +8,16 @@ function toneClasses(tone) {
|
||||
return 'border-sky-300/25 bg-sky-300/10 text-sky-100 hover:border-sky-300/40 hover:bg-sky-300/15'
|
||||
case 'curated':
|
||||
return 'border-emerald-300/25 bg-emerald-400/10 text-emerald-100 hover:border-emerald-300/40 hover:bg-emerald-400/15'
|
||||
case 'emerald':
|
||||
return 'border-emerald-300/25 bg-emerald-400/10 text-emerald-100 hover:border-emerald-300/40 hover:bg-emerald-400/15'
|
||||
case 'amber':
|
||||
return 'border-amber-300/30 bg-amber-400/12 text-amber-50 hover:border-amber-300/45 hover:bg-amber-400/18'
|
||||
case 'violet':
|
||||
return 'border-violet-300/25 bg-violet-400/10 text-violet-100 hover:border-violet-300/40 hover:bg-violet-400/15'
|
||||
case 'rose':
|
||||
return 'border-rose-300/25 bg-rose-400/10 text-rose-100 hover:border-rose-300/40 hover:bg-rose-400/15'
|
||||
case 'sky':
|
||||
return 'border-sky-300/25 bg-sky-300/10 text-sky-100 hover:border-sky-300/40 hover:bg-sky-300/15'
|
||||
default:
|
||||
return 'border-white/10 bg-white/[0.04] text-white hover:border-white/20 hover:bg-white/[0.07]'
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export const TABS = [
|
||||
{ id: 'artworks', label: 'Artworks', icon: 'fa-images' },
|
||||
{ id: 'stories', label: 'Stories', icon: 'fa-feather-pointed' },
|
||||
{ id: 'achievements', label: 'Achievements', icon: 'fa-trophy' },
|
||||
{ id: 'worlds', label: 'Worlds', icon: 'fa-globe' },
|
||||
{ id: 'collections', label: 'Collections', icon: 'fa-layer-group' },
|
||||
{ id: 'about', label: 'About', icon: 'fa-id-card' },
|
||||
{ id: 'stats', label: 'Stats', icon: 'fa-chart-bar' },
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import React from 'react'
|
||||
import AchievementsList from '../../achievements/AchievementsList'
|
||||
|
||||
export default function TabAchievements({ achievements }) {
|
||||
export default function TabAchievements({ achievements, worldRewards, worldHistory, onTabChange }) {
|
||||
const unlocked = Array.isArray(achievements?.unlocked) ? achievements.unlocked : []
|
||||
const locked = Array.isArray(achievements?.locked) ? achievements.locked : []
|
||||
const historyAvailable = Boolean(worldHistory?.summary?.available)
|
||||
const worldAppearances = worldHistory?.summary?.world_appearances || worldHistory?.summary?.worlds_joined || worldRewards?.count || 0
|
||||
const featuredCount = worldHistory?.summary?.featured_appearances || 0
|
||||
const winnerFinalistCount = worldHistory?.summary?.finalist_winner_appearances || 0
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -30,6 +34,45 @@ export default function TabAchievements({ achievements }) {
|
||||
</div>
|
||||
|
||||
<AchievementsList unlocked={unlocked} locked={locked} />
|
||||
|
||||
{historyAvailable ? (
|
||||
<section className="mt-8">
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_18px_44px_rgba(2,6,23,0.18)] md:p-6">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Worlds history</h3>
|
||||
<p className="mt-2 text-sm text-slate-300">
|
||||
World participation and recognition now live in a dedicated history view so recurring editions, challenge results, and standout placements read like a creator story instead of a badge dump.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">World Appearances</div>
|
||||
<div className="mt-2 text-2xl font-bold tracking-tight text-white">{worldAppearances}</div>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Featured</div>
|
||||
<div className="mt-2 text-2xl font-bold tracking-tight text-white">{featuredCount}</div>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Wins / Finalists</div>
|
||||
<div className="mt-2 text-2xl font-bold tracking-tight text-white">{winnerFinalistCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange?.('worlds')}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-medium text-slate-100 transition-colors hover:bg-white/[0.08]"
|
||||
>
|
||||
<i className="fa-solid fa-globe text-xs" />
|
||||
Open worlds history
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
15
resources/js/components/profile/tabs/TabWorlds.jsx
Normal file
15
resources/js/components/profile/tabs/TabWorlds.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react'
|
||||
import ProfileWorldHistorySection from '../worlds/ProfileWorldHistorySection'
|
||||
|
||||
export default function TabWorlds({ worldHistory, isOwner }) {
|
||||
return (
|
||||
<div
|
||||
id="tabpanel-worlds"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-worlds"
|
||||
className="pt-6"
|
||||
>
|
||||
<ProfileWorldHistorySection history={worldHistory} isOwner={isOwner} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import React from 'react'
|
||||
import ProfileWorldRecognitionBadge from './ProfileWorldRecognitionBadge'
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return null
|
||||
|
||||
try {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default function ProfileWorldHistoryCard({ entry }) {
|
||||
const recognitionBadges = Array.isArray(entry?.recognitions) ? entry.recognitions : []
|
||||
const artwork = entry?.linked_artwork
|
||||
const challenge = entry?.challenge
|
||||
|
||||
return (
|
||||
<article className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_18px_48px_rgba(2,6,23,0.18)] transition-colors hover:border-white/15 hover:bg-white/[0.055] md:p-6">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-start">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{recognitionBadges.map((recognition, index) => (
|
||||
<ProfileWorldRecognitionBadge key={`${entry.id}-${recognition.key}`} recognition={recognition} isPrimary={index === 0} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-baseline gap-x-3 gap-y-2">
|
||||
<h3 className="text-xl font-semibold tracking-[-0.02em] text-white">{entry?.world?.title}</h3>
|
||||
{entry?.world?.edition_year ? (
|
||||
<span className="text-sm text-slate-400">Edition {entry.world.edition_year}</span>
|
||||
) : null}
|
||||
{entry?.world?.type_label ? (
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-1 text-[11px] font-medium text-slate-300">{entry.world.type_label}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-slate-400">
|
||||
{entry?.world?.family_label ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<i className="fa-solid fa-layer-group text-[11px] text-slate-500" />
|
||||
{entry.world.family_label}
|
||||
</span>
|
||||
) : null}
|
||||
{formatDate(entry?.occurred_at) ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<i className="fa-regular fa-calendar text-[11px] text-slate-500" />
|
||||
{formatDate(entry.occurred_at)}
|
||||
</span>
|
||||
) : null}
|
||||
{challenge?.title ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<i className="fa-solid fa-flag-checkered text-[11px] text-slate-500" />
|
||||
{challenge.title}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
{entry?.world?.url ? (
|
||||
<a
|
||||
href={entry.world.url}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-medium text-slate-100 transition-colors hover:bg-white/[0.08]"
|
||||
>
|
||||
<i className="fa-solid fa-globe text-xs" />
|
||||
View world
|
||||
</a>
|
||||
) : null}
|
||||
{artwork?.url ? (
|
||||
<a
|
||||
href={artwork.url}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-medium text-slate-100 transition-colors hover:bg-white/[0.08]"
|
||||
>
|
||||
<i className="fa-solid fa-image text-xs" />
|
||||
View artwork
|
||||
</a>
|
||||
) : null}
|
||||
{challenge?.url ? (
|
||||
<a
|
||||
href={challenge.url}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-medium text-slate-100 transition-colors hover:bg-white/[0.08]"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-up-right-from-square text-xs" />
|
||||
View challenge
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full shrink-0 lg:w-44">
|
||||
{artwork?.thumbnail_url ? (
|
||||
<a href={artwork.url || '#'} className="group block overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.03]">
|
||||
<div className="aspect-[4/3] overflow-hidden bg-slate-900/60">
|
||||
<img
|
||||
src={artwork.thumbnail_url}
|
||||
alt={artwork.title || entry?.world?.title || 'World artwork'}
|
||||
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-white/10 px-4 py-3">
|
||||
<div className="truncate text-sm font-medium text-white">{artwork.title}</div>
|
||||
<div className="mt-1 text-xs text-slate-400">Linked artwork</div>
|
||||
</div>
|
||||
</a>
|
||||
) : (
|
||||
<div className="flex aspect-[4/3] items-center justify-center rounded-[24px] border border-dashed border-white/12 bg-white/[0.02] text-slate-500">
|
||||
<div className="text-center">
|
||||
<i className="fa-solid fa-globe text-xl" />
|
||||
<div className="mt-2 text-xs uppercase tracking-[0.18em]">World entry</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import React from 'react'
|
||||
import ProfileWorldHistorySummary from './ProfileWorldHistorySummary'
|
||||
import ProfileWorldTimelineList from './ProfileWorldTimelineList'
|
||||
|
||||
function EmptyState({ isOwner, hasPrivateContext }) {
|
||||
return (
|
||||
<div className="rounded-[30px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-16 text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl border border-white/10 bg-white/[0.05] text-slate-500">
|
||||
<i className="fa-solid fa-globe text-2xl" />
|
||||
</div>
|
||||
<h3 className="mt-5 text-xl font-semibold text-white">No public worlds timeline yet</h3>
|
||||
<p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-slate-400">
|
||||
{isOwner
|
||||
? (hasPrivateContext
|
||||
? 'Public world history appears here once a live, publicly visible submission or recognized placement is available. Until then, private-only world activity is still tracked for you above.'
|
||||
: 'Public world history appears here once a live, publicly visible submission or recognized placement is available.')
|
||||
: 'This creator does not have any public world participation or recognition to show yet.'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OwnerNote({ ownerContext }) {
|
||||
if (!ownerContext) {
|
||||
return null
|
||||
}
|
||||
|
||||
const details = [
|
||||
ownerContext.pending_submissions ? `${ownerContext.pending_submissions} pending submission${ownerContext.pending_submissions === 1 ? '' : 's'}` : null,
|
||||
ownerContext.removed_or_blocked_submissions ? `${ownerContext.removed_or_blocked_submissions} removed or blocked item${ownerContext.removed_or_blocked_submissions === 1 ? '' : 's'}` : null,
|
||||
ownerContext.hidden_public_entries ? `${ownerContext.hidden_public_entries} recognition${ownerContext.hidden_public_entries === 1 ? '' : 's'} hidden from public view` : null,
|
||||
].filter(Boolean)
|
||||
|
||||
if (details.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-[24px] border border-sky-300/15 bg-sky-400/10 px-5 py-4 text-sm text-sky-100">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl border border-sky-300/20 bg-sky-300/10 text-sky-100">
|
||||
<i className="fa-solid fa-eye text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/80">Private View</div>
|
||||
<p className="mt-1 leading-relaxed text-sky-50/90">
|
||||
Your public worlds timeline stays strict about visibility, but this profile still tracks {details.join(', ')}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProfileWorldHistorySection({ history, isOwner }) {
|
||||
const entries = Array.isArray(history?.entries) ? history.entries : []
|
||||
const highlights = Array.isArray(history?.highlights) ? history.highlights : []
|
||||
const highlightIds = new Set(highlights.map((entry) => entry?.id).filter(Boolean))
|
||||
const timelineEntries = entries.filter((entry) => !highlightIds.has(entry?.id))
|
||||
const hasPrivateContext = Boolean(
|
||||
history?.owner_context?.pending_submissions
|
||||
|| history?.owner_context?.removed_or_blocked_submissions
|
||||
|| history?.owner_context?.hidden_public_entries
|
||||
)
|
||||
const hasEntries = Boolean(history?.summary?.available) && entries.length > 0
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Worlds History</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.02em] text-white md:text-3xl">Recurring worlds, challenge outcomes, and standout editions</h2>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-slate-400">
|
||||
This timeline pulls together edition-aware world participation, featured placements, finalists, winners, and linked challenge results into one creator-facing history layer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OwnerNote ownerContext={isOwner ? history?.owner_context : null} />
|
||||
|
||||
{hasEntries ? (
|
||||
<>
|
||||
<ProfileWorldHistorySummary history={history} />
|
||||
|
||||
{highlights.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Highlights</div>
|
||||
<p className="mt-2 text-sm text-slate-400">
|
||||
The most recent and highest-signal world appearances surface first so recurring recognition reads like a creator recap, not just a raw list.
|
||||
</p>
|
||||
</div>
|
||||
<ProfileWorldTimelineList entries={highlights} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{timelineEntries.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Full Timeline</div>
|
||||
<p className="mt-2 text-sm text-slate-400">
|
||||
Every public world appearance, challenge-linked outcome, and edition-aware placement remains visible here in chronological order.
|
||||
</p>
|
||||
</div>
|
||||
<ProfileWorldTimelineList entries={timelineEntries} />
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState isOwner={isOwner} hasPrivateContext={hasPrivateContext} />
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react'
|
||||
import ProfileWorldStatsRow from './ProfileWorldStatsRow'
|
||||
import ProfileWorldRecognitionBadge from './ProfileWorldRecognitionBadge'
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return null
|
||||
|
||||
try {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default function ProfileWorldHistorySummary({ history }) {
|
||||
const summary = history?.summary || {}
|
||||
const recent = summary?.most_recent_world_activity
|
||||
const recentRecognition = recent?.primary_recognition || (recent?.recognition_label
|
||||
? { key: String(recent.recognition_label).toLowerCase().replace(/\s+/g, '_'), label: recent.recognition_label, tone: 'sky' }
|
||||
: null)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ProfileWorldStatsRow summary={summary} />
|
||||
|
||||
{recent ? (
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_18px_46px_rgba(2,6,23,0.18)] md:p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Most Recent World Activity</div>
|
||||
<div className="mt-2 text-xl font-semibold tracking-[-0.02em] text-white">{recent.world_title}</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{recentRecognition ? <ProfileWorldRecognitionBadge recognition={recentRecognition} isPrimary /> : null}
|
||||
{formatDate(recent.occurred_at) ? <span className="text-xs text-slate-400">{formatDate(recent.occurred_at)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
{recent.world_url ? (
|
||||
<a
|
||||
href={recent.world_url}
|
||||
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-medium text-slate-100 transition-colors hover:bg-white/[0.08]"
|
||||
>
|
||||
<i className="fa-solid fa-arrow-up-right-from-square text-xs" />
|
||||
View world
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react'
|
||||
|
||||
function toneClasses(tone, isPrimary) {
|
||||
const styles = {
|
||||
emerald: 'border-emerald-300/20 bg-emerald-400/12 text-emerald-100',
|
||||
violet: 'border-violet-300/20 bg-violet-400/12 text-violet-100',
|
||||
amber: 'border-amber-300/20 bg-amber-400/12 text-amber-100',
|
||||
rose: 'border-rose-300/20 bg-rose-400/12 text-rose-100',
|
||||
sky: 'border-sky-300/20 bg-sky-400/12 text-sky-100',
|
||||
slate: 'border-slate-300/15 bg-slate-300/10 text-slate-200',
|
||||
}
|
||||
|
||||
const base = styles[tone] || styles.sky
|
||||
return `${base} ${isPrimary ? 'shadow-[0_0_22px_rgba(255,255,255,0.05)]' : ''}`.trim()
|
||||
}
|
||||
|
||||
export default function ProfileWorldRecognitionBadge({ recognition, isPrimary = false }) {
|
||||
if (!recognition) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] ${toneClasses(recognition.tone, isPrimary)}`}>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-current opacity-80" />
|
||||
{recognition.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react'
|
||||
|
||||
const TONE_STYLES = {
|
||||
sky: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
|
||||
amber: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
|
||||
emerald: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
|
||||
violet: 'border-violet-300/20 bg-violet-400/10 text-violet-100',
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value, tone, hint }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4 shadow-[0_18px_42px_rgba(2,6,23,0.18)]">
|
||||
<div className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl border ${TONE_STYLES[tone] || TONE_STYLES.sky}`}>
|
||||
<i className={`fa-solid ${icon} text-sm`} />
|
||||
</div>
|
||||
<div className="mt-4 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</div>
|
||||
<div className="mt-1 text-2xl font-bold tracking-tight text-white">{value}</div>
|
||||
{hint ? <div className="mt-2 text-xs leading-relaxed text-slate-400">{hint}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProfileWorldStatsRow({ summary }) {
|
||||
const worldAppearances = summary?.world_appearances || summary?.worlds_joined || 0
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
icon="fa-globe"
|
||||
label="World Appearances"
|
||||
value={worldAppearances}
|
||||
tone="sky"
|
||||
hint={summary?.active_year_span?.label ? `Active across ${summary.active_year_span.label}` : 'Edition-aware creator history'}
|
||||
/>
|
||||
<StatCard
|
||||
icon="fa-stars"
|
||||
label="Featured"
|
||||
value={summary?.featured_appearances || 0}
|
||||
tone="amber"
|
||||
hint="Editorial features and highlighted placements"
|
||||
/>
|
||||
<StatCard
|
||||
icon="fa-trophy"
|
||||
label="Wins / Finalists"
|
||||
value={summary?.finalist_winner_appearances || 0}
|
||||
tone="emerald"
|
||||
hint="Higher-tier placements tied to world-linked challenges"
|
||||
/>
|
||||
<StatCard
|
||||
icon="fa-bolt"
|
||||
label="Spotlights"
|
||||
value={summary?.spotlight_appearances || 0}
|
||||
tone="violet"
|
||||
hint="Editorial spotlight moments across editions"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
import ProfileWorldHistoryCard from './ProfileWorldHistoryCard'
|
||||
|
||||
export default function ProfileWorldTimelineList({ entries }) {
|
||||
const items = Array.isArray(entries) ? entries : []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{items.map((entry) => (
|
||||
<ProfileWorldHistoryCard key={entry.id} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
151
resources/js/components/worlds/ActiveWorldSpotlight.jsx
Normal file
151
resources/js/components/worlds/ActiveWorldSpotlight.jsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import WorldCampaignMeta from './WorldCampaignMeta'
|
||||
import WorldCard from './WorldCard'
|
||||
import WorldStatusBadge from './WorldStatusBadge'
|
||||
import { trackWorldSourceClick, trackWorldSourceImpression, withWorldSource } from '../../lib/worldAnalytics'
|
||||
|
||||
export default function ActiveWorldSpotlight({
|
||||
spotlight,
|
||||
secondary = [],
|
||||
indexUrl = '/worlds',
|
||||
eyebrow = 'World spotlight',
|
||||
secondaryTitle = 'Campaign rail',
|
||||
className = '',
|
||||
sourceSurface = '',
|
||||
sourceDetail = '',
|
||||
secondarySourceSurface = '',
|
||||
secondarySourceDetail = '',
|
||||
}) {
|
||||
const spotlightRef = useRef(null)
|
||||
|
||||
const primaryHref = spotlight && sourceSurface ? withWorldSource(spotlight.public_url || spotlight.cta_url, sourceSurface, sourceDetail) : (spotlight?.public_url || spotlight?.cta_url)
|
||||
|
||||
useEffect(() => {
|
||||
if (!spotlight?.id || !sourceSurface || typeof window === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const node = spotlightRef.current
|
||||
if (!node) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (typeof window.IntersectionObserver !== 'function') {
|
||||
trackWorldSourceImpression({
|
||||
worldId: spotlight.id,
|
||||
worldTitle: spotlight.title || spotlight.headline,
|
||||
sourceSurface,
|
||||
sourceDetail,
|
||||
sectionKey: 'spotlight',
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
|
||||
const observer = new window.IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting || entry.intersectionRatio < 0.45) {
|
||||
return
|
||||
}
|
||||
|
||||
trackWorldSourceImpression({
|
||||
worldId: spotlight.id,
|
||||
worldTitle: spotlight.title || spotlight.headline,
|
||||
sourceSurface,
|
||||
sourceDetail,
|
||||
sectionKey: 'spotlight',
|
||||
})
|
||||
observer.disconnect()
|
||||
})
|
||||
}, { threshold: [0.45] })
|
||||
|
||||
observer.observe(node)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [spotlight?.headline, spotlight?.id, spotlight?.title, sourceDetail, sourceSurface])
|
||||
|
||||
if (!spotlight) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={className}>
|
||||
<div
|
||||
ref={spotlightRef}
|
||||
className="group relative overflow-hidden rounded-[32px] border border-white/10 bg-slate-950/70"
|
||||
style={{
|
||||
'--world-accent': spotlight.theme?.accent_color || '#f97316',
|
||||
'--world-accent-secondary': spotlight.theme?.accent_color_secondary || '#0f172a',
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_color-mix(in_srgb,var(--world-accent)_30%,transparent),_transparent_36%),linear-gradient(135deg,_color-mix(in_srgb,var(--world-accent-secondary)_92%,black),_rgba(2,6,23,0.98))]" />
|
||||
{spotlight.cover_url ? <img src={spotlight.cover_url} alt={spotlight.title || spotlight.headline} className="absolute inset-0 h-full w-full object-cover opacity-20 transition duration-500 group-hover:scale-[1.03]" /> : null}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-slate-950 via-slate-950/84 to-slate-950/28" />
|
||||
|
||||
<div className="relative grid gap-8 px-6 py-7 sm:px-8 lg:grid-cols-[minmax(0,1.2fr)_20rem] lg:px-10">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-white/70">{eyebrow}</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{(Array.isArray(spotlight.status_badges) ? spotlight.status_badges : []).map((badge) => (
|
||||
<WorldStatusBadge key={badge.label} badge={badge} />
|
||||
))}
|
||||
{spotlight.campaign_label ? <WorldStatusBadge badge={{ label: spotlight.campaign_label, tone: 'slate' }} /> : null}
|
||||
</div>
|
||||
{spotlight.title && spotlight.headline && spotlight.title !== spotlight.headline ? <p className="mt-5 text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-100/70">{spotlight.title}</p> : null}
|
||||
<h2 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white sm:text-4xl">{spotlight.headline || spotlight.title}</h2>
|
||||
{spotlight.tagline ? <p className="mt-2 text-sm uppercase tracking-[0.18em] text-white/55">{spotlight.tagline}</p> : null}
|
||||
{spotlight.summary ? <p className="mt-4 max-w-3xl text-sm leading-7 text-slate-200/88">{spotlight.summary}</p> : null}
|
||||
|
||||
<WorldCampaignMeta world={spotlight} className="mt-6" />
|
||||
|
||||
{spotlight.supporting_item ? (
|
||||
<a href={spotlight.supporting_item.url} className="mt-6 inline-flex max-w-xl items-center gap-3 rounded-[22px] border border-white/12 bg-black/25 px-4 py-3 text-left text-sm text-slate-100 transition hover:border-white/20 hover:bg-white/[0.06]">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">{spotlight.supporting_item.entity_label || 'Related item'}</div>
|
||||
<div className="mt-1 truncate font-semibold text-white">{spotlight.supporting_item.title}</div>
|
||||
{spotlight.supporting_item.context_label ? <div className="mt-1 text-xs text-slate-300/80">{spotlight.supporting_item.context_label}</div> : null}
|
||||
</div>
|
||||
<i className="fa-solid fa-arrow-right shrink-0 text-xs text-sky-100" />
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<a href={primaryHref} onClick={() => trackWorldSourceClick({ worldId: spotlight.id, worldTitle: spotlight.title || spotlight.headline, sourceSurface, sourceDetail })} className="inline-flex items-center gap-2 rounded-full bg-white px-4 py-2.5 text-sm font-semibold text-slate-950 transition group-hover:bg-sky-100">
|
||||
{spotlight.cta_label || 'Explore world'}
|
||||
<i className="fa-solid fa-arrow-right" />
|
||||
</a>
|
||||
<a href={indexUrl} className="inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
|
||||
Browse all worlds
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/12 bg-black/25 p-5 text-white backdrop-blur-sm">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Campaign state</div>
|
||||
<div className="mt-3 flex items-center gap-3 text-lg font-semibold">
|
||||
<i className={spotlight.icon_name || 'fa-solid fa-globe'} />
|
||||
<span>{spotlight.theme?.label || 'Editorial world'}</span>
|
||||
</div>
|
||||
{spotlight.timeframe_label ? <div className="mt-4 text-sm text-slate-300">{spotlight.timeframe_label}</div> : null}
|
||||
{spotlight.promotion_window_label ? <div className="mt-2 text-sm text-slate-400">{spotlight.promotion_window_label}</div> : null}
|
||||
{Number(spotlight.live_submission_count || 0) > 0 ? <div className="mt-5 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-100">{spotlight.live_submission_count} live submissions are already part of this campaign.</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Array.isArray(secondary) && secondary.length > 0 ? (
|
||||
<div className="mt-6">
|
||||
<div className="mb-4 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold tracking-[-0.03em] text-white">{secondaryTitle}</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-400">More live or upcoming worlds that are being actively surfaced right now.</p>
|
||||
</div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{secondary.length} worlds</div>
|
||||
</div>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{secondary.map((world) => <WorldCard key={world.id} world={world} compact sourceSurface={secondarySourceSurface || sourceSurface} sourceDetail={secondarySourceDetail || sourceDetail} />)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
36
resources/js/components/worlds/ChallengeWorldLinkBadge.jsx
Normal file
36
resources/js/components/worlds/ChallengeWorldLinkBadge.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react'
|
||||
import WorldStatusBadge from './WorldStatusBadge'
|
||||
import { trackWorldSourceClick, withWorldSource } from '../../lib/worldAnalytics'
|
||||
|
||||
export default function ChallengeWorldLinkBadge({ world, className = '' }) {
|
||||
if (!world?.public_url || !world?.title) {
|
||||
return null
|
||||
}
|
||||
|
||||
const badges = Array.isArray(world.status_badges) ? world.status_badges.filter((badge) => badge?.label).slice(0, 3) : []
|
||||
const metaItems = [
|
||||
world.campaign_label,
|
||||
world.timeframe_label,
|
||||
Number(world.live_submission_count || 0) > 0 ? `${world.live_submission_count} live submissions` : null,
|
||||
].filter(Boolean)
|
||||
const worldHref = withWorldSource(world.public_url, 'challenge_page', 'linked_world')
|
||||
|
||||
return (
|
||||
<section className={`rounded-[26px] border border-sky-300/20 bg-[linear-gradient(135deg,_rgba(56,189,248,0.14),_rgba(15,23,42,0.92))] p-5 text-white ${className}`.trim()}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100/80">Linked world</div>
|
||||
{badges.length > 0 ? <div className="flex flex-wrap gap-2">{badges.map((badge) => <WorldStatusBadge key={`${badge.label}-${badge.tone || 'slate'}`} badge={badge} />)}</div> : null}
|
||||
</div>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-white">Continue in {world.title}</h2>
|
||||
{world.summary ? <p className="mt-3 max-w-2xl text-sm leading-7 text-slate-200">{world.summary}</p> : null}
|
||||
{metaItems.length > 0 ? <div className="mt-4 flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">{metaItems.map((item) => <span key={item}>{item}</span>)}</div> : null}
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<a href={worldHref} onClick={() => trackWorldSourceClick({ worldId: world.id, worldTitle: world.title, sourceSurface: 'challenge_page', sourceDetail: 'linked_world' })} className="inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-100">
|
||||
Open world
|
||||
<i className="fa-solid fa-arrow-right" />
|
||||
</a>
|
||||
{world.challenge_cta_url ? <a href={world.challenge_cta_url} className="inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.08]">{world.challenge_cta_label || 'Challenge update'}</a> : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
76
resources/js/components/worlds/UploadWorldHighlightCard.jsx
Normal file
76
resources/js/components/worlds/UploadWorldHighlightCard.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import WorldCampaignMeta from './WorldCampaignMeta'
|
||||
import WorldStatusBadge from './WorldStatusBadge'
|
||||
import { trackWorldSourceImpression } from '../../lib/worldAnalytics'
|
||||
|
||||
export default function UploadWorldHighlightCard({ world, sourceSurface = '', sourceDetail = '' }) {
|
||||
const cardRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourceSurface || !world?.id || typeof window === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const node = cardRef.current
|
||||
if (!node) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (typeof window.IntersectionObserver !== 'function') {
|
||||
trackWorldSourceImpression({
|
||||
worldId: world.id,
|
||||
worldTitle: world.title,
|
||||
sourceSurface,
|
||||
sourceDetail,
|
||||
sectionKey: 'upload_highlight',
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
|
||||
const observer = new window.IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting || entry.intersectionRatio < 0.5) {
|
||||
return
|
||||
}
|
||||
|
||||
trackWorldSourceImpression({
|
||||
worldId: world.id,
|
||||
worldTitle: world.title,
|
||||
sourceSurface,
|
||||
sourceDetail,
|
||||
sectionKey: 'upload_highlight',
|
||||
})
|
||||
observer.disconnect()
|
||||
})
|
||||
}, { threshold: [0.5] })
|
||||
|
||||
observer.observe(node)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [sourceDetail, sourceSurface, world?.id, world?.title])
|
||||
|
||||
if (!world) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={cardRef} className="overflow-hidden rounded-[24px] border border-emerald-300/20 bg-[linear-gradient(135deg,rgba(16,185,129,0.14),rgba(15,23,42,0.84))] p-5">
|
||||
<div className="grid gap-4 md:grid-cols-[9rem_minmax(0,1fr)] md:items-center">
|
||||
<div className="h-28 overflow-hidden rounded-[20px] border border-white/12 bg-slate-950/80">
|
||||
{(world.teaser_image_url || world.cover_url) ? <img src={world.teaser_image_url || world.cover_url} alt={world.title} className="h-full w-full object-cover" /> : <div className="flex h-full items-center justify-center text-slate-500"><i className="fa-solid fa-globe" /></div>}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-100/80">Upload spotlight</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{(Array.isArray(world.status_badges) ? world.status_badges : []).map((badge) => <WorldStatusBadge key={badge.label} badge={badge} />)}
|
||||
{world.campaign_label ? <WorldStatusBadge badge={{ label: world.campaign_label, tone: 'slate' }} /> : null}
|
||||
</div>
|
||||
<h3 className="mt-4 text-xl font-semibold tracking-[-0.03em] text-white">{world.teaser_title || world.title}</h3>
|
||||
{world.teaser_title && world.teaser_title !== world.title ? <div className="mt-1 text-xs uppercase tracking-[0.16em] text-white/55">{world.title}</div> : null}
|
||||
{(world.teaser_summary || world.summary) ? <p className="mt-3 text-sm leading-6 text-slate-200/85">{world.teaser_summary || world.summary}</p> : null}
|
||||
<WorldCampaignMeta world={world} className="mt-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
resources/js/components/worlds/WorldArchiveNotice.jsx
Normal file
27
resources/js/components/worlds/WorldArchiveNotice.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function WorldArchiveNotice({ notice }) {
|
||||
if (!notice) {
|
||||
return null
|
||||
}
|
||||
|
||||
const currentEdition = notice.current_edition || null
|
||||
|
||||
return (
|
||||
<section className="mb-6 rounded-[28px] border border-amber-300/20 bg-amber-400/10 px-5 py-4 text-sm text-amber-50">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="max-w-3xl">
|
||||
{notice.eyebrow ? <div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100/75">{notice.eyebrow}</div> : null}
|
||||
{notice.title ? <div className="mt-1 text-base font-semibold text-white">{notice.title}</div> : null}
|
||||
{notice.description ? <p className="mt-2 leading-6 text-amber-50/85">{notice.description}</p> : null}
|
||||
</div>
|
||||
{currentEdition?.public_url ? (
|
||||
<a href={currentEdition.public_url} className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white">
|
||||
Open current edition
|
||||
<i className="fa-solid fa-arrow-right" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
28
resources/js/components/worlds/WorldCampaignMeta.jsx
Normal file
28
resources/js/components/worlds/WorldCampaignMeta.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react'
|
||||
|
||||
function metaItems(world) {
|
||||
return [
|
||||
world?.promotion_window_label || world?.timeframe_label,
|
||||
Number(world?.live_submission_count || 0) > 0 ? `${Number(world.live_submission_count)} live submissions` : null,
|
||||
Number(world?.relation_count || 0) > 0 ? `${Number(world.relation_count)} curated links` : null,
|
||||
world?.theme?.label || world?.type || null,
|
||||
].filter(Boolean)
|
||||
}
|
||||
|
||||
export default function WorldCampaignMeta({ world, className = '' }) {
|
||||
const items = metaItems(world)
|
||||
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap gap-2 text-xs text-slate-200/75 ${className}`.trim()}>
|
||||
{items.map((item) => (
|
||||
<span key={item} className="rounded-full border border-white/12 bg-black/25 px-3 py-1.5">
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import WorldCampaignMeta from './WorldCampaignMeta'
|
||||
import WorldStatusBadge from './WorldStatusBadge'
|
||||
import { trackWorldSourceClick, trackWorldSourceImpression, withWorldSource } from '../../lib/worldAnalytics'
|
||||
|
||||
function themeStyle(theme) {
|
||||
return {
|
||||
@@ -7,14 +10,62 @@ function themeStyle(theme) {
|
||||
}
|
||||
}
|
||||
|
||||
export default function WorldCard({ world, compact = false }) {
|
||||
export default function WorldCard({ world, compact = false, sourceSurface = '', sourceDetail = '' }) {
|
||||
const cardRef = useRef(null)
|
||||
const href = world && sourceSurface ? withWorldSource(world.public_url, sourceSurface, sourceDetail) : world?.public_url
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourceSurface || !world?.id || typeof window === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const node = cardRef.current
|
||||
if (!node) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (typeof window.IntersectionObserver !== 'function') {
|
||||
trackWorldSourceImpression({
|
||||
worldId: world.id,
|
||||
worldTitle: world.title,
|
||||
sourceSurface,
|
||||
sourceDetail,
|
||||
sectionKey: compact ? 'compact_card' : 'card',
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
|
||||
const observer = new window.IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting || entry.intersectionRatio < 0.4) {
|
||||
return
|
||||
}
|
||||
|
||||
trackWorldSourceImpression({
|
||||
worldId: world.id,
|
||||
worldTitle: world.title,
|
||||
sourceSurface,
|
||||
sourceDetail,
|
||||
sectionKey: compact ? 'compact_card' : 'card',
|
||||
})
|
||||
observer.disconnect()
|
||||
})
|
||||
}, { threshold: [0.4] })
|
||||
|
||||
observer.observe(node)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [compact, sourceDetail, sourceSurface, world?.id, world?.title])
|
||||
|
||||
if (!world) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={world.public_url}
|
||||
ref={cardRef}
|
||||
href={href}
|
||||
onClick={() => trackWorldSourceClick({ worldId: world.id, worldTitle: world.title, sourceSurface, sourceDetail })}
|
||||
className={`group relative block w-full overflow-hidden rounded-[28px] border border-white/10 bg-slate-950/70 transition duration-300 hover:-translate-y-1 hover:border-white/20 ${compact ? 'p-5' : 'p-6'}`}
|
||||
style={themeStyle(world.theme)}
|
||||
>
|
||||
@@ -24,25 +75,30 @@ export default function WorldCard({ world, compact = false }) {
|
||||
|
||||
<div className="relative flex h-full min-h-[16rem] flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.22em] text-white/70">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1">{world.phase || world.status}</span>
|
||||
{world.badge_label ? <span className="rounded-full border border-white/15 bg-black/30 px-3 py-1">{world.badge_label}</span> : null}
|
||||
{world.is_recurring ? (
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-100/70">
|
||||
{world.family_title || 'Recurring family'}
|
||||
{world.edition_label ? <span className="ml-2 text-slate-300/70">{world.edition_label}</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{(Array.isArray(world.status_badges) ? world.status_badges : []).map((badge) => (
|
||||
<WorldStatusBadge key={badge.label} badge={badge} />
|
||||
))}
|
||||
{!Array.isArray(world.status_badges) || world.status_badges.length === 0 ? <WorldStatusBadge badge={{ label: world.phase || world.status, tone: 'slate' }} /> : null}
|
||||
{world.campaign_label ? <WorldStatusBadge badge={{ label: world.campaign_label, tone: 'slate' }} /> : null}
|
||||
{world.badge_label ? <WorldStatusBadge badge={{ label: world.badge_label, tone: 'rose' }} /> : null}
|
||||
</div>
|
||||
<h3 className={`mt-4 max-w-xl font-semibold tracking-[-0.03em] text-white ${compact ? 'text-2xl' : 'text-3xl'}`}>{world.title}</h3>
|
||||
{world.teaser_title && world.teaser_title !== world.title ? <p className="mt-4 text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-100/70">{world.title}</p> : null}
|
||||
<h3 className={`mt-4 max-w-xl font-semibold tracking-[-0.03em] text-white ${compact ? 'text-2xl' : 'text-3xl'}`}>{world.teaser_title || world.title}</h3>
|
||||
{world.tagline ? <p className="mt-2 text-sm uppercase tracking-[0.18em] text-white/55">{world.tagline}</p> : null}
|
||||
{world.summary ? <p className="mt-4 max-w-2xl text-sm leading-6 text-slate-200/85">{world.summary}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="space-y-1 text-sm text-slate-200/80">
|
||||
{world.timeframe_label ? <div>{world.timeframe_label}</div> : null}
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.16em] text-white/55">
|
||||
<i className={world.icon_name || 'fa-solid fa-globe'} />
|
||||
<span>{world.theme?.label || world.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
<WorldCampaignMeta world={world} />
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-semibold text-white transition group-hover:bg-white/15">
|
||||
{world.cta_label || 'Open world'}
|
||||
{world.cta_label || world.challenge_cta_label || (world.is_recurring && !world.is_canonical_edition ? 'Open edition' : 'Open world')}
|
||||
<i className="fa-solid fa-arrow-right" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
26
resources/js/components/worlds/WorldChallengeArtworkCard.jsx
Normal file
26
resources/js/components/worlds/WorldChallengeArtworkCard.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import WorldChallengeStatusBadge from './WorldChallengeStatusBadge'
|
||||
|
||||
export default function WorldChallengeArtworkCard({ item, featured = false, sectionKey = 'challenge_entries', challengeId = null }) {
|
||||
if (!item) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={item.url || '#'} data-world-event="world_entity_clicked" data-world-section-key={sectionKey} data-world-entity-type="artwork" data-world-entity-id={item.id} data-world-entity-title={item.title || ''} data-world-challenge-id={challengeId || ''} className={`overflow-hidden rounded-[24px] border transition ${featured ? 'border-amber-300/20 bg-amber-400/10 hover:border-amber-200/35' : 'border-white/10 bg-black/20 hover:border-white/15 hover:bg-white/[0.06]'}`}>
|
||||
<div className="aspect-[4/3] overflow-hidden bg-slate-950/80">
|
||||
{item.image ? <img src={item.image} alt={item.title} className="h-full w-full object-cover" /> : <div className="flex h-full items-center justify-center text-slate-500"><i className="fa-solid fa-image" /></div>}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{item.status_label ? <WorldChallengeStatusBadge label={item.status_label} tone={featured ? 'amber' : 'slate'} className="px-2.5 py-1 text-[10px]" /> : null}
|
||||
{item.context_label ? <span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">{item.context_label}</span> : null}
|
||||
</div>
|
||||
<div className="mt-3 text-base font-semibold text-white">{item.title}</div>
|
||||
{item.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{item.subtitle}</div> : null}
|
||||
{item.description ? <p className="mt-3 text-sm leading-6 text-slate-300">{item.description}</p> : null}
|
||||
{Array.isArray(item.meta) && item.meta.length > 0 ? <div className="mt-3 flex flex-wrap gap-2 text-xs text-slate-500">{item.meta.map((entry) => <span key={entry}>{entry}</span>)}</div> : null}
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
24
resources/js/components/worlds/WorldChallengeEntriesRail.jsx
Normal file
24
resources/js/components/worlds/WorldChallengeEntriesRail.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react'
|
||||
import WorldChallengeArtworkCard from './WorldChallengeArtworkCard'
|
||||
|
||||
export default function WorldChallengeEntriesRail({ section, challengeId = null }) {
|
||||
const items = Array.isArray(section?.items) ? section.items : []
|
||||
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-10">
|
||||
<div className="mb-5 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">{section.title || 'Challenge entries'}</h2>
|
||||
{section.description ? <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">{section.description}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => <WorldChallengeArtworkCard key={item.id} item={item} sectionKey="challenge_entries" challengeId={challengeId} />)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import WorldChallengeArtworkCard from './WorldChallengeArtworkCard'
|
||||
|
||||
export default function WorldChallengeFinalistsGrid({ panel, section = null }) {
|
||||
const items = Array.isArray(section?.items) ? section.items : []
|
||||
|
||||
if (items.length === 0 && (!panel?.show_finalists || panel?.supports_finalists)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-10 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="mb-5">
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">{section?.title || 'Challenge finalists'}</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">
|
||||
{section?.description || 'Finalists from the linked challenge stay visible here so the world can carry the full result set forward as a public recap.'}
|
||||
</p>
|
||||
</div>
|
||||
{items.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => <WorldChallengeArtworkCard key={item.id} item={item} sectionKey="challenge_finalists" challengeId={panel?.id || null} />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[24px] border border-dashed border-white/10 bg-black/20 p-5 text-sm leading-6 text-slate-400">
|
||||
Finalists will appear here automatically once the linked challenge publishes them as structured outcomes.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
17
resources/js/components/worlds/WorldChallengeMeta.jsx
Normal file
17
resources/js/components/worlds/WorldChallengeMeta.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function WorldChallengeMeta({ items = [], className = '' }) {
|
||||
const filteredItems = Array.isArray(items) ? items.filter(Boolean) : []
|
||||
|
||||
if (filteredItems.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap gap-2 text-xs uppercase tracking-[0.16em] text-slate-400 ${className}`.trim()}>
|
||||
{filteredItems.map((item) => (
|
||||
<span key={item}>{item}</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
resources/js/components/worlds/WorldChallengePanel.jsx
Normal file
61
resources/js/components/worlds/WorldChallengePanel.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react'
|
||||
import WorldChallengeMeta from './WorldChallengeMeta'
|
||||
import WorldChallengeStatusBadge from './WorldChallengeStatusBadge'
|
||||
|
||||
export default function WorldChallengePanel({ section }) {
|
||||
if (!section) {
|
||||
return null
|
||||
}
|
||||
|
||||
const storyMeta = Array.isArray(section.story?.meta) ? section.story.meta.filter(Boolean) : []
|
||||
const metaItems = [
|
||||
section.timeframe_label,
|
||||
Number(section.entry_count || 0) > 0 ? `${section.entry_count} entries` : null,
|
||||
section.has_winner ? 'Winner synced' : null,
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="mt-10 overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03]">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_32%),linear-gradient(135deg,_rgba(15,23,42,0.94),_rgba(2,6,23,0.98))]" />
|
||||
{section.cover_url ? <img src={section.cover_url} alt={section.title} className="absolute inset-0 h-full w-full object-cover opacity-20" /> : null}
|
||||
<div className="relative grid gap-6 p-6 lg:grid-cols-[minmax(0,1.25fr)_18rem] lg:p-8">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<WorldChallengeStatusBadge label={section.state_label} tone={section.state_tone} />
|
||||
{section.group?.name ? <span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">{section.group.name}</span> : null}
|
||||
</div>
|
||||
<h2 className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{section.title}</h2>
|
||||
{section.summary ? <p className="mt-4 max-w-3xl text-sm leading-7 text-slate-200/86">{section.summary}</p> : null}
|
||||
<WorldChallengeMeta items={metaItems} className="mt-5" />
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/25 p-5 text-sm text-slate-200">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Linked challenge</div>
|
||||
<div className="mt-3 space-y-3">
|
||||
{section.show_entries ? <div>Derived entries rail enabled</div> : null}
|
||||
{section.show_winners ? <div>Winner section enabled</div> : null}
|
||||
{section.show_finalists ? <div>{section.supports_finalists ? 'Finalists section enabled' : 'Finalists section unavailable'}</div> : null}
|
||||
</div>
|
||||
{section.story?.url ? (
|
||||
<div className="mt-4 rounded-[20px] border border-sky-300/15 bg-sky-400/10 p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100/80">{section.story.eyebrow || section.story.context_label || 'Challenge story'}</div>
|
||||
<a href={section.story.url} data-world-event="world_challenge_cta_clicked" data-world-section-key="challenge" data-world-cta-key={section.story.intent === 'recap' ? 'challenge_recap' : 'challenge_story'} data-world-challenge-id={section.id} className="mt-2 block text-base font-semibold text-white transition hover:text-sky-100">{section.story.title}</a>
|
||||
{section.story.description ? <p className="mt-2 text-sm leading-6 text-slate-200/85">{section.story.description}</p> : null}
|
||||
{storyMeta.length > 0 ? <div className="mt-3 flex flex-wrap gap-2 text-[11px] uppercase tracking-[0.16em] text-slate-400">{storyMeta.map((item) => <span key={item}>{item}</span>)}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<a href={section.cta_url || section.url} data-world-event="world_challenge_cta_clicked" data-world-section-key="challenge" data-world-cta-key={section.story?.intent === 'recap' && section.cta_url === section.story?.url ? 'challenge_recap' : 'challenge_primary'} data-world-challenge-id={section.id} className="inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-100">
|
||||
{section.cta_label || 'Open challenge'}
|
||||
<i className="fa-solid fa-arrow-right" />
|
||||
</a>
|
||||
{section.story?.url && section.story.url !== section.cta_url ? <a href={section.story.url} data-world-event="world_challenge_cta_clicked" data-world-section-key="challenge" data-world-cta-key={section.story.intent === 'recap' ? 'challenge_recap' : 'challenge_story'} data-world-challenge-id={section.id} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">{section.story.cta_label || 'Read story'}</a> : null}
|
||||
{section.challenge_url && section.challenge_url !== section.cta_url ? <a href={section.challenge_url} data-world-event="world_challenge_cta_clicked" data-world-section-key="challenge" data-world-cta-key="challenge_direct" data-world-challenge-id={section.id} className="inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Open challenge</a> : null}
|
||||
{section.group?.url ? <a href={section.group.url} data-world-event="world_cta_clicked" data-world-section-key="challenge" data-world-cta-key="linked_group" data-world-challenge-id={section.id} className="inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Open group</a> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
24
resources/js/components/worlds/WorldChallengeStatusBadge.jsx
Normal file
24
resources/js/components/worlds/WorldChallengeStatusBadge.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react'
|
||||
|
||||
const TONE_CLASSNAMES = {
|
||||
slate: 'border-white/12 bg-white/[0.06] text-slate-100',
|
||||
sky: 'border-sky-300/25 bg-sky-400/12 text-sky-100',
|
||||
emerald: 'border-emerald-300/25 bg-emerald-400/12 text-emerald-100',
|
||||
amber: 'border-amber-300/25 bg-amber-400/12 text-amber-100',
|
||||
rose: 'border-rose-300/25 bg-rose-400/12 text-rose-100',
|
||||
violet: 'border-violet-300/25 bg-violet-400/12 text-violet-100',
|
||||
}
|
||||
|
||||
export default function WorldChallengeStatusBadge({ label, tone = 'slate', className = '' }) {
|
||||
if (!label) {
|
||||
return null
|
||||
}
|
||||
|
||||
const toneClassName = TONE_CLASSNAMES[tone] || TONE_CLASSNAMES.slate
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${toneClassName} ${className}`.trim()}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react'
|
||||
import WorldChallengeArtworkCard from './WorldChallengeArtworkCard'
|
||||
|
||||
export default function WorldChallengeWinnersPanel({ section, challengeId = null }) {
|
||||
const items = Array.isArray(section?.items) ? section.items : (section?.item ? [section.item] : [])
|
||||
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-10 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="mb-5">
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">{section.title || 'Challenge winner'}</h2>
|
||||
{section.description ? <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">{section.description}</p> : null}
|
||||
</div>
|
||||
{items.length === 1 ? <WorldChallengeArtworkCard item={items[0]} featured sectionKey="challenge_winners" challengeId={challengeId} /> : (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => <WorldChallengeArtworkCard key={item.id} item={item} featured sectionKey="challenge_winners" challengeId={challengeId} />)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export default function WorldCommunitySubmissionsSection({ section }) {
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{section.items.map((item) => (
|
||||
<a key={item.id} href={item.url} className="group overflow-hidden rounded-[26px] border border-white/10 bg-white/[0.03] transition duration-300 hover:-translate-y-1 hover:border-white/20 hover:bg-white/[0.05]">
|
||||
<a key={item.id} href={item.url} data-world-event="world_entity_clicked" data-world-section-key="community_submissions" data-world-entity-type="artwork" data-world-entity-id={item.id} data-world-entity-title={item.title || ''} className="group overflow-hidden rounded-[26px] border border-white/10 bg-white/[0.03] transition duration-300 hover:-translate-y-1 hover:border-white/20 hover:bg-white/[0.05]">
|
||||
<div className="relative overflow-hidden border-b border-white/10 bg-slate-950/80">
|
||||
{item.image ? (
|
||||
<img src={item.image} alt={item.title} className="aspect-[16/10] w-full object-cover transition duration-500 group-hover:scale-[1.04]" />
|
||||
|
||||
5
resources/js/components/worlds/WorldEndedBadge.jsx
Normal file
5
resources/js/components/worlds/WorldEndedBadge.jsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function WorldEndedBadge({ label = 'Ended edition' }) {
|
||||
return <span className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/12 px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100"><i className="fa-solid fa-flag-checkered" />{label}</span>
|
||||
}
|
||||
87
resources/js/components/worlds/WorldFamilyCard.jsx
Normal file
87
resources/js/components/worlds/WorldFamilyCard.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from 'react'
|
||||
import WorldStatusBadge from './WorldStatusBadge'
|
||||
import { trackWorldSourceClick, withWorldSource } from '../../lib/worldAnalytics'
|
||||
|
||||
function themeStyle(theme) {
|
||||
return {
|
||||
'--world-accent': theme?.accent_color || '#38bdf8',
|
||||
'--world-accent-secondary': theme?.accent_color_secondary || '#0f172a',
|
||||
}
|
||||
}
|
||||
|
||||
export default function WorldFamilyCard({ family, sourceSurface = '', sourceDetail = '' }) {
|
||||
if (!family) {
|
||||
return null
|
||||
}
|
||||
|
||||
const currentWorld = family.current_world || null
|
||||
const editionCount = Number(family.edition_count || 0)
|
||||
const archiveCount = Number(family.archive_count || 0)
|
||||
const familyHref = sourceSurface ? withWorldSource(family.public_url, sourceSurface, sourceDetail || 'recurring_family') : family.public_url
|
||||
|
||||
return (
|
||||
<article
|
||||
className="group relative overflow-hidden rounded-[28px] border border-white/10 bg-slate-950/70 p-6"
|
||||
style={themeStyle(family.theme)}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,_color-mix(in_srgb,var(--world-accent)_26%,transparent),_transparent_42%),linear-gradient(135deg,_color-mix(in_srgb,var(--world-accent-secondary)_94%,black),_rgba(2,6,23,0.94))] opacity-95" />
|
||||
{family.cover_url ? <img src={family.cover_url} alt={family.title} className="absolute inset-0 h-full w-full object-cover opacity-15" /> : null}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/88 to-slate-950/15" />
|
||||
|
||||
<div className="relative flex h-full min-h-[18rem] flex-col justify-between gap-6">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<WorldStatusBadge badge={{ label: 'Recurring family', tone: 'sky' }} />
|
||||
{archiveCount > 0 ? <WorldStatusBadge badge={{ label: `${archiveCount} archived`, tone: 'amber' }} /> : null}
|
||||
{currentWorld?.campaign_state_label ? <WorldStatusBadge badge={{ label: currentWorld.campaign_state_label, tone: currentWorld.campaign_state === 'live_now' ? 'emerald' : 'slate' }} /> : null}
|
||||
</div>
|
||||
|
||||
<h3 className="mt-4 text-3xl font-semibold tracking-[-0.03em] text-white">{family.title}</h3>
|
||||
{family.summary ? <p className="mt-4 max-w-2xl text-sm leading-6 text-slate-200/85">{family.summary}</p> : null}
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-300/75">
|
||||
<span>{editionCount} editions</span>
|
||||
{Array.isArray(family.years) && family.years.length > 0 ? <span>{family.years.join(' / ')}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-[22px] border border-white/10 bg-black/25 p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Current edition</div>
|
||||
{currentWorld ? (
|
||||
<div className="mt-3">
|
||||
<a href={sourceSurface ? withWorldSource(currentWorld.public_url, sourceSurface, 'recurring_family_current') : currentWorld.public_url} onClick={() => trackWorldSourceClick({ worldId: currentWorld.id, worldTitle: currentWorld.title, sourceSurface, sourceDetail: 'recurring_family_current' })} className="text-lg font-semibold text-white transition hover:text-sky-200">{currentWorld.title}</a>
|
||||
{currentWorld.summary ? <p className="mt-2 text-sm leading-6 text-slate-300/85">{currentWorld.summary}</p> : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-sm leading-6 text-slate-300/75">No public edition is currently available.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-[22px] border border-white/10 bg-black/25 p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Archive</div>
|
||||
{Array.isArray(family.previous_editions) && family.previous_editions.length > 0 ? (
|
||||
<div className="mt-3 grid gap-2">
|
||||
{family.previous_editions.map((edition) => (
|
||||
<a key={edition.id} href={sourceSurface ? withWorldSource(edition.public_url, sourceSurface, 'recurring_family_archive') : edition.public_url} onClick={() => trackWorldSourceClick({ worldId: edition.id, worldTitle: edition.title, sourceSurface, sourceDetail: 'recurring_family_archive' })} className="inline-flex items-center justify-between gap-3 rounded-2xl border border-white/8 bg-white/[0.04] px-3 py-2 text-sm text-slate-200 transition hover:border-white/16 hover:bg-white/[0.07]">
|
||||
<span>{edition.title}</span>
|
||||
{edition.edition_year ? <span className="text-xs uppercase tracking-[0.14em] text-slate-400">{edition.edition_year}</span> : null}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-sm leading-6 text-slate-300/75">The archive starts with the current edition.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a href={familyHref} onClick={() => trackWorldSourceClick({ worldId: family.current_world?.id || 0, worldTitle: family.title, sourceSurface, sourceDetail: sourceDetail || 'recurring_family' })} className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-semibold text-white transition group-hover:bg-white/15">
|
||||
Open family
|
||||
<i className="fa-solid fa-arrow-right" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from 'react'
|
||||
import WorldCampaignMeta from './WorldCampaignMeta'
|
||||
import WorldStatusBadge from './WorldStatusBadge'
|
||||
|
||||
function styleForWorld(world) {
|
||||
return {
|
||||
@@ -10,13 +12,13 @@ function styleForWorld(world) {
|
||||
function resolvedIconName(world) {
|
||||
const icon = String(world?.icon_name || '').trim()
|
||||
|
||||
if (icon) {
|
||||
if (icon.startsWith('fa-')) {
|
||||
return icon
|
||||
}
|
||||
|
||||
const themeIcon = String(world?.theme?.icon_name || '').trim()
|
||||
|
||||
return themeIcon || 'fa-solid fa-globe'
|
||||
return themeIcon.startsWith('fa-') ? themeIcon : 'fa-solid fa-globe'
|
||||
}
|
||||
|
||||
export default function WorldHero({ world, previewMode = false }) {
|
||||
@@ -24,6 +26,8 @@ export default function WorldHero({ world, previewMode = false }) {
|
||||
return null
|
||||
}
|
||||
|
||||
const themeIconName = resolvedIconName(world)
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden rounded-[36px] border border-white/10" style={styleForWorld(world)}>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_16%,_color-mix(in_srgb,var(--world-accent)_30%,transparent),_transparent_34%),radial-gradient(circle_at_82%_18%,_color-mix(in_srgb,var(--world-accent-secondary)_68%,transparent),_transparent_42%),linear-gradient(135deg,_rgba(2,6,23,0.92),_rgba(15,23,42,0.82)_45%,_rgba(2,6,23,0.95))]" />
|
||||
@@ -33,26 +37,35 @@ export default function WorldHero({ world, previewMode = false }) {
|
||||
<div className="relative grid gap-10 px-6 py-8 sm:px-8 lg:grid-cols-[minmax(0,1.25fr)_20rem] lg:px-10 lg:py-10">
|
||||
<div>
|
||||
{previewMode ? <div className="inline-flex items-center gap-2 rounded-full border border-amber-300/25 bg-amber-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-amber-100">Preview Mode</div> : null}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-100/70">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1">{world.type}</span>
|
||||
{world.badge_label ? <span className="rounded-full border border-white/15 bg-black/30 px-3 py-1">{world.badge_label}</span> : null}
|
||||
{world.timeframe_label ? <span className="rounded-full border border-white/15 bg-black/30 px-3 py-1">{world.timeframe_label}</span> : null}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
{(Array.isArray(world.status_badges) ? world.status_badges : []).map((badge) => <WorldStatusBadge key={badge.label} badge={badge} />)}
|
||||
<WorldStatusBadge badge={{ label: world.type, tone: 'slate' }} />
|
||||
{world.campaign_label ? <WorldStatusBadge badge={{ label: world.campaign_label, tone: 'slate' }} /> : null}
|
||||
{world.badge_label ? <WorldStatusBadge badge={{ label: world.badge_label, tone: 'rose' }} /> : null}
|
||||
</div>
|
||||
<h1 className="mt-5 max-w-4xl text-4xl font-semibold tracking-[-0.05em] text-white sm:text-5xl lg:text-6xl">{world.title}</h1>
|
||||
{world.tagline ? <p className="mt-4 text-sm uppercase tracking-[0.24em] text-white/55">{world.tagline}</p> : null}
|
||||
{world.summary ? <p className="mt-6 max-w-3xl text-base leading-7 text-slate-200/86 sm:text-lg">{world.summary}</p> : null}
|
||||
{world.summary ? <p className="mt-6 max-w-none text-base leading-7 text-slate-200/86 sm:text-lg">{world.summary}</p> : null}
|
||||
{world.description ? (
|
||||
<div
|
||||
className="prose prose-invert prose-sm mt-5 max-w-3xl prose-p:text-slate-300/88 prose-p:leading-7 prose-headings:text-white prose-strong:text-white prose-a:text-sky-300 prose-blockquote:border-l-sky-500/40 prose-blockquote:text-slate-300 prose-code:text-amber-300 prose-code:bg-white/[0.06] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-pre:border prose-pre:border-white/[0.06] prose-pre:bg-white/[0.04] prose-hr:border-white/10 prose-ul:text-slate-300/88 prose-ol:text-slate-300/88"
|
||||
className="prose prose-invert prose-sm mt-5 max-w-none prose-p:text-slate-300/88 prose-p:leading-7 prose-headings:text-white prose-strong:text-white prose-a:text-sky-300 prose-blockquote:border-l-sky-500/40 prose-blockquote:text-slate-300 prose-code:text-amber-300 prose-code:bg-white/[0.06] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-pre:border prose-pre:border-white/[0.06] prose-pre:bg-white/[0.04] prose-hr:border-white/10 prose-ul:text-slate-300/88 prose-ol:text-slate-300/88"
|
||||
dangerouslySetInnerHTML={{ __html: world.description }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
{world.cta_url ? <a href={world.cta_url} className="inline-flex items-center gap-2 rounded-full bg-white px-5 py-3 text-sm font-semibold text-slate-950 transition hover:bg-sky-100">{world.cta_label || 'Explore'}<i className="fa-solid fa-arrow-right" /></a> : null}
|
||||
{world.public_url ? <a href={world.public_url} className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/8 px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/12">Canonical page<i className="fa-solid fa-up-right-from-square" /></a> : null}
|
||||
{world.cta_url || world.challenge_cta_url ? <a href={world.cta_url || world.challenge_cta_url} data-world-event="world_cta_clicked" data-world-section-key="hero" data-world-cta-key="main_world_cta" className="inline-flex items-center gap-2 rounded-full bg-white px-5 py-3 text-sm font-semibold text-slate-950 transition hover:bg-sky-100">{world.cta_label || world.challenge_cta_label || 'Explore'}<i className="fa-solid fa-arrow-right" /></a> : null}
|
||||
</div>
|
||||
|
||||
<WorldCampaignMeta world={world} className="mt-6" />
|
||||
|
||||
{world.is_recurring ? (
|
||||
<div className="mt-6 flex flex-wrap gap-3 text-sm text-slate-200/80">
|
||||
{world.family_url ? <a href={world.family_url} data-world-event="world_cta_clicked" data-world-section-key="hero" data-world-cta-key="family_route" className="inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/[0.05] px-4 py-2 hover:bg-white/[0.08]">Family route<i className="fa-solid fa-arrow-right" /></a> : null}
|
||||
{!world.is_canonical_edition && world.edition_url ? <a href={world.edition_url} data-world-event="world_cta_clicked" data-world-section-key="hero" data-world-cta-key="edition_archive" className="inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/[0.05] px-4 py-2 hover:bg-white/[0.08]">Edition archive link<i className="fa-solid fa-arrow-right" /></a> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{Array.isArray(world.related_tags) && world.related_tags.length > 0 ? (
|
||||
<div className="mt-8 flex flex-wrap gap-2">
|
||||
{world.related_tags.map((tag) => (
|
||||
@@ -65,9 +78,11 @@ export default function WorldHero({ world, previewMode = false }) {
|
||||
<aside className="grid gap-4 self-end">
|
||||
<div className="rounded-[28px] border border-white/12 bg-black/25 p-5 text-white shadow-2xl shadow-slate-950/30 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/12 bg-white/10 text-lg text-white">
|
||||
<i className={resolvedIconName(world)} />
|
||||
</div>
|
||||
{themeIconName ? (
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/12 bg-white/10 text-lg text-white">
|
||||
<i className={themeIconName} />
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Theme</div>
|
||||
<div className="mt-1 text-lg font-semibold">{world.theme?.label || world.type}</div>
|
||||
@@ -78,11 +93,14 @@ export default function WorldHero({ world, previewMode = false }) {
|
||||
|
||||
<div className="mt-5 grid gap-3 text-sm text-slate-200/90">
|
||||
{world.timeframe_label ? <div className="flex items-center gap-2"><i className="fa-regular fa-calendar" /><span>{world.timeframe_label}</span></div> : null}
|
||||
{world.edition_year ? <div className="flex items-center gap-2"><i className="fa-solid fa-repeat" /><span>Edition {world.edition_year}</span></div> : null}
|
||||
{world.is_recurring ? <div className="flex items-center gap-2"><i className="fa-solid fa-clock-rotate-left" /><span>Recurring world</span></div> : null}
|
||||
{world.promotion_window_label ? <div className="flex items-center gap-2"><i className="fa-solid fa-bullhorn" /><span>{world.promotion_window_label}</span></div> : null}
|
||||
{world.edition_label ? <div className="flex items-center gap-2"><i className="fa-solid fa-repeat" /><span>{world.edition_label}</span></div> : null}
|
||||
{world.is_recurring ? <div className="flex items-center gap-2"><i className="fa-solid fa-clock-rotate-left" /><span>{world.is_canonical_edition ? 'Canonical family edition' : 'Archive edition in a recurring family'}</span></div> : null}
|
||||
{world.family_title ? <div className="flex items-center gap-2"><i className="fa-solid fa-layer-group" /><span>{world.family_title}</span></div> : null}
|
||||
{world.live_submission_count > 0 ? <div className="flex items-center gap-2"><i className="fa-solid fa-people-group" /><span>{world.live_submission_count} live submissions</span></div> : null}
|
||||
</div>
|
||||
|
||||
{world.badge_url ? <a href={world.badge_url} className="mt-5 inline-flex items-center gap-2 text-sm font-semibold text-sky-100 hover:text-white">View badge<i className="fa-solid fa-arrow-right" /></a> : null}
|
||||
{world.badge_url ? <a href={world.badge_url} data-world-event="world_cta_clicked" data-world-section-key="hero" data-world-cta-key="badge_cta" className="mt-5 inline-flex items-center gap-2 text-sm font-semibold text-sky-100 hover:text-white">View badge<i className="fa-solid fa-arrow-right" /></a> : null}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
24
resources/js/components/worlds/WorldRecapArticleCard.jsx
Normal file
24
resources/js/components/worlds/WorldRecapArticleCard.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function WorldRecapArticleCard({ article }) {
|
||||
if (!article?.url) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-10 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.1fr)_16rem] lg:items-center">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{article.eyebrow || 'Recap article'}</div>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-white">{article.title}</h2>
|
||||
{article.description ? <p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">{article.description}</p> : null}
|
||||
{Array.isArray(article.meta) && article.meta.length > 0 ? <div className="mt-4 flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{article.meta.map((item) => <span key={item}>{item}</span>)}</div> : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 lg:items-end">
|
||||
{article.image ? <img src={article.image} alt={article.title} className="h-32 w-full rounded-[24px] border border-white/10 object-cover lg:w-56" /> : null}
|
||||
<a href={article.url} data-world-event="world_cta_clicked" data-world-section-key="recap_article" data-world-cta-key="recap_article" className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">{article.cta_label || 'Read article'}<i className="fa-solid fa-arrow-right" /></a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function WorldRecapCommunityHighlights({ section }) {
|
||||
const items = Array.isArray(section?.items) ? section.items : []
|
||||
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-10 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="mb-5 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">{section.title || 'Community highlights'}</h2>
|
||||
{section.description ? <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">{section.description}</p> : null}
|
||||
</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">{items.length} artworks</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<a key={item.id} href={item.url} data-world-event="world_entity_clicked" data-world-section-key="recap_community" data-world-entity-type="artwork" data-world-entity-id={item.id} data-world-entity-title={item.title || ''} className="group overflow-hidden rounded-[26px] border border-white/10 bg-black/20 transition duration-300 hover:-translate-y-1 hover:border-white/20 hover:bg-white/[0.05]">
|
||||
<div className="relative overflow-hidden border-b border-white/10 bg-slate-950/80">
|
||||
{item.image ? <img src={item.image} alt={item.title} className="aspect-[16/10] w-full object-cover transition duration-500 group-hover:scale-[1.04]" /> : <div className="aspect-[16/10] w-full bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_42%),linear-gradient(135deg,_rgba(15,23,42,0.96),_rgba(2,6,23,0.96))]" />}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{item.context_label ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-200">{item.context_label}</span> : null}
|
||||
{item.status_label ? <span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-amber-100">{item.status_label}</span> : null}
|
||||
</div>
|
||||
<h3 className="mt-3 text-lg font-semibold tracking-[-0.02em] text-white">{item.title}</h3>
|
||||
{item.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{item.subtitle}</div> : null}
|
||||
{item.description ? <p className="mt-3 text-sm leading-6 text-slate-300">{item.description}</p> : null}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
63
resources/js/components/worlds/WorldRecapCreatorsPanel.jsx
Normal file
63
resources/js/components/worlds/WorldRecapCreatorsPanel.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react'
|
||||
|
||||
function EntityCard({ item, sectionKey }) {
|
||||
return (
|
||||
<a href={item.url} data-world-event="world_entity_clicked" data-world-section-key={sectionKey} data-world-entity-type={item.entity_type || ''} data-world-entity-id={item.id} data-world-entity-title={item.title || ''} className="rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/15 hover:bg-white/[0.06]">
|
||||
<div className="flex items-center gap-3">
|
||||
{item.avatar ? <img src={item.avatar} alt="" className="h-12 w-12 rounded-2xl border border-white/10 object-cover" /> : item.image ? <img src={item.image} alt="" className="h-12 w-12 rounded-2xl border border-white/10 object-cover" /> : <div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-500"><i className="fa-solid fa-user-group" /></div>}
|
||||
<div className="min-w-0 flex-1">
|
||||
{item.context_label ? <div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{item.context_label}</div> : null}
|
||||
<div className="truncate text-sm font-semibold text-white">{item.title}</div>
|
||||
{item.subtitle ? <div className="truncate text-xs uppercase tracking-[0.14em] text-slate-500">{item.subtitle}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
{item.description ? <p className="mt-3 text-sm leading-6 text-slate-300">{item.description}</p> : null}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function RewardedCard({ item }) {
|
||||
const creator = item?.creator || null
|
||||
|
||||
if (!creator) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<a href={creator.profile_url || '#'} data-world-event="world_entity_clicked" data-world-section-key="recap_rewarded" data-world-entity-type="creator" data-world-entity-id={creator.id || 0} data-world-entity-title={creator.name || creator.username || 'Creator'} className="rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/15 hover:bg-white/[0.06]">
|
||||
<div className="flex items-center gap-3">
|
||||
{creator.avatar_url ? <img src={creator.avatar_url} alt={creator.username || creator.name} className="h-12 w-12 rounded-2xl border border-white/10 object-cover" /> : <div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-500"><i className="fa-solid fa-user" /></div>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-white">{creator.name || creator.username || 'Creator'}</div>
|
||||
<div className="truncate text-xs uppercase tracking-[0.14em] text-slate-500">{item.badge_label}</div>
|
||||
</div>
|
||||
</div>
|
||||
{item.artwork?.title ? <div className="mt-3 text-sm text-slate-300">{item.artwork.title}</div> : null}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WorldRecapCreatorsPanel({ section }) {
|
||||
const items = Array.isArray(section?.items) ? section.items : []
|
||||
const rewarded = Array.isArray(section?.rewarded) ? section.rewarded.filter(Boolean) : []
|
||||
|
||||
if (items.length === 0 && rewarded.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-10 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="mb-5">
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">{section.title || 'Creators and groups'}</h2>
|
||||
{section.description ? <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">{section.description}</p> : null}
|
||||
</div>
|
||||
{items.length > 0 ? <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">{items.map((item) => <EntityCard key={`${item.entity_type}-${item.id}`} item={item} sectionKey="recap_creators" />)}</div> : null}
|
||||
{rewarded.length > 0 ? (
|
||||
<div className="mt-6">
|
||||
<div className="mb-4 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Rewarded contributors</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{rewarded.map((item) => <RewardedCard key={item.id} item={item} />)}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react'
|
||||
|
||||
function ArtworkCard({ item }) {
|
||||
return (
|
||||
<a href={item.url} data-world-event="world_entity_clicked" data-world-section-key="recap_highlights" data-world-entity-type={item.entity_type || 'artwork'} data-world-entity-id={item.id} data-world-entity-title={item.title || ''} className="group overflow-hidden rounded-[26px] border border-white/10 bg-white/[0.03] transition duration-300 hover:-translate-y-1 hover:border-white/20 hover:bg-white/[0.05]">
|
||||
<div className="relative overflow-hidden border-b border-white/10 bg-slate-950/80">
|
||||
{item.image ? <img src={item.image} alt={item.title} className="aspect-[16/10] w-full object-cover transition duration-500 group-hover:scale-[1.04]" /> : <div className="aspect-[16/10] w-full bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_42%),linear-gradient(135deg,_rgba(15,23,42,0.96),_rgba(2,6,23,0.96))]" />}
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 flex items-start justify-between gap-2 p-4">
|
||||
{item.context_label ? <span className="rounded-full border border-white/15 bg-black/35 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-white/80">{item.context_label}</span> : null}
|
||||
{item.status_label ? <span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-sky-100">{item.status_label}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-semibold tracking-[-0.02em] text-white">{item.title}</h3>
|
||||
{item.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{item.subtitle}</div> : null}
|
||||
{item.description ? <p className="mt-3 text-sm leading-6 text-slate-300">{item.description}</p> : null}
|
||||
{Array.isArray(item.meta) && item.meta.length > 0 ? <div className="mt-4 flex flex-wrap gap-2">{item.meta.map((entry) => <span key={entry} className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-xs text-slate-300">{entry}</span>)}</div> : null}
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WorldRecapFeaturedArtworks({ section }) {
|
||||
const items = Array.isArray(section?.items) ? section.items : []
|
||||
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="world-recap-highlights" className="mt-10">
|
||||
<div className="mb-5 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">{section.title || 'Edition highlights'}</h2>
|
||||
{section.description ? <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">{section.description}</p> : null}
|
||||
</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">{items.length} highlights</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{items.map((item) => <ArtworkCard key={item.id} item={item} />)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
54
resources/js/components/worlds/WorldRecapHero.jsx
Normal file
54
resources/js/components/worlds/WorldRecapHero.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react'
|
||||
import WorldEndedBadge from './WorldEndedBadge'
|
||||
import WorldStatusBadge from './WorldStatusBadge'
|
||||
|
||||
function recapStyle(world) {
|
||||
return {
|
||||
'--world-accent': world?.theme?.accent_color || '#38bdf8',
|
||||
'--world-accent-secondary': world?.theme?.accent_color_secondary || '#0f172a',
|
||||
}
|
||||
}
|
||||
|
||||
export default function WorldRecapHero({ world, recap, previewMode = false }) {
|
||||
if (!world || !recap) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="world-recap" className="relative overflow-hidden rounded-[36px] border border-white/10" style={recapStyle(world)}>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_15%_20%,_color-mix(in_srgb,var(--world-accent)_28%,transparent),_transparent_32%),radial-gradient(circle_at_85%_18%,_color-mix(in_srgb,var(--world-accent-secondary)_70%,transparent),_transparent_40%),linear-gradient(140deg,_rgba(2,6,23,0.95),_rgba(15,23,42,0.84)_48%,_rgba(2,6,23,0.98))]" />
|
||||
{recap.cover_url ? <img src={recap.cover_url} alt={recap.title || world.title} className="absolute inset-0 h-full w-full object-cover opacity-20" /> : null}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-slate-950 via-slate-950/75 to-slate-950/10" />
|
||||
|
||||
<div className="relative grid gap-8 px-6 py-8 sm:px-8 lg:grid-cols-[minmax(0,1.18fr)_21rem] lg:px-10 lg:py-10">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<WorldEndedBadge label={previewMode && recap.status === 'draft_preview' ? 'Recap draft preview' : 'Published recap'} />
|
||||
{(Array.isArray(world.status_badges) ? world.status_badges : []).slice(0, 3).map((badge) => <WorldStatusBadge key={badge.label} badge={badge} />)}
|
||||
</div>
|
||||
<h1 className="mt-5 max-w-4xl text-4xl font-semibold tracking-[-0.05em] text-white sm:text-5xl lg:text-6xl">{recap.title}</h1>
|
||||
{world.title ? <p className="mt-3 text-xs uppercase tracking-[0.24em] text-white/55">{world.title}</p> : null}
|
||||
{recap.summary ? <p className="mt-6 max-w-3xl text-base leading-7 text-slate-200/90 sm:text-lg">{recap.summary}</p> : null}
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
{world.cta_url ? <a href={world.cta_url} data-world-event="world_cta_clicked" data-world-section-key="recap_hero" data-world-cta-key="recap_primary" className="inline-flex items-center gap-2 rounded-full bg-white px-5 py-3 text-sm font-semibold text-slate-950 transition hover:bg-sky-100">{world.cta_label || 'Browse recap highlights'}<i className="fa-solid fa-arrow-right" /></a> : null}
|
||||
{world.public_url ? <a href={`${world.public_url}#world-recap-highlights`} data-world-event="world_cta_clicked" data-world-section-key="recap_hero" data-world-cta-key="recap_jump_highlights" className="inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Edition highlights</a> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="rounded-[28px] border border-white/12 bg-black/25 p-5 text-sm text-slate-200 shadow-2xl shadow-slate-950/30 backdrop-blur-sm">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Recap status</div>
|
||||
<div className="mt-3 space-y-3">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-slate-500">Edition state</div>
|
||||
<div className="mt-1 text-base font-semibold text-white">Archive-facing recap</div>
|
||||
</div>
|
||||
{recap.published_at ? <div><div className="text-xs uppercase tracking-[0.16em] text-slate-500">Published</div><div className="mt-1 text-base font-semibold text-white">{new Date(recap.published_at).toLocaleDateString()}</div></div> : null}
|
||||
{world.edition_label ? <div><div className="text-xs uppercase tracking-[0.16em] text-slate-500">Edition</div><div className="mt-1 text-base font-semibold text-white">{world.edition_label}</div></div> : null}
|
||||
{world.family_title ? <div><div className="text-xs uppercase tracking-[0.16em] text-slate-500">Family</div><div className="mt-1 text-base font-semibold text-white">{world.family_title}</div></div> : null}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
34
resources/js/components/worlds/WorldRecapStatsGrid.jsx
Normal file
34
resources/js/components/worlds/WorldRecapStatsGrid.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react'
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat('en', { notation: value >= 1000 ? 'compact' : 'standard', maximumFractionDigits: 1 }).format(Number(value || 0))
|
||||
}
|
||||
|
||||
export default function WorldRecapStatsGrid({ stats }) {
|
||||
const items = Array.isArray(stats?.items) ? stats.items : []
|
||||
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-10 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="mb-5 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">Key stats</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">A compact snapshot of how the edition performed before it settled into the archive.</p>
|
||||
</div>
|
||||
{stats?.captured_at ? <div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{stats.source === 'snapshot' ? 'Snapshot captured' : 'Live draft metrics'} {new Date(stats.captured_at).toLocaleDateString()}</div> : null}
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<div key={item.key} className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{item.label}</div>
|
||||
<div className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">{formatNumber(item.value)}</div>
|
||||
{item.description ? <p className="mt-3 text-sm leading-6 text-slate-400">{item.description}</p> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
14
resources/js/components/worlds/WorldRecapSummaryCard.jsx
Normal file
14
resources/js/components/worlds/WorldRecapSummaryCard.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function WorldRecapSummaryCard({ recap }) {
|
||||
if (!recap?.intro) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-10 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Recap summary</div>
|
||||
<div className="prose prose-invert prose-sm mt-4 max-w-4xl prose-p:text-slate-300 prose-p:leading-7 prose-headings:text-white prose-strong:text-white prose-a:text-sky-300" dangerouslySetInnerHTML={{ __html: recap.intro }} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react'
|
||||
|
||||
function EntityCard({ item }) {
|
||||
function EntityCard({ item, sectionKey }) {
|
||||
return (
|
||||
<a href={item.url} className="group rounded-[26px] border border-white/10 bg-white/[0.03] p-4 transition duration-300 hover:-translate-y-1 hover:border-white/20 hover:bg-white/[0.05]">
|
||||
<a href={item.url} data-world-event="world_entity_clicked" data-world-section-key={sectionKey} data-world-entity-type={item.entity_type || ''} data-world-entity-id={item.id} data-world-entity-title={item.title || ''} className="group rounded-[26px] border border-white/10 bg-white/[0.03] p-4 transition duration-300 hover:-translate-y-1 hover:border-white/20 hover:bg-white/[0.05]">
|
||||
<div className="relative overflow-hidden rounded-[22px] border border-white/10 bg-slate-950/70">
|
||||
{item.image ? (
|
||||
<img src={item.image} alt={item.title} className="aspect-[16/10] w-full object-cover transition duration-500 group-hover:scale-[1.04]" />
|
||||
@@ -44,7 +44,7 @@ export default function WorldSection({ section }) {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{section.items.map((item) => <EntityCard key={`${item.entity_type}-${item.id}`} item={item} />)}
|
||||
{section.items.map((item) => <EntityCard key={`${item.entity_type}-${item.id}`} item={item} sectionKey={section.key || ''} />)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
23
resources/js/components/worlds/WorldStatusBadge.jsx
Normal file
23
resources/js/components/worlds/WorldStatusBadge.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react'
|
||||
|
||||
const TONE_CLASSNAMES = {
|
||||
slate: 'border-white/12 bg-white/[0.06] text-slate-100',
|
||||
sky: 'border-sky-300/25 bg-sky-400/12 text-sky-100',
|
||||
emerald: 'border-emerald-300/25 bg-emerald-400/12 text-emerald-100',
|
||||
amber: 'border-amber-300/25 bg-amber-400/12 text-amber-100',
|
||||
rose: 'border-rose-300/25 bg-rose-400/12 text-rose-100',
|
||||
}
|
||||
|
||||
export default function WorldStatusBadge({ badge, className = '' }) {
|
||||
if (!badge?.label) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tone = TONE_CLASSNAMES[badge.tone] || TONE_CLASSNAMES.slate
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${tone} ${className}`.trim()}>
|
||||
{badge.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +1,8 @@
|
||||
import React from 'react'
|
||||
|
||||
function statusTone(item) {
|
||||
if (item?.is_featured) {
|
||||
return 'border-amber-300/30 bg-amber-400/10 text-amber-100'
|
||||
}
|
||||
|
||||
switch (item?.status) {
|
||||
case 'live':
|
||||
return 'border-emerald-300/30 bg-emerald-400/10 text-emerald-100'
|
||||
case 'removed':
|
||||
return 'border-orange-300/30 bg-orange-400/10 text-orange-100'
|
||||
case 'blocked':
|
||||
return 'border-rose-300/30 bg-rose-400/10 text-rose-100'
|
||||
case 'pending':
|
||||
return 'border-sky-300/30 bg-sky-400/10 text-sky-100'
|
||||
default:
|
||||
return 'border-white/10 bg-white/[0.04] text-slate-300'
|
||||
}
|
||||
}
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import UploadWorldHighlightCard from './UploadWorldHighlightCard'
|
||||
import WorldCampaignMeta from './WorldCampaignMeta'
|
||||
import WorldStatusBadge from './WorldStatusBadge'
|
||||
import { trackWorldAnalytics, trackWorldSourceImpression } from '../../lib/worldAnalytics'
|
||||
|
||||
function modeTone(mode) {
|
||||
switch (mode) {
|
||||
@@ -49,8 +34,72 @@ export default function WorldSubmissionSelector({
|
||||
onToggle,
|
||||
onNoteChange,
|
||||
className = '',
|
||||
analyticsContext = null,
|
||||
}) {
|
||||
const items = Array.isArray(options) ? options : []
|
||||
const highlightedWorld = items.find((item) => item.is_active_campaign && item.is_accepting_submissions)
|
||||
const itemRefs = useRef(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
if (!analyticsContext?.sourceSurface || typeof window === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const refs = Array.from(itemRefs.current.entries())
|
||||
if (refs.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (typeof window.IntersectionObserver !== 'function') {
|
||||
refs.forEach(([worldId]) => {
|
||||
const item = items.find((candidate) => Number(candidate.id) === Number(worldId))
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
|
||||
trackWorldSourceImpression({
|
||||
worldId: item.id,
|
||||
worldTitle: item.title || item.teaser_title || '',
|
||||
sourceSurface: analyticsContext.sourceSurface,
|
||||
sourceDetail: analyticsContext.sourceDetail ? `${analyticsContext.sourceDetail}:selector` : 'selector',
|
||||
sectionKey: 'community_submissions',
|
||||
})
|
||||
})
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const observer = new window.IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting || entry.intersectionRatio < 0.35) {
|
||||
return
|
||||
}
|
||||
|
||||
const worldId = Number(entry.target.getAttribute('data-world-id') || 0)
|
||||
const item = items.find((candidate) => Number(candidate.id) === worldId)
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
|
||||
trackWorldSourceImpression({
|
||||
worldId: item.id,
|
||||
worldTitle: item.title || item.teaser_title || '',
|
||||
sourceSurface: analyticsContext.sourceSurface,
|
||||
sourceDetail: analyticsContext.sourceDetail ? `${analyticsContext.sourceDetail}:selector` : 'selector',
|
||||
sectionKey: 'community_submissions',
|
||||
})
|
||||
observer.unobserve(entry.target)
|
||||
})
|
||||
}, { threshold: [0.35] })
|
||||
|
||||
refs.forEach(([, node]) => {
|
||||
if (node) {
|
||||
observer.observe(node)
|
||||
}
|
||||
})
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [analyticsContext?.sourceDetail, analyticsContext?.sourceSurface, items])
|
||||
|
||||
return (
|
||||
<section className={`rounded-[28px] border border-white/10 bg-white/[0.03] p-5 ${className}`.trim()}>
|
||||
@@ -62,98 +111,144 @@ export default function WorldSubmissionSelector({
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">{items.filter((item) => item.selected).length} selected</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<UploadWorldHighlightCard
|
||||
world={highlightedWorld}
|
||||
sourceSurface={analyticsContext?.sourceSurface || ''}
|
||||
sourceDetail={analyticsContext?.sourceDetail ? `${analyticsContext.sourceDetail}:highlight` : 'highlight'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="mt-5 rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">{emptyMessage}</div>
|
||||
) : (
|
||||
<div className="mt-5 grid gap-4 xl:grid-cols-2">
|
||||
<div className="mt-5 grid gap-3">
|
||||
{items.map((item) => {
|
||||
const checked = Boolean(item.selected)
|
||||
const locked = Boolean(item.selection_locked)
|
||||
const combinedDateLabel = dateBadgeLabel(item)
|
||||
|
||||
return (
|
||||
<div key={item.id} className={`overflow-hidden rounded-[24px] border ${checked ? 'border-sky-300/25 bg-sky-400/[0.07]' : 'border-white/10 bg-black/20'}`}>
|
||||
<div
|
||||
key={item.id}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
itemRefs.current.set(item.id, node)
|
||||
} else {
|
||||
itemRefs.current.delete(item.id)
|
||||
}
|
||||
}}
|
||||
data-world-id={item.id}
|
||||
className={`overflow-hidden rounded-[24px] border transition-colors ${checked ? 'border-sky-300/25 bg-sky-400/[0.07]' : 'border-white/10 bg-black/20'}`}
|
||||
>
|
||||
{/* ── Compact row (always visible) ── */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !locked && onToggle?.(item.id)}
|
||||
onClick={() => {
|
||||
if (locked) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!checked && analyticsContext?.sourceSurface) {
|
||||
trackWorldAnalytics('world_submission_started', {
|
||||
world_id: item.id,
|
||||
source_surface: analyticsContext.sourceSurface,
|
||||
source_detail: analyticsContext.sourceDetail || '',
|
||||
section_key: 'community_submissions',
|
||||
entity_type: 'world',
|
||||
entity_id: item.id,
|
||||
entity_title: item.title || '',
|
||||
})
|
||||
}
|
||||
|
||||
onToggle?.(item.id)
|
||||
}}
|
||||
disabled={locked}
|
||||
className="w-full p-4 text-left disabled:cursor-not-allowed disabled:opacity-100"
|
||||
className="flex w-full items-center gap-4 p-4 text-left disabled:cursor-not-allowed"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-[auto_minmax(0,1fr)_auto] md:items-start">
|
||||
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-[20px] border border-white/10 bg-slate-950/80">
|
||||
{item.cover_url ? (
|
||||
<img src={item.cover_url} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_48%),linear-gradient(135deg,_rgba(15,23,42,0.96),_rgba(2,6,23,0.96))] text-slate-500">
|
||||
<i className="fa-solid fa-globe text-lg" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-white">{item.title}</h3>
|
||||
{item.status_label ? (
|
||||
<span className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] ${statusTone(item)}`}>
|
||||
{item.status_label}
|
||||
</span>
|
||||
) : null}
|
||||
{item.participation_mode_label ? (
|
||||
<span className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] ${modeTone(item.participation_mode)}`}>
|
||||
{item.participation_mode_label}
|
||||
</span>
|
||||
) : null}
|
||||
{/* Thumbnail */}
|
||||
<div className="h-12 w-12 shrink-0 overflow-hidden rounded-2xl border border-white/10 bg-slate-950/80">
|
||||
{item.cover_url ? (
|
||||
<img src={item.cover_url} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_48%),linear-gradient(135deg,_rgba(15,23,42,0.96),_rgba(2,6,23,0.96))] text-slate-500">
|
||||
<i className="fa-solid fa-globe text-sm" />
|
||||
</div>
|
||||
|
||||
{item.tagline ? <p className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{item.tagline}</p> : null}
|
||||
</div>
|
||||
|
||||
<span className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-xs md:mt-0.5 ${checked ? 'border-sky-300/40 bg-sky-400/20 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-500'}`}>
|
||||
{checked ? '✓' : ''}
|
||||
</span>
|
||||
|
||||
{item.summary ? <p className="text-sm leading-6 text-slate-300 md:col-span-3">{item.summary}</p> : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs text-slate-300 md:col-span-3">
|
||||
{combinedDateLabel ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">{combinedDateLabel}</span> : null}
|
||||
{item.can_resubmit ? <span className="rounded-full border border-amber-300/25 bg-amber-400/10 px-3 py-1 text-amber-100">Can re-add</span> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title + badges */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{(Array.isArray(item.status_badges) ? item.status_badges : []).map((badge) => <WorldStatusBadge key={`${item.id}-${badge.label}`} badge={badge} />)}
|
||||
{item.participation_mode_label ? <span className={`rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] ${modeTone(item.participation_mode)}`}>{item.participation_mode_label}</span> : null}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">{item.teaser_title || item.title}</div>
|
||||
{item.tagline ? <div className="truncate text-[11px] uppercase tracking-[0.14em] text-slate-500">{item.tagline}</div> : null}
|
||||
</div>
|
||||
|
||||
{/* Checkbox */}
|
||||
<span className={`inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs ${checked ? 'border-sky-300/40 bg-sky-400/20 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-500'}`}>
|
||||
{checked ? <i className="fa-solid fa-check text-[10px]" /> : null}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="border-t border-white/10 px-4 py-4">
|
||||
{item.submission_guidelines ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm leading-6 text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">World guidelines</div>
|
||||
<div className="mt-2">{item.submission_guidelines}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{/* ── Expanded details (only when checked) ── */}
|
||||
{checked ? (
|
||||
<div className="border-t border-white/10 px-4 pb-4 pt-4">
|
||||
{/* Full description */}
|
||||
{(item.teaser_summary || item.summary) ? (
|
||||
<p className="text-sm leading-6 text-slate-300">{item.teaser_summary || item.summary}</p>
|
||||
) : null}
|
||||
|
||||
{item.selection_locked_reason ? (
|
||||
<div className="mt-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-400">{item.selection_locked_reason}</div>
|
||||
) : null}
|
||||
{/* Date/window chips */}
|
||||
{(combinedDateLabel || item.promotion_window_label) ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs text-slate-300">
|
||||
{combinedDateLabel ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">{combinedDateLabel}</span> : null}
|
||||
{item.promotion_window_label ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">{item.promotion_window_label}</span> : null}
|
||||
{item.can_resubmit ? <span className="rounded-full border border-amber-300/25 bg-amber-400/10 px-3 py-1 text-amber-100">Can re-add</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{item.reviewer_note ? (
|
||||
<div className="mt-3 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm text-amber-50">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80">Moderator note</div>
|
||||
<div className="mt-2 leading-6">{item.reviewer_note}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<WorldCampaignMeta world={item} className="mt-3" />
|
||||
|
||||
{checked && item.submission_note_enabled ? (
|
||||
<label className="mt-3 grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Creator note</span>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={item.note || ''}
|
||||
onChange={(event) => onNoteChange?.(item.id, event.target.value)}
|
||||
disabled={locked}
|
||||
placeholder="Optional note for world moderators: fit, context, challenge angle, or why this artwork belongs here."
|
||||
className="nova-scrollbar rounded-[20px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
{/* Guidelines */}
|
||||
{item.submission_guidelines ? (
|
||||
<div className="mt-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm leading-6 text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">World guidelines</div>
|
||||
<div className="mt-2">{item.submission_guidelines}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Locked reason */}
|
||||
{item.selection_locked_reason ? (
|
||||
<div className="mt-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-400">{item.selection_locked_reason}</div>
|
||||
) : null}
|
||||
|
||||
{/* Moderator note */}
|
||||
{item.reviewer_note ? (
|
||||
<div className="mt-3 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm text-amber-50">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80">Moderator note</div>
|
||||
<div className="mt-2 leading-6">{item.reviewer_note}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Creator note */}
|
||||
{item.submission_note_enabled ? (
|
||||
<label className="mt-3 grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Creator note <span className="normal-case tracking-normal text-slate-600">(optional)</span></span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={item.note || ''}
|
||||
onChange={(event) => onNoteChange?.(item.id, event.target.value)}
|
||||
disabled={locked}
|
||||
placeholder="Context for world moderators: fit, angle, or why this artwork belongs here."
|
||||
className="nova-scrollbar rounded-[20px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none placeholder:text-slate-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
36
resources/js/components/worlds/WorldsIndexSection.jsx
Normal file
36
resources/js/components/worlds/WorldsIndexSection.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react'
|
||||
import WorldCard from './WorldCard'
|
||||
|
||||
function defaultRenderItem(item, sourceProps) {
|
||||
return <WorldCard key={item.id} world={item} compact {...sourceProps} />
|
||||
}
|
||||
|
||||
export default function WorldsIndexSection({ title, description, items = [], emptyMessage = '', countLabel = 'worlds', renderItem = defaultRenderItem, sourceSurface = '', sourceDetail = '' }) {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
if (!emptyMessage) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-10 rounded-[28px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">
|
||||
<div className="font-semibold text-white">{title}</div>
|
||||
<div className="mt-2">{emptyMessage}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-10">
|
||||
<div className="mb-5 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">{title}</h2>
|
||||
{description ? <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">{description}</p> : null}
|
||||
</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">{items.length} {countLabel}</div>
|
||||
</div>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{items.map((item) => renderItem(item, { sourceSurface, sourceDetail }))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function WorldDuplicateActionMenu({ duplicateUrl, newEditionUrl, canCreateEdition, onDuplicate, onCreateEdition }) {
|
||||
export default function WorldDuplicateActionMenu({ duplicateUrl, newEditionUrl, canCreateEdition, onDuplicate, onCreateEdition, copyModeCount = 0 }) {
|
||||
if (!duplicateUrl && !newEditionUrl) {
|
||||
return null
|
||||
}
|
||||
@@ -19,7 +19,8 @@ export default function WorldDuplicateActionMenu({ duplicateUrl, newEditionUrl,
|
||||
</div>
|
||||
|
||||
{!canCreateEdition ? <div className="mt-3 text-xs leading-5 text-slate-500">Next-edition creation unlocks once this world has recurrence data.</div> : null}
|
||||
<div className="mt-3 text-xs leading-5 text-slate-500">Template creation is prepared through duplication. A dedicated preset/template browser can be layered on top later without changing the editor data model.</div>
|
||||
{copyModeCount > 1 ? <div className="mt-3 text-xs leading-5 text-slate-500">Each action lets you choose whether to carry over curated relations or start from a clean structural shell.</div> : null}
|
||||
<div className="mt-3 text-xs leading-5 text-slate-500">Next-edition drafts preserve the recurrence key, increment the edition year, and reset live dates plus homepage flags so the new edition starts clean.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import Modal from '../../ui/Modal'
|
||||
|
||||
function SearchResultList({ items, loading, selectedId, onSelect }) {
|
||||
if (loading) {
|
||||
return <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">Searching group challenges…</div>
|
||||
}
|
||||
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">Search by challenge title, slug, or group name to link a primary challenge.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{items.map((item) => (
|
||||
<button key={item.id} type="button" onClick={() => onSelect(item)} className={`min-w-0 flex items-start gap-3 rounded-[24px] border px-4 py-4 text-left transition ${String(selectedId) === String(item.id) ? 'border-emerald-300/25 bg-emerald-400/10' : 'border-white/10 bg-black/20 hover:border-white/20'}`}>
|
||||
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-2xl border border-white/10 bg-slate-950">
|
||||
{item.image ? <img src={item.image} alt="" className="h-full w-full object-cover" /> : null}
|
||||
{!item.image && item.avatar ? <img src={item.avatar} alt="" className="h-full w-full object-cover" /> : null}
|
||||
{!item.image && !item.avatar ? <div className="flex h-full w-full items-center justify-center text-slate-500"><i className="fa-solid fa-trophy" /></div> : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{item.entity_label ? <span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100">{item.entity_label}</span> : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold text-white">{item.title}</div>
|
||||
{item.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{item.subtitle}</div> : null}
|
||||
{item.description ? <div className="mt-2 line-clamp-2 text-sm leading-6 text-slate-400">{item.description}</div> : null}
|
||||
{Array.isArray(item.meta) && item.meta.length > 0 ? <div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-500">{item.meta.map((meta) => <span key={meta}>{meta}</span>)}</div> : null}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WorldLinkedChallengePickerModal({ open, onClose, onSave, initialChallenge, searchEntities }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selected, setSelected] = useState(initialChallenge || null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
setQuery(initialChallenge?.title || '')
|
||||
setSelected(initialChallenge || null)
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
}, [open, initialChallenge])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
return undefined
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const timeoutId = window.setTimeout(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const items = await searchEntities('challenge', query || '')
|
||||
if (!cancelled) {
|
||||
setResults(Array.isArray(items) ? items : [])
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, query ? 220 : 0)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}, [open, query, searchEntities])
|
||||
|
||||
const selectedPreview = useMemo(() => selected || null, [selected])
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<button type="button" onClick={onClose} className="rounded-full border border-white/10 px-4 py-2 text-sm font-semibold text-white">Cancel</button>
|
||||
<button type="button" onClick={() => selectedPreview && onSave(selectedPreview)} disabled={!selectedPreview} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100 disabled:cursor-not-allowed disabled:opacity-50">Link challenge</button>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Link primary challenge" size="2xl" footer={footer}>
|
||||
<div className="grid gap-5 overflow-x-hidden">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search</span>
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search challenge title, slug, or group" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
|
||||
<SearchResultList items={results} loading={loading} selectedId={selectedPreview?.id} onSelect={(item) => {
|
||||
setSelected(item)
|
||||
setQuery(item.title)
|
||||
}} />
|
||||
|
||||
{selectedPreview ? (
|
||||
<div className="rounded-[24px] border border-emerald-300/20 bg-emerald-400/10 p-4 text-sm text-emerald-50">
|
||||
<div className="break-words font-semibold">Selected: {selectedPreview.title}</div>
|
||||
{selectedPreview.subtitle ? <div className="mt-1 break-words text-xs uppercase tracking-[0.14em] text-emerald-100/70">{selectedPreview.subtitle}</div> : null}
|
||||
{Array.isArray(selectedPreview.meta) && selectedPreview.meta.length > 0 ? <div className="mt-2 flex flex-wrap gap-2 text-xs text-emerald-100/75">{selectedPreview.meta.map((entry) => <span key={entry}>{entry}</span>)}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -28,10 +28,13 @@ export default function WorldMediaUploadField({
|
||||
const [error, setError] = useState('')
|
||||
const [meta, setMeta] = useState(null)
|
||||
|
||||
const csrfToken = useMemo(
|
||||
() => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
[],
|
||||
)
|
||||
const csrfToken = useMemo(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return ''
|
||||
}
|
||||
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}, [])
|
||||
|
||||
const deleteTemporaryUpload = async (path) => {
|
||||
if (!deleteUrl || !path) return
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import Modal from '../../ui/Modal'
|
||||
|
||||
function SearchResultList({ items, loading, selectedId, onSelect }) {
|
||||
if (loading) {
|
||||
return <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">Searching recap articles…</div>
|
||||
}
|
||||
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">Search published news by title, slug, or category to link a recap article.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{items.map((item) => (
|
||||
<button key={item.id} type="button" onClick={() => onSelect(item)} className={`min-w-0 flex items-start gap-3 rounded-[24px] border px-4 py-4 text-left transition ${String(selectedId) === String(item.id) ? 'border-emerald-300/25 bg-emerald-400/10' : 'border-white/10 bg-black/20 hover:border-white/20'}`}>
|
||||
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-2xl border border-white/10 bg-slate-950">
|
||||
{item.image ? <img src={item.image} alt="" className="h-full w-full object-cover" /> : null}
|
||||
{!item.image && item.avatar ? <img src={item.avatar} alt="" className="h-full w-full object-cover" /> : null}
|
||||
{!item.image && !item.avatar ? <div className="flex h-full w-full items-center justify-center text-slate-500"><i className="fa-solid fa-newspaper" /></div> : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{item.entity_label ? <span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100">{item.entity_label}</span> : null}
|
||||
<div className="mt-2 text-sm font-semibold text-white">{item.title}</div>
|
||||
{item.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{item.subtitle}</div> : null}
|
||||
{item.description ? <div className="mt-2 line-clamp-2 text-sm leading-6 text-slate-400">{item.description}</div> : null}
|
||||
{Array.isArray(item.meta) && item.meta.length > 0 ? <div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-500">{item.meta.map((meta) => <span key={meta}>{meta}</span>)}</div> : null}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WorldRecapArticlePickerModal({ open, onClose, onSave, initialArticle, searchEntities }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selected, setSelected] = useState(initialArticle || null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
setQuery(initialArticle?.title || '')
|
||||
setSelected(initialArticle || null)
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
}, [open, initialArticle])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
return undefined
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const timeoutId = window.setTimeout(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const items = await searchEntities('news', query || '')
|
||||
if (!cancelled) {
|
||||
setResults(Array.isArray(items) ? items : [])
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, query ? 220 : 0)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}, [open, query, searchEntities])
|
||||
|
||||
const selectedPreview = useMemo(() => selected || null, [selected])
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<button type="button" onClick={onClose} className="rounded-full border border-white/10 px-4 py-2 text-sm font-semibold text-white">Cancel</button>
|
||||
<button type="button" onClick={() => selectedPreview && onSave(selectedPreview)} disabled={!selectedPreview} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100 disabled:cursor-not-allowed disabled:opacity-50">Link article</button>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Link recap article" size="2xl" footer={footer}>
|
||||
<div className="grid gap-5 overflow-x-hidden">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search</span>
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search article title, slug, or category" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
|
||||
<SearchResultList items={results} loading={loading} selectedId={selectedPreview?.id} onSelect={(item) => {
|
||||
setSelected(item)
|
||||
setQuery(item.title)
|
||||
}} />
|
||||
|
||||
{selectedPreview ? (
|
||||
<div className="rounded-[24px] border border-emerald-300/20 bg-emerald-400/10 p-4 text-sm text-emerald-50">
|
||||
<div className="break-words font-semibold">Selected: {selectedPreview.title}</div>
|
||||
{selectedPreview.subtitle ? <div className="mt-1 break-words text-xs uppercase tracking-[0.14em] text-emerald-100/70">{selectedPreview.subtitle}</div> : null}
|
||||
{Array.isArray(selectedPreview.meta) && selectedPreview.meta.length > 0 ? <div className="mt-2 flex flex-wrap gap-2 text-xs text-emerald-100/75">{selectedPreview.meta.map((entry) => <span key={entry}>{entry}</span>)}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export default function WorldRecurrenceHelper({ enabled, recurrenceKey, editionY
|
||||
<p>Use the recurrence key to identify the campaign family. Example: <span className="font-semibold text-white">{exampleKey}</span>.</p>
|
||||
<p>Use the edition year for the specific annual or seasonal instance. Example: <span className="font-semibold text-white">{exampleYear}</span>.</p>
|
||||
<p className="text-sky-100">Example output: {exampleKey === '' ? 'Halloween' : exampleKey.replace(/-/g, ' ')} {exampleYear} is part of the recurring world <span className="font-semibold text-white">{exampleKey}</span>.</p>
|
||||
<p>The family route resolves to the current or latest edition, while archived editions remain available on a year-specific URL.</p>
|
||||
</div>
|
||||
|
||||
{recurrenceKeyError || editionYearError ? (
|
||||
|
||||
@@ -19,25 +19,35 @@ function typeLabel(value) {
|
||||
}
|
||||
|
||||
function promotionState(world, state) {
|
||||
if (!world?.is_featured) {
|
||||
if (!world?.is_active_campaign) {
|
||||
return {
|
||||
label: 'Public page only',
|
||||
message: 'This world will live at its own URL, but it is not currently marked for homepage or Worlds spotlight placement.',
|
||||
message: 'This world will live at its own URL, but it is not currently marked as an active campaign for stronger discovery surfaces.',
|
||||
tone: 'slate',
|
||||
}
|
||||
}
|
||||
|
||||
if (world?.is_homepage_featured && state.label === 'Live') {
|
||||
return {
|
||||
label: 'Homepage spotlight ready',
|
||||
message: 'This campaign is active and flagged for homepage spotlight, so it is eligible for the strongest public placement.',
|
||||
tone: 'emerald',
|
||||
}
|
||||
}
|
||||
|
||||
if (state.label === 'Live') {
|
||||
return {
|
||||
label: 'Active seasonal promotion',
|
||||
message: 'Featured promotion is enabled and the world is live, so it is ready for homepage spotlight and promoted Worlds surfaces.',
|
||||
label: 'Active campaign',
|
||||
message: 'Campaign activation is enabled and the world is currently live across promotion-aware surfaces.',
|
||||
tone: 'emerald',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label: 'Homepage spotlight eligible',
|
||||
message: 'Featured promotion is enabled. Once the world is live, it becomes eligible for homepage and Worlds spotlight treatment.',
|
||||
label: world?.is_homepage_featured ? 'Homepage promotion queued' : 'Campaign promotion queued',
|
||||
message: world?.is_homepage_featured
|
||||
? 'Homepage spotlight is enabled. Once the campaign goes live, it can occupy the main homepage promotion slot.'
|
||||
: 'Campaign activation is enabled. Once the world goes live, upload and worlds surfaces can prioritize it.',
|
||||
tone: 'sky',
|
||||
}
|
||||
}
|
||||
@@ -107,6 +117,8 @@ export default function WorldSummaryCard({ world, themeLabel, relationCount, ena
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Theme preset</div><div className="mt-2 text-sm font-semibold text-white">{themeLabel || 'No preset'}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Campaign window</div><div className="mt-2 text-sm font-semibold text-white">{world?.starts_at || world?.ends_at ? `${formatDateTime(world?.starts_at)} to ${formatDateTime(world?.ends_at)}` : 'Open ended'}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Publish at</div><div className="mt-2 text-sm font-semibold text-white">{formatDateTime(world?.published_at)}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Promotion window</div><div className="mt-2 text-sm font-semibold text-white">{world?.promotion_starts_at || world?.promotion_ends_at ? `${formatDateTime(world?.promotion_starts_at)} to ${formatDateTime(world?.promotion_ends_at)}` : 'Uses campaign window'}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Activation</div><div className="mt-2 text-sm font-semibold text-white">{world?.is_active_campaign ? (world?.is_homepage_featured ? 'Active + homepage featured' : 'Active campaign') : 'Standalone public page'}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Recurrence</div><div className="mt-2 text-sm font-semibold text-white">{world?.is_recurring ? `${world?.recurrence_key || 'recurring'} ${world?.edition_year || ''}`.trim() : 'One-off world'}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Editorial setup</div><div className="mt-2 text-sm font-semibold text-white">{relationCount} relations · {enabledSectionsCount} enabled sections</div></div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react'
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat('en-US').format(Number(value || 0))
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
return `${Math.round(Number(value || 0) * 100)}%`
|
||||
}
|
||||
|
||||
export default function WorldAnalyticsChallengePanel({ challenge = {} }) {
|
||||
if (!challenge?.linked_challenge_id) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cards = [
|
||||
['Challenge CTA clicks', challenge.challenge_cta_clicks, 'number'],
|
||||
['Recap clicks', challenge.recap_clicks, 'number'],
|
||||
['Entry clicks', challenge.entry_clicks, 'number'],
|
||||
['Winner clicks', challenge.winner_clicks, 'number'],
|
||||
['Finalist clicks', challenge.finalist_clicks, 'number'],
|
||||
['Total challenge clicks', challenge.total_clicks, 'number'],
|
||||
['Submission starts', challenge.submission_starts, 'number'],
|
||||
['Created submissions', challenge.submissions_created, 'number'],
|
||||
['Click-to-submit', challenge.click_to_submission_conversion, 'percent'],
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Challenge-linked engagement</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{cards.map(([label, value, type]) => (
|
||||
<div key={label} className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">{label}</div>
|
||||
<div className="mt-2 text-xl font-semibold text-white">{type === 'percent' ? formatPercent(value) : formatNumber(value)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react'
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat('en-US').format(Number(value || 0))
|
||||
}
|
||||
|
||||
export default function WorldAnalyticsEditionComparisonCard({ comparison = null }) {
|
||||
if (!comparison?.editions || comparison.editions.length < 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Recurring edition comparison</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{comparison.label}</div>
|
||||
</div>
|
||||
<div className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-300">{comparison.recurrence_key}</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm text-slate-300">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10 text-[11px] uppercase tracking-[0.16em] text-slate-500">
|
||||
<th className="pb-3 pr-4">Edition</th>
|
||||
<th className="pb-3 pr-4">Views</th>
|
||||
<th className="pb-3 pr-4">Unique</th>
|
||||
<th className="pb-3 pr-4">Submissions</th>
|
||||
<th className="pb-3 pr-4">Featured</th>
|
||||
<th className="pb-3 pr-4">Challenge</th>
|
||||
<th className="pb-3">Rewards</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{comparison.editions.map((edition) => (
|
||||
<tr key={edition.world_id} className="border-b border-white/[0.06] last:border-b-0">
|
||||
<td className="py-3 pr-4">
|
||||
<div className="font-semibold text-white">{edition.title}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{edition.edition_year || 'Unversioned'}{edition.is_current_world ? ' • current editor' : ''}</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4">{formatNumber(edition.metrics?.views)}</td>
|
||||
<td className="py-3 pr-4">{formatNumber(edition.metrics?.unique_visitors)}</td>
|
||||
<td className="py-3 pr-4">{formatNumber(edition.metrics?.submissions)}</td>
|
||||
<td className="py-3 pr-4">{formatNumber(edition.metrics?.featured_participations)}</td>
|
||||
<td className="py-3 pr-4">{formatNumber(edition.metrics?.challenge_clicks)}</td>
|
||||
<td className="py-3">{formatNumber(edition.metrics?.reward_grants)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react'
|
||||
import WorldAnalyticsSummaryCard from './WorldAnalyticsSummaryCard'
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat('en-US').format(Number(value || 0))
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
return `${Math.round(Number(value || 0) * 100)}%`
|
||||
}
|
||||
|
||||
export default function WorldAnalyticsMetricGrid({ summary = {} }) {
|
||||
const cards = [
|
||||
{
|
||||
label: 'Views',
|
||||
value: formatNumber(summary.views),
|
||||
hint: summary.top_source_surface?.label
|
||||
? `Top source: ${summary.top_source_surface.label} • ${formatPercent(summary.top_source_surface.clickthrough_rate)} CTR`
|
||||
: 'Traffic to the world page.',
|
||||
},
|
||||
{
|
||||
label: 'Unique Visitors',
|
||||
value: formatNumber(summary.unique_visitors),
|
||||
hint: 'Distinct visitors in the selected window.',
|
||||
},
|
||||
{
|
||||
label: 'Promotion Impressions',
|
||||
value: formatNumber(summary.promotion_impressions),
|
||||
hint: `Source CTR: ${formatPercent(summary.promotion_clickthrough_rate)}`,
|
||||
tone: 'accent',
|
||||
},
|
||||
{
|
||||
label: 'CTA Clicks',
|
||||
value: formatNumber(summary.cta_clicks),
|
||||
hint: 'Tracked world and challenge actions.',
|
||||
tone: 'accent',
|
||||
},
|
||||
{
|
||||
label: 'Submissions',
|
||||
value: formatNumber(summary.submissions),
|
||||
hint: `Live: ${formatNumber(summary.approved_live_participations)} • Approval: ${formatPercent(summary.approval_rate)}`,
|
||||
tone: 'emerald',
|
||||
},
|
||||
{
|
||||
label: 'Reward Grants',
|
||||
value: formatNumber(summary.reward_grants),
|
||||
hint: `Challenge clicks: ${formatNumber(summary.challenge_clicks)} • View-to-submit: ${formatPercent(summary.view_to_submission_conversion)}`,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{cards.map((card) => <WorldAnalyticsSummaryCard key={card.label} {...card} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import WorldAnalyticsMetricGrid from './WorldAnalyticsMetricGrid'
|
||||
import WorldAnalyticsSourceBreakdown from './WorldAnalyticsSourceBreakdown'
|
||||
import WorldAnalyticsSectionPerformance from './WorldAnalyticsSectionPerformance'
|
||||
import WorldAnalyticsParticipationPanel from './WorldAnalyticsParticipationPanel'
|
||||
import WorldAnalyticsChallengePanel from './WorldAnalyticsChallengePanel'
|
||||
import WorldAnalyticsEditionComparisonCard from './WorldAnalyticsEditionComparisonCard'
|
||||
|
||||
export default function WorldAnalyticsPanel({ analytics = null, world = null }) {
|
||||
const [activeRange, setActiveRange] = useState(analytics?.default_range || '30d')
|
||||
const range = useMemo(() => analytics?.ranges?.[activeRange] || analytics?.ranges?.[analytics?.default_range || '30d'] || null, [activeRange, analytics])
|
||||
|
||||
if (!world?.id || !analytics || !range) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm leading-6 text-slate-400">
|
||||
Analytics will populate after the world starts receiving traffic, clicks, submissions, or rewards.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">World analytics</div>
|
||||
<h3 className="mt-2 text-2xl font-semibold text-white">Campaign performance and editorial signals</h3>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">Traffic, promotion surfaces, engagement, participation, challenge energy, and recurring-edition readiness for this world.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(analytics.range_options || []).map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setActiveRange(option.value)}
|
||||
className={`rounded-full border px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] transition ${activeRange === option.value ? 'border-sky-300/25 bg-sky-400/12 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-300 hover:bg-white/[0.08]'}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WorldAnalyticsMetricGrid summary={range.summary} />
|
||||
<WorldAnalyticsSourceBreakdown sources={range.sources} />
|
||||
<WorldAnalyticsSectionPerformance sections={range.section_performance} entities={range.entity_performance} />
|
||||
<WorldAnalyticsParticipationPanel participation={range.participation} />
|
||||
<WorldAnalyticsChallengePanel challenge={range.challenge} />
|
||||
<WorldAnalyticsEditionComparisonCard comparison={analytics.edition_comparison} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react'
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat('en-US').format(Number(value || 0))
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
return `${Math.round(Number(value || 0) * 100)}%`
|
||||
}
|
||||
|
||||
export default function WorldAnalyticsParticipationPanel({ participation = {} }) {
|
||||
const currentCards = [
|
||||
['Pending', participation.pending],
|
||||
['Live', participation.live],
|
||||
['Removed', participation.removed],
|
||||
['Blocked', participation.blocked],
|
||||
['Featured', participation.featured],
|
||||
]
|
||||
|
||||
const activityCards = [
|
||||
['Submitted', participation.submitted],
|
||||
['Approved', participation.approved],
|
||||
['Removed Actions', participation.removed_actions],
|
||||
['Blocked Actions', participation.blocked_actions],
|
||||
['Featured Actions', participation.featured_actions],
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Participation state</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{currentCards.map(([label, value]) => (
|
||||
<div key={label} className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">{label}</div>
|
||||
<div className="mt-2 text-xl font-semibold text-white">{formatNumber(value)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Participation funnel</div>
|
||||
<div className="mt-4 grid gap-3">
|
||||
{activityCards.map(([label, value]) => (
|
||||
<div key={label} className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
||||
<div className="text-sm font-semibold text-white">{label}</div>
|
||||
<div className="text-sm font-semibold text-sky-100">{formatNumber(value)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300">Approval rate: <span className="font-semibold text-white">{formatPercent(participation.approval_rate)}</span></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300">Removal rate: <span className="font-semibold text-white">{formatPercent(participation.removal_rate)}</span></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300">Block rate: <span className="font-semibold text-white">{formatPercent(participation.block_rate)}</span></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300">View-to-submit: <span className="font-semibold text-white">{formatPercent(participation.view_to_submission_conversion)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import WorldAnalyticsSummaryCard from './WorldAnalyticsSummaryCard'
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat('en-US').format(Number(value || 0))
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
return `${Math.round(Number(value || 0) * 100)}%`
|
||||
}
|
||||
|
||||
function metricValue(row, key) {
|
||||
switch (key) {
|
||||
case 'conversion':
|
||||
return formatPercent(row.view_to_submission_conversion)
|
||||
case 'reward_grants':
|
||||
return `${formatNumber(row.reward_grants)} grants`
|
||||
case 'submissions':
|
||||
return `${formatNumber(row.submissions)} submissions`
|
||||
case 'unique_visitors':
|
||||
return `${formatNumber(row.unique_visitors)} visitors`
|
||||
case 'views':
|
||||
default:
|
||||
return `${formatNumber(row.views)} views`
|
||||
}
|
||||
}
|
||||
|
||||
function LeaderboardColumn({ title, rows = [], metricKey = 'views' }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">{title}</div>
|
||||
<div className="mt-4 grid gap-3">
|
||||
{rows.length > 0 ? rows.map((row, index) => (
|
||||
<a key={`${metricKey}-${row.world_id}`} href={row.edit_url} className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 transition hover:border-white/20 hover:bg-white/[0.05]">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">#{index + 1}</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">{row.title}</div>
|
||||
<div className="mt-1 text-xs text-slate-400">/{row.slug}{row.edition_year ? ` • ${row.edition_year}` : ''}</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-xs font-semibold uppercase tracking-[0.14em] text-sky-100">{metricValue(row, metricKey)}</div>
|
||||
</div>
|
||||
</a>
|
||||
)) : <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] px-4 py-5 text-sm text-slate-400">No activity recorded for this range yet.</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WorldAnalyticsPortfolioPanel({ analytics = null }) {
|
||||
const rangeOptions = Array.isArray(analytics?.range_options) ? analytics.range_options : []
|
||||
const defaultRange = analytics?.default_range || rangeOptions[0]?.value || '30d'
|
||||
const [selectedRange, setSelectedRange] = useState(defaultRange)
|
||||
|
||||
const range = useMemo(() => analytics?.ranges?.[selectedRange] || {}, [analytics, selectedRange])
|
||||
const summary = range.summary || {}
|
||||
const leaderboards = range.leaderboards || {}
|
||||
|
||||
if (!analytics || rangeOptions.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const summaryCards = [
|
||||
{
|
||||
label: 'Tracked Worlds',
|
||||
value: formatNumber(summary.tracked_worlds),
|
||||
hint: 'Worlds with activity in this range.',
|
||||
},
|
||||
{
|
||||
label: 'Views',
|
||||
value: formatNumber(summary.views),
|
||||
hint: 'Portfolio traffic across all worlds.',
|
||||
tone: 'accent',
|
||||
},
|
||||
{
|
||||
label: 'Promotion Impressions',
|
||||
value: formatNumber(summary.promotion_impressions),
|
||||
hint: 'Observed spotlight, rail, and upload placements.',
|
||||
},
|
||||
{
|
||||
label: 'Submissions',
|
||||
value: formatNumber(summary.submissions),
|
||||
hint: `Rewards granted: ${formatNumber(summary.reward_grants)}`,
|
||||
tone: 'emerald',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Portfolio analytics</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Cross-world performance</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">Use this snapshot to see which worlds are drawing traffic, driving participation, and converting attention into submissions.</p>
|
||||
</div>
|
||||
<div className="inline-flex flex-wrap gap-2 rounded-full border border-white/10 bg-black/20 p-1">
|
||||
{rangeOptions.map((option) => {
|
||||
const active = option.value === selectedRange
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setSelectedRange(option.value)}
|
||||
className={`rounded-full px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] transition ${active ? 'bg-sky-400/15 text-sky-100' : 'text-slate-400 hover:text-white'}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{summaryCards.map((card) => <WorldAnalyticsSummaryCard key={card.label} {...card} />)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 xl:grid-cols-2">
|
||||
<LeaderboardColumn title="Top by views" rows={leaderboards.views || []} metricKey="views" />
|
||||
<LeaderboardColumn title="Top by unique visitors" rows={leaderboards.unique_visitors || []} metricKey="unique_visitors" />
|
||||
<LeaderboardColumn title="Top by submissions" rows={leaderboards.submissions || []} metricKey="submissions" />
|
||||
<LeaderboardColumn title="Best view-to-submit conversion" rows={leaderboards.conversion || []} metricKey="conversion" />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react'
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat('en-US').format(Number(value || 0))
|
||||
}
|
||||
|
||||
export default function WorldAnalyticsSectionPerformance({ sections = [], entities = [] }) {
|
||||
return (
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Section performance</div>
|
||||
<div className="mt-4 grid gap-3">
|
||||
{Array.isArray(sections) && sections.length > 0 ? sections.slice(0, 6).map((item) => (
|
||||
<div key={item.section_key} className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{item.label}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{item.section_key}</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-sky-100">{formatNumber(item.clicks)}</div>
|
||||
</div>
|
||||
)) : <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] px-4 py-3 text-sm text-slate-400">No tracked section engagement yet.</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Top clicked entities</div>
|
||||
<div className="mt-4 grid gap-3">
|
||||
{Array.isArray(entities) && entities.length > 0 ? entities.slice(0, 6).map((item) => (
|
||||
<div key={`${item.entity_type}-${item.entity_id}`} className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">{item.entity_title}</div>
|
||||
<div className="text-sm font-semibold text-sky-100">{formatNumber(item.clicks)}</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{item.section_key || item.entity_type}</div>
|
||||
</div>
|
||||
)) : <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] px-4 py-3 text-sm text-slate-400">No linked entity clicks recorded yet.</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat('en-US').format(Number(value || 0))
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
return `${Math.round(Number(value || 0) * 100)}%`
|
||||
}
|
||||
|
||||
export default function WorldAnalyticsSourceBreakdown({ sources = [] }) {
|
||||
if (!Array.isArray(sources) || sources.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const maxViews = Math.max(...sources.map((row) => Number(row.views || 0)), 1)
|
||||
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Source breakdown</div>
|
||||
<div className="mt-4 grid gap-3">
|
||||
{sources.map((row) => (
|
||||
<div key={row.source_surface} className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">{row.label}</div>
|
||||
<div className="text-xs uppercase tracking-[0.14em] text-slate-400">{formatNumber(row.views)} views</div>
|
||||
</div>
|
||||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-white/[0.06]">
|
||||
<div className="h-full rounded-full bg-sky-300/80" style={{ width: `${Math.max(8, (Number(row.views || 0) / maxViews) * 100)}%` }} />
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-3 text-xs text-slate-400">
|
||||
<span>{formatNumber(row.impressions)} impressions</span>
|
||||
<span>{formatNumber(row.unique_visitors)} unique</span>
|
||||
<span>{formatNumber(row.clicks)} source clicks</span>
|
||||
<span>{formatPercent(row.clickthrough_rate)} CTR</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function WorldAnalyticsSummaryCard({ label, value, hint = '', tone = 'default' }) {
|
||||
const toneClass = tone === 'accent'
|
||||
? 'border-sky-300/20 bg-sky-400/10 text-sky-100'
|
||||
: tone === 'emerald'
|
||||
? 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100'
|
||||
: 'border-white/10 bg-black/20 text-white'
|
||||
|
||||
return (
|
||||
<div className={`rounded-[22px] border px-4 py-4 ${toneClass}`}>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] opacity-75">{label}</div>
|
||||
<div className="mt-3 text-2xl font-semibold tracking-[-0.03em]">{value}</div>
|
||||
{hint ? <div className="mt-2 text-sm leading-6 opacity-80">{hint}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react'
|
||||
import WorldSuggestionCard from './WorldSuggestionCard'
|
||||
|
||||
export default function WorldChallengeSuggestionPanel({ group, busyKey, onAddFeatured, onAddSection, onPin, onDismiss, onNotRelevant, onRestore }) {
|
||||
if (!group) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-[24px] border border-sky-300/15 bg-sky-400/[0.06] p-4">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100/80">Challenge-aware suggestions</div>
|
||||
<h3 className="mt-2 text-lg font-semibold text-white">{group.label}</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-300">{group.description}</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100">{group.count} ready</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4">
|
||||
{group.items.map((item) => (
|
||||
<WorldSuggestionCard
|
||||
key={item.key}
|
||||
item={item}
|
||||
busyKey={busyKey === item.key ? busyKey : ''}
|
||||
onAddFeatured={onAddFeatured}
|
||||
onAddSection={onAddSection}
|
||||
onPin={onPin}
|
||||
onDismiss={onDismiss}
|
||||
onNotRelevant={onNotRelevant}
|
||||
onRestore={onRestore}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react'
|
||||
import WorldSuggestionActions from './WorldSuggestionActions'
|
||||
import WorldSuggestionReasonPills from './WorldSuggestionReasonPills'
|
||||
|
||||
function TinyBadge({ children, tone = 'default' }) {
|
||||
const tones = {
|
||||
default: 'border-white/10 bg-white/[0.05] text-slate-200',
|
||||
sky: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
|
||||
emerald: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
|
||||
amber: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
|
||||
rose: 'border-rose-300/20 bg-rose-400/10 text-rose-100',
|
||||
}
|
||||
|
||||
return <span className={`rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${tones[tone] || tones.default}`}>{children}</span>
|
||||
}
|
||||
|
||||
export default function WorldSuggestionCard({ item, busyKey, onAddFeatured, onAddSection, onPin, onDismiss, onNotRelevant, onRestore }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex flex-col gap-4 xl:flex-row">
|
||||
<div className="relative h-24 w-24 shrink-0 overflow-hidden rounded-[20px] border border-white/10 bg-slate-950">
|
||||
{item.image ? <img src={item.image} alt="" className="h-full w-full object-cover" /> : null}
|
||||
{!item.image && item.avatar ? <img src={item.avatar} alt="" className="h-full w-full object-cover" /> : null}
|
||||
{!item.image && !item.avatar ? <div className="flex h-full w-full items-center justify-center text-slate-500"><i className="fa-solid fa-stars" /></div> : null}
|
||||
{item.avatar && item.image ? <img src={item.avatar} alt="" className="absolute bottom-2 left-2 h-9 w-9 rounded-xl border border-white/10 object-cover" /> : null}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{item.entity_label ? <TinyBadge tone="sky">{item.entity_label}</TinyBadge> : null}
|
||||
{item.category_label ? <TinyBadge>{item.category_label}</TinyBadge> : null}
|
||||
{item.signals?.challenge_linked ? <TinyBadge tone="sky">Challenge-linked</TinyBadge> : null}
|
||||
{item.signals?.community_submission ? <TinyBadge tone="emerald">Community signal</TinyBadge> : null}
|
||||
{item.signals?.recurring_history_informed ? <TinyBadge tone="default">Recurring signal</TinyBadge> : null}
|
||||
{item.signals?.analytics_informed ? <TinyBadge tone="amber">Analytics cue</TinyBadge> : null}
|
||||
{item.state?.status === 'pinned' ? <TinyBadge tone="amber">Pinned</TinyBadge> : null}
|
||||
{item.state?.status === 'dismissed' ? <TinyBadge tone="default">Dismissed</TinyBadge> : null}
|
||||
{item.state?.status === 'not_relevant' ? <TinyBadge tone="rose">Not relevant</TinyBadge> : null}
|
||||
{item.score_label ? <TinyBadge tone="emerald">{item.score_label}</TinyBadge> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-base font-semibold text-white">{item.title}</div>
|
||||
{item.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{item.subtitle}</div> : null}
|
||||
</div>
|
||||
<div className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-200">Score {item.score}</div>
|
||||
</div>
|
||||
|
||||
{item.description ? <div className="mt-3 text-sm leading-6 text-slate-300">{item.description}</div> : null}
|
||||
{item.context_label ? <div className="mt-3 text-sm font-medium text-sky-100">{item.context_label}</div> : null}
|
||||
{Array.isArray(item.meta) && item.meta.length > 0 ? <div className="mt-3 flex flex-wrap gap-2 text-xs text-slate-500">{item.meta.map((entry) => <span key={entry}>{entry}</span>)}</div> : null}
|
||||
|
||||
<WorldSuggestionReasonPills reasons={item.reasons} />
|
||||
|
||||
{item.url ? <a href={item.url} target="_blank" rel="noreferrer" className="mt-4 inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300 hover:text-white">Open source entity <i className="fa-solid fa-up-right-from-square" /></a> : null}
|
||||
|
||||
<WorldSuggestionActions
|
||||
item={item}
|
||||
busyKey={busyKey}
|
||||
onAddFeatured={onAddFeatured}
|
||||
onAddSection={onAddSection}
|
||||
onPin={onPin}
|
||||
onDismiss={onDismiss}
|
||||
onNotRelevant={onNotRelevant}
|
||||
onRestore={onRestore}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react'
|
||||
|
||||
const TONE_CLASSES = {
|
||||
default: 'border-white/10 bg-white/[0.05] text-slate-200',
|
||||
slate: 'border-white/10 bg-white/[0.05] text-slate-300',
|
||||
sky: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
|
||||
emerald: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
|
||||
amber: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
|
||||
rose: 'border-rose-300/20 bg-rose-400/10 text-rose-100',
|
||||
}
|
||||
|
||||
export default function WorldSuggestionReasonPills({ reasons = [] }) {
|
||||
if (!Array.isArray(reasons) || reasons.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{reasons.map((reason) => (
|
||||
<span
|
||||
key={`${reason.label}-${reason.tone || 'default'}`}
|
||||
className={`rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${TONE_CLASSES[reason.tone] || TONE_CLASSES.default}`}
|
||||
>
|
||||
{reason.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import WorldChallengeSuggestionPanel from './WorldChallengeSuggestionPanel'
|
||||
import WorldSuggestionCard from './WorldSuggestionCard'
|
||||
import WorldSuggestionFilters from './WorldSuggestionFilters'
|
||||
|
||||
function SummaryPill({ label, value, tone = 'default' }) {
|
||||
const tones = {
|
||||
default: 'border-white/10 bg-white/[0.04] text-slate-200',
|
||||
sky: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
|
||||
emerald: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
|
||||
amber: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border px-4 py-3 ${tones[tone] || tones.default}`}>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] opacity-80">{label}</div>
|
||||
<div className="mt-2 text-lg font-semibold">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function matchesFilters(item, filters) {
|
||||
if (filters.category && item.category_key !== filters.category) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filters.type && item.entity_type !== filters.type) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filters.section && !item.section_targets?.some((target) => target.value === filters.section)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filters.challengeOnly && !item.signals?.challenge_linked) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filters.communityOnly && !item.signals?.community_submission) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filters.recurringOnly && !item.signals?.recurring_history_informed) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filters.analyticsOnly && !item.signals?.analytics_informed) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function sortItems(items, sortMode) {
|
||||
const list = Array.isArray(items) ? [...items] : []
|
||||
|
||||
return list.sort((left, right) => {
|
||||
if (sortMode === 'newest') {
|
||||
return Number(right?.ranking?.freshness_timestamp || 0) - Number(left?.ranking?.freshness_timestamp || 0)
|
||||
}
|
||||
|
||||
if (sortMode === 'performance') {
|
||||
return Number(right?.ranking?.performance_value || 0) - Number(left?.ranking?.performance_value || 0)
|
||||
}
|
||||
|
||||
return Number(right?.score || 0) - Number(left?.score || 0)
|
||||
})
|
||||
}
|
||||
|
||||
export default function WorldSuggestionsPanel({ suggestions, notice = null, worldExists = false, busyKey = '', onAddFeatured, onAddSection, onPin, onDismiss, onNotRelevant, onRestore }) {
|
||||
const [filters, setFilters] = useState({
|
||||
category: '',
|
||||
type: '',
|
||||
section: '',
|
||||
sort: 'relevance',
|
||||
challengeOnly: false,
|
||||
communityOnly: false,
|
||||
recurringOnly: false,
|
||||
analyticsOnly: false,
|
||||
showSuppressed: false,
|
||||
})
|
||||
|
||||
const groups = Array.isArray(suggestions?.groups) ? suggestions.groups : []
|
||||
const pinnedItems = Array.isArray(suggestions?.pinned_items) ? suggestions.pinned_items : []
|
||||
const suppressedItems = Array.isArray(suggestions?.suppressed_items) ? suggestions.suppressed_items : []
|
||||
|
||||
const visibleGroups = useMemo(() => groups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: sortItems((Array.isArray(group.items) ? group.items : []).filter((item) => matchesFilters(item, filters)), filters.sort),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0 || filters.category === group.key), [filters, groups])
|
||||
|
||||
const visiblePinned = useMemo(() => sortItems(pinnedItems.filter((item) => matchesFilters(item, filters)), filters.sort), [filters, pinnedItems])
|
||||
const visibleSuppressed = useMemo(() => sortItems(suppressedItems.filter((item) => matchesFilters(item, filters)), filters.sort), [filters, suppressedItems])
|
||||
|
||||
if (!worldExists) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm leading-6 text-slate-300">
|
||||
Save the world once to unlock editorial suggestions. The suggestion service uses real world metadata, submissions, linked challenge context, and recurring-family signals, so it needs a persisted edition to score against.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">World editorial suggestions</h2>
|
||||
<p className="mt-1 max-w-3xl text-sm leading-6 text-slate-400">Review scored candidate artworks, creators, collections, groups, stories, and challenge standouts without auto-publishing anything into the world.</p>
|
||||
</div>
|
||||
{suggestions?.generated_at ? <div className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">Refreshed {new Date(suggestions.generated_at).toLocaleString()}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<SummaryPill label="Ready now" value={suggestions?.summary?.available_count || 0} tone="emerald" />
|
||||
<SummaryPill label="Pinned" value={suggestions?.summary?.pinned_count || 0} tone="amber" />
|
||||
<SummaryPill label="Suppressed" value={suggestions?.summary?.suppressed_count || 0} />
|
||||
<SummaryPill label="Community signal" value={suggestions?.summary?.community_submission_count || 0} tone="sky" />
|
||||
<SummaryPill label="Analytics cues" value={suggestions?.summary?.analytics_signal_count || 0} tone="amber" />
|
||||
</div>
|
||||
|
||||
{notice ? <div className="mt-4 rounded-[20px] border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm text-sky-100">{notice}</div> : null}
|
||||
</div>
|
||||
|
||||
<WorldSuggestionFilters filters={suggestions?.filters || {}} value={filters} onChange={setFilters} />
|
||||
|
||||
{visiblePinned.length > 0 ? (
|
||||
<div className="rounded-[28px] border border-amber-300/15 bg-amber-400/[0.05] p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Pinned for later</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-300">These suggestions stay separate from the public world until you explicitly attach them.</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">{visiblePinned.length} pinned</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4">
|
||||
{visiblePinned.map((item) => (
|
||||
<WorldSuggestionCard
|
||||
key={item.key}
|
||||
item={item}
|
||||
busyKey={busyKey === item.key ? busyKey : ''}
|
||||
onAddFeatured={onAddFeatured}
|
||||
onAddSection={onAddSection}
|
||||
onPin={onPin}
|
||||
onDismiss={onDismiss}
|
||||
onNotRelevant={onNotRelevant}
|
||||
onRestore={onRestore}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{visibleGroups.length > 0 ? visibleGroups.map((group) => (
|
||||
group.key === 'challenge' ? (
|
||||
<WorldChallengeSuggestionPanel
|
||||
key={group.key}
|
||||
group={group}
|
||||
busyKey={busyKey}
|
||||
onAddFeatured={onAddFeatured}
|
||||
onAddSection={onAddSection}
|
||||
onPin={onPin}
|
||||
onDismiss={onDismiss}
|
||||
onNotRelevant={onNotRelevant}
|
||||
onRestore={onRestore}
|
||||
/>
|
||||
) : (
|
||||
<div key={group.key} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{group.label}</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-400">{group.description}</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{group.items.length} ready</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4">
|
||||
{group.items.length > 0 ? group.items.map((item) => (
|
||||
<WorldSuggestionCard
|
||||
key={item.key}
|
||||
item={item}
|
||||
busyKey={busyKey === item.key ? busyKey : ''}
|
||||
onAddFeatured={onAddFeatured}
|
||||
onAddSection={onAddSection}
|
||||
onPin={onPin}
|
||||
onDismiss={onDismiss}
|
||||
onNotRelevant={onNotRelevant}
|
||||
onRestore={onRestore}
|
||||
/>
|
||||
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">{group.empty_label}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)) : (
|
||||
<div className="rounded-[28px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm leading-6 text-slate-300">
|
||||
No suggestions match the current filters. Change the filters or save new world metadata to refresh the candidate pool.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filters.showSuppressed ? (
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Suppressed suggestions</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-400">Dismissed and not-relevant items stay out of the active queue until you restore them.</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{visibleSuppressed.length} hidden</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4">
|
||||
{visibleSuppressed.length > 0 ? visibleSuppressed.map((item) => (
|
||||
<WorldSuggestionCard
|
||||
key={item.key}
|
||||
item={item}
|
||||
busyKey={busyKey === item.key ? busyKey : ''}
|
||||
onAddFeatured={onAddFeatured}
|
||||
onAddSection={onAddSection}
|
||||
onPin={onPin}
|
||||
onDismiss={onDismiss}
|
||||
onNotRelevant={onNotRelevant}
|
||||
onRestore={onRestore}
|
||||
/>
|
||||
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">No suppressed suggestions match the current filters.</div>}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
212
resources/js/lib/worldAnalytics.js
Normal file
212
resources/js/lib/worldAnalytics.js
Normal file
@@ -0,0 +1,212 @@
|
||||
const WORLD_ANALYTICS_ENDPOINT = '/api/worlds/analytics/events'
|
||||
const VISITOR_STORAGE_KEY = 'skinbase:world-analytics-visitor'
|
||||
const SOURCE_PARAM = 'world_source'
|
||||
const SOURCE_DETAIL_PARAM = 'world_source_detail'
|
||||
const IMPRESSION_KEYS = new Set()
|
||||
|
||||
const ALLOWED_SOURCES = new Set([
|
||||
'homepage_spotlight',
|
||||
'homepage_worlds_rail',
|
||||
'worlds_index',
|
||||
'navigation',
|
||||
'upload_flow',
|
||||
'challenge_page',
|
||||
'news_article',
|
||||
'profile',
|
||||
'direct',
|
||||
'unknown',
|
||||
])
|
||||
|
||||
function csrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
function randomToken() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
return `w-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`
|
||||
}
|
||||
|
||||
function normalizeSourceSurface(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
return ALLOWED_SOURCES.has(normalized) ? normalized : ''
|
||||
}
|
||||
|
||||
function sanitizeDetail(value) {
|
||||
return String(value || '').trim().slice(0, 80)
|
||||
}
|
||||
|
||||
function impressionKey({ worldId, sourceSurface, sourceDetail = '', sectionKey = '' }) {
|
||||
return [worldId, sourceSurface, sanitizeDetail(sourceDetail), String(sectionKey || '').trim()].join(':')
|
||||
}
|
||||
|
||||
export function worldAnalyticsVisitorToken() {
|
||||
try {
|
||||
const existing = window.localStorage?.getItem(VISITOR_STORAGE_KEY)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const next = randomToken()
|
||||
window.localStorage?.setItem(VISITOR_STORAGE_KEY, next)
|
||||
return next
|
||||
} catch {
|
||||
return randomToken()
|
||||
}
|
||||
}
|
||||
|
||||
export function withWorldSource(url, sourceSurface, sourceDetail = '') {
|
||||
if (!url) {
|
||||
return url
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin)
|
||||
if (parsed.origin !== window.location.origin) {
|
||||
return url
|
||||
}
|
||||
|
||||
const normalizedSource = normalizeSourceSurface(sourceSurface)
|
||||
if (normalizedSource) {
|
||||
parsed.searchParams.set(SOURCE_PARAM, normalizedSource)
|
||||
}
|
||||
|
||||
const normalizedDetail = sanitizeDetail(sourceDetail)
|
||||
if (normalizedDetail) {
|
||||
parsed.searchParams.set(SOURCE_DETAIL_PARAM, normalizedDetail)
|
||||
}
|
||||
|
||||
return `${parsed.pathname}${parsed.search}${parsed.hash}`
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveWorldLandingSource() {
|
||||
try {
|
||||
const locationUrl = new URL(window.location.href)
|
||||
const explicitSource = normalizeSourceSurface(locationUrl.searchParams.get(SOURCE_PARAM))
|
||||
const explicitDetail = sanitizeDetail(locationUrl.searchParams.get(SOURCE_DETAIL_PARAM))
|
||||
|
||||
if (explicitSource) {
|
||||
return {
|
||||
sourceSurface: explicitSource,
|
||||
sourceDetail: explicitDetail,
|
||||
}
|
||||
}
|
||||
|
||||
if (!document.referrer) {
|
||||
return { sourceSurface: 'direct', sourceDetail: '' }
|
||||
}
|
||||
|
||||
const referrer = new URL(document.referrer)
|
||||
if (referrer.origin !== window.location.origin) {
|
||||
return { sourceSurface: 'unknown', sourceDetail: 'external_referrer' }
|
||||
}
|
||||
|
||||
const path = referrer.pathname || '/'
|
||||
|
||||
if (path === '/') {
|
||||
return { sourceSurface: 'homepage_spotlight', sourceDetail: 'referrer' }
|
||||
}
|
||||
|
||||
if (path === '/worlds') {
|
||||
return { sourceSurface: 'worlds_index', sourceDetail: 'referrer' }
|
||||
}
|
||||
|
||||
if (/^\/groups\/[^/]+\/challenges\//.test(path)) {
|
||||
return { sourceSurface: 'challenge_page', sourceDetail: 'referrer' }
|
||||
}
|
||||
|
||||
if (path.startsWith('/upload') || path.startsWith('/studio/artworks')) {
|
||||
return { sourceSurface: 'upload_flow', sourceDetail: 'referrer' }
|
||||
}
|
||||
|
||||
if (path.startsWith('/news') || path.startsWith('/stories')) {
|
||||
return { sourceSurface: 'news_article', sourceDetail: 'referrer' }
|
||||
}
|
||||
|
||||
if (path.startsWith('/@') || path.startsWith('/profile')) {
|
||||
return { sourceSurface: 'profile', sourceDetail: 'referrer' }
|
||||
}
|
||||
} catch {
|
||||
return { sourceSurface: 'unknown', sourceDetail: '' }
|
||||
}
|
||||
|
||||
return { sourceSurface: 'unknown', sourceDetail: '' }
|
||||
}
|
||||
|
||||
export async function trackWorldAnalytics(eventType, payload = {}) {
|
||||
try {
|
||||
if (!eventType || !payload.world_id) {
|
||||
return
|
||||
}
|
||||
|
||||
await fetch(WORLD_ANALYTICS_ENDPOINT, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
keepalive: true,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
event_type: eventType,
|
||||
visitor_token: worldAnalyticsVisitorToken(),
|
||||
...payload,
|
||||
}),
|
||||
})
|
||||
} catch {
|
||||
// Best-effort analytics only.
|
||||
}
|
||||
}
|
||||
|
||||
export function trackWorldSourceClick({ worldId, worldTitle = '', sourceSurface = '', sourceDetail = '' }) {
|
||||
const normalizedSource = normalizeSourceSurface(sourceSurface)
|
||||
if (!worldId || !normalizedSource) {
|
||||
return
|
||||
}
|
||||
|
||||
trackWorldAnalytics('world_source_clicked', {
|
||||
world_id: worldId,
|
||||
source_surface: normalizedSource,
|
||||
source_detail: sanitizeDetail(sourceDetail),
|
||||
entity_type: 'world',
|
||||
entity_id: worldId,
|
||||
entity_title: worldTitle,
|
||||
})
|
||||
}
|
||||
|
||||
export function trackWorldSourceImpression({
|
||||
worldId,
|
||||
worldTitle = '',
|
||||
sourceSurface = '',
|
||||
sourceDetail = '',
|
||||
sectionKey = '',
|
||||
}) {
|
||||
const normalizedSource = normalizeSourceSurface(sourceSurface)
|
||||
if (!worldId || !normalizedSource) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = impressionKey({ worldId, sourceSurface: normalizedSource, sourceDetail, sectionKey })
|
||||
if (IMPRESSION_KEYS.has(key)) {
|
||||
return
|
||||
}
|
||||
|
||||
IMPRESSION_KEYS.add(key)
|
||||
|
||||
trackWorldAnalytics('world_source_impression', {
|
||||
world_id: worldId,
|
||||
source_surface: normalizedSource,
|
||||
source_detail: sanitizeDetail(sourceDetail),
|
||||
section_key: String(sectionKey || '').trim().slice(0, 80),
|
||||
entity_type: 'world',
|
||||
entity_id: worldId,
|
||||
entity_title: worldTitle,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user