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

@@ -1,12 +1,26 @@
import React from 'react'
import React, { useEffect, useRef } from 'react'
import { usePage } from '@inertiajs/react'
import SeoHead from '../../components/seo/SeoHead'
import WorldArchiveNotice from '../../components/worlds/WorldArchiveNotice'
import WorldChallengeEntriesRail from '../../components/worlds/WorldChallengeEntriesRail'
import WorldChallengeFinalistsGrid from '../../components/worlds/WorldChallengeFinalistsGrid'
import WorldChallengePanel from '../../components/worlds/WorldChallengePanel'
import WorldChallengeWinnersPanel from '../../components/worlds/WorldChallengeWinnersPanel'
import WorldHero from '../../components/worlds/WorldHero'
import WorldCommunitySubmissionsSection from '../../components/worlds/WorldCommunitySubmissionsSection'
import WorldRecapArticleCard from '../../components/worlds/WorldRecapArticleCard'
import WorldRecapCommunityHighlights from '../../components/worlds/WorldRecapCommunityHighlights'
import WorldRecapCreatorsPanel from '../../components/worlds/WorldRecapCreatorsPanel'
import WorldRecapFeaturedArtworks from '../../components/worlds/WorldRecapFeaturedArtworks'
import WorldRecapHero from '../../components/worlds/WorldRecapHero'
import WorldRecapStatsGrid from '../../components/worlds/WorldRecapStatsGrid'
import WorldRecapSummaryCard from '../../components/worlds/WorldRecapSummaryCard'
import WorldSection from '../../components/worlds/WorldSection'
import WorldCard from '../../components/worlds/WorldCard'
import WorldFamilyCard from '../../components/worlds/WorldFamilyCard'
import { resolveWorldLandingSource, trackWorldAnalytics, trackWorldSourceClick, withWorldSource } from '../../lib/worldAnalytics'
function SupportingRail({ title, description, items }) {
function SupportingRail({ title, description, items, sourceDetail = 'navigation_rail' }) {
if (!Array.isArray(items) || items.length === 0) {
return null
}
@@ -20,7 +34,110 @@ function SupportingRail({ title, description, items }) {
</div>
</div>
<div className="grid gap-4 xl:grid-cols-3">
{items.map((item) => <WorldCard key={item.id} world={item} compact />)}
{items.map((item) => <WorldCard key={item.id} world={item} compact sourceSurface="navigation" sourceDetail={sourceDetail} />)}
</div>
</section>
)
}
function EditionNavigation({ previousEdition, nextEdition }) {
if (!previousEdition && !nextEdition) {
return null
}
return (
<section className="mt-10 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="mb-5">
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">Edition Navigation</h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">Move through adjacent editions in this recurring world family without losing the archive context.</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<a href={previousEdition ? withWorldSource(previousEdition.public_url, 'navigation', 'previous_edition') : '#'} onClick={() => previousEdition && trackWorldSourceClick({ worldId: previousEdition.id, worldTitle: previousEdition.title, sourceSurface: 'navigation', sourceDetail: 'previous_edition' })} className={`rounded-[24px] border border-white/10 bg-black/20 p-4 ${previousEdition ? 'text-white hover:bg-white/[0.06]' : 'pointer-events-none text-slate-500'}`}>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Previous edition</div>
<div className="mt-2 text-lg font-semibold">{previousEdition?.title || 'No earlier edition'}</div>
{previousEdition?.edition_year ? <div className="mt-1 text-sm text-slate-300">{previousEdition.edition_year}</div> : null}
</a>
<a href={nextEdition ? withWorldSource(nextEdition.public_url, 'navigation', 'next_edition') : '#'} onClick={() => nextEdition && trackWorldSourceClick({ worldId: nextEdition.id, worldTitle: nextEdition.title, sourceSurface: 'navigation', sourceDetail: 'next_edition' })} className={`rounded-[24px] border border-white/10 bg-black/20 p-4 ${nextEdition ? 'text-white hover:bg-white/[0.06]' : 'pointer-events-none text-slate-500'}`}>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Next edition</div>
<div className="mt-2 text-lg font-semibold">{nextEdition?.title || 'No newer archive edition'}</div>
{nextEdition?.edition_year ? <div className="mt-1 text-sm text-slate-300">{nextEdition.edition_year}</div> : null}
</a>
</div>
</section>
)
}
function RewardedContributors({ section, world }) {
const items = Array.isArray(section?.items) ? section.items : []
const counts = section?.counts || {}
const summaryChips = [
section?.creator_count ? `${section.creator_count} rewarded creators` : null,
world?.live_submission_count ? `${world.live_submission_count} live submissions` : null,
world?.featured_submission_count ? `${world.featured_submission_count} featured artworks` : null,
].filter(Boolean)
const rewardTypeChips = [
counts.winner ? `${counts.winner} winner${counts.winner === 1 ? '' : 's'}` : null,
counts.finalist ? `${counts.finalist} finalist${counts.finalist === 1 ? '' : 's'}` : null,
counts.spotlight ? `${counts.spotlight} spotlight` : null,
counts.featured ? `${counts.featured} featured` : null,
counts.participant ? `${counts.participant} participant` : null,
].filter(Boolean)
if (items.length === 0) {
return null
}
return (
<section className="mt-10 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="mb-5">
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">Rewarded Contributors</h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">Creators who earned visible recognition in this edition. Live participation builds history here, while featured and editorial selections raise the level of recognition.</p>
</div>
{summaryChips.length > 0 || rewardTypeChips.length > 0 || world?.cta_url ? (
<div className="mb-5 rounded-[24px] border border-white/10 bg-black/20 p-4">
{summaryChips.length > 0 ? (
<div className="flex flex-wrap gap-2">
{summaryChips.map((item) => (
<span key={item} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-200">{item}</span>
))}
</div>
) : null}
{rewardTypeChips.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{rewardTypeChips.map((item) => (
<span key={item} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-sky-100">{item}</span>
))}
</div>
) : null}
{world?.cta_url ? (
<div className="mt-4">
<a href={world.cta_url} data-world-event="world_cta_clicked" data-world-section-key="rewards" data-world-cta-key="rewards_join_world" className="inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/[0.06] px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white transition hover:bg-white/[0.1]">
{world.cta_label || 'Join this world'}
<i className="fa-solid fa-arrow-right" />
</a>
</div>
) : null}
</div>
) : null}
<div className="mb-5">
<p className="max-w-3xl text-sm leading-6 text-slate-400">This editions rewards are edition-aware, so recognition here remains part of each creators recurring world history.</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => (
<a key={item.id} href={item.creator?.profile_url || item.world?.url || '#'} data-world-event="world_entity_clicked" data-world-section-key="rewards" data-world-entity-type="creator" data-world-entity-id={item.creator?.id || 0} data-world-entity-title={item.creator?.name || item.creator?.username || 'Creator'} className="rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/15 hover:bg-white/[0.06]">
<div className="flex items-center gap-3">
{item.creator?.avatar_url ? <img src={item.creator.avatar_url} alt={item.creator.username || item.creator.name} className="h-12 w-12 rounded-2xl object-cover ring-1 ring-white/10" /> : <div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-500"><i className="fa-solid fa-user" /></div>}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-white">{item.creator?.name || item.creator?.username || 'Creator'}</div>
<div className="truncate text-xs uppercase tracking-[0.16em] text-slate-500">{item.badge_label}</div>
</div>
</div>
{item.artwork?.title ? <div className="mt-3 text-sm text-slate-300">{item.artwork.title}</div> : null}
</a>
))}
</div>
</section>
)
@@ -29,12 +146,87 @@ function SupportingRail({ title, description, items }) {
export default function WorldShow() {
const { props } = usePage()
const world = props.world
const recap = props.recap || null
const sections = Array.isArray(props.sections) ? props.sections : []
const linkedChallenge = props.linkedChallenge || null
const linkedChallengeEntries = props.linkedChallengeEntries || null
const linkedChallengeWinners = props.linkedChallengeWinners || null
const linkedChallengeFinalists = props.linkedChallengeFinalists || null
const communitySubmissions = props.communitySubmissions || null
const rewardedContributors = props.rewardedContributors || null
const previewMode = Boolean(props.previewMode)
const archiveNotice = props.archiveNotice || null
const familySummary = props.familySummary || null
const currentEdition = props.currentEdition || null
const previousEdition = props.previousEdition || null
const nextEdition = props.nextEdition || null
const archiveTitle = currentEdition ? 'Previous Editions' : 'Archive Editions'
const archiveDescription = currentEdition
? 'Earlier editions remain public so the recurring family keeps its full history accessible.'
: 'Past iterations remain accessible so recurring worlds can build continuity over time.'
const rootRef = useRef(null)
useEffect(() => {
if (previewMode || !world?.id) {
return
}
const landing = resolveWorldLandingSource()
trackWorldAnalytics('world_viewed', {
world_id: world.id,
source_surface: landing.sourceSurface,
source_detail: landing.sourceDetail,
})
}, [previewMode, world?.id])
useEffect(() => {
if (previewMode || !world?.id || !rootRef.current) {
return
}
const landing = resolveWorldLandingSource()
const container = rootRef.current
const clickHandler = (event) => {
const target = event.target.closest('[data-world-event], [data-world-section-key]')
if (!target || !container.contains(target)) {
return
}
const sectionKey = target.dataset.worldSectionKey || ''
const challengeId = Number(target.dataset.worldChallengeId || 0)
const entityId = Number(target.dataset.worldEntityId || 0)
const payload = {
world_id: world.id,
source_surface: landing.sourceSurface,
source_detail: landing.sourceDetail,
...(sectionKey ? { section_key: sectionKey } : {}),
...(target.dataset.worldCtaKey ? { cta_key: target.dataset.worldCtaKey } : {}),
...(target.dataset.worldEntityType ? { entity_type: target.dataset.worldEntityType } : {}),
...(entityId > 0 ? { entity_id: entityId } : {}),
...(target.dataset.worldEntityTitle ? { entity_title: target.dataset.worldEntityTitle } : {}),
...(challengeId > 0 ? { challenge_id: challengeId } : {}),
}
if (sectionKey) {
trackWorldAnalytics('world_section_clicked', payload)
}
if (target.dataset.worldEvent) {
trackWorldAnalytics(target.dataset.worldEvent, payload)
}
}
container.addEventListener('click', clickHandler)
return () => {
container.removeEventListener('click', clickHandler)
}
}, [previewMode, world?.id])
return (
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.12),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
<main ref={rootRef} className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.12),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
<SeoHead title={props.seo?.title || `${world?.title || 'World'} - Skinbase Nova`} description={props.seo?.description || world?.summary} image={props.seo?.image} />
<div className="mx-auto max-w-7xl">
{previewMode ? (
@@ -49,26 +241,65 @@ export default function WorldShow() {
</section>
) : null}
<WorldHero world={world} previewMode={previewMode} />
<WorldArchiveNotice notice={archiveNotice} />
{sections.length > 0 ? sections.map((section) => <WorldSection key={section.key} section={section} />) : (
<section className="mt-10 rounded-[28px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">
This world has been themed and published, but no curated sections have been attached yet.
{recap ? <WorldRecapHero world={world} recap={recap} previewMode={previewMode} /> : <WorldHero world={world} previewMode={previewMode} />}
{recap ? (
<>
<WorldRecapSummaryCard recap={recap} />
<WorldRecapStatsGrid stats={recap.stats} />
<WorldRecapArticleCard article={recap.article} />
<WorldRecapFeaturedArtworks section={recap.featured_artworks} />
<WorldChallengePanel section={linkedChallenge} />
</>
) : <WorldChallengePanel section={linkedChallenge} />}
{familySummary ? (
<section className="mt-10">
<div className="mb-5">
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">Recurring Family</h2>
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">Each edition stays public, but the family route always resolves to the canonical current or latest edition.</p>
</div>
<WorldFamilyCard family={familySummary} sourceSurface="navigation" sourceDetail="family_summary" />
</section>
)}
) : null}
<WorldCommunitySubmissionsSection section={communitySubmissions} />
{sections.length > 0 ? sections.map((section) => <WorldSection key={section.key} section={section} />) : null}
<WorldChallengeEntriesRail section={linkedChallengeEntries} challengeId={linkedChallenge?.id || null} />
<WorldChallengeWinnersPanel section={linkedChallengeWinners} challengeId={linkedChallenge?.id || null} />
<WorldChallengeFinalistsGrid panel={linkedChallenge} section={linkedChallengeFinalists} />
{recap ? <WorldRecapCommunityHighlights section={recap.community_highlights} /> : <RewardedContributors section={rewardedContributors} world={world} />}
{recap ? <WorldRecapCreatorsPanel section={recap.creators} /> : <WorldCommunitySubmissionsSection section={communitySubmissions} />}
{currentEdition ? (
<SupportingRail
title="Current Edition"
description="This recurring family has a newer public edition. Use the family route to follow the current canonical page."
items={[currentEdition]}
sourceDetail="current_edition"
/>
) : null}
<EditionNavigation previousEdition={previousEdition} nextEdition={nextEdition} />
<SupportingRail
title="Archive Editions"
description="Past iterations remain accessible so recurring worlds can build continuity over time."
title={archiveTitle}
description={archiveDescription}
items={props.archiveEditions}
sourceDetail="archive_editions"
/>
<SupportingRail
title="Related Worlds"
description="Other worlds sharing the same recurrence, theme, or editorial lineage."
description="Other worlds with adjacent themes, related editorial mood, or connected programming context."
items={props.relatedWorlds}
sourceDetail="related_worlds"
/>
</div>
</main>