Build world campaigns rewards and recaps
This commit is contained in:
@@ -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