Build world campaigns rewards and recaps

This commit is contained in:
2026-05-01 11:44:41 +02:00
parent 28e7e46e13
commit 257b0dbef6
100 changed files with 11300 additions and 367 deletions

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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]" />

View 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>
}

View 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>
)
}

View File

@@ -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>

View 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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)

View 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>
)
}

View File

@@ -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>
)
})}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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 ? (

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}