307 lines
16 KiB
JavaScript
307 lines
16 KiB
JavaScript
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, sourceDetail = 'navigation_rail' }) {
|
||
if (!Array.isArray(items) || items.length === 0) {
|
||
return null
|
||
}
|
||
|
||
return (
|
||
<section className="mt-10">
|
||
<div className="mb-5 flex items-end justify-between gap-4">
|
||
<div>
|
||
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">{title}</h2>
|
||
{description ? <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">{description}</p> : null}
|
||
</div>
|
||
</div>
|
||
<div className="grid gap-4 xl:grid-cols-3">
|
||
{items.map((item) => <WorldCard key={item.id} world={item} compact sourceSurface="navigation" sourceDetail={sourceDetail} />)}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function EditionNavigation({ previousEdition, nextEdition }) {
|
||
if (!previousEdition && !nextEdition) {
|
||
return null
|
||
}
|
||
|
||
return (
|
||
<section className="mt-10 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||
<div className="mb-5">
|
||
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">Edition Navigation</h2>
|
||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">Move through adjacent editions in this recurring world family without losing the archive context.</p>
|
||
</div>
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<a href={previousEdition ? withWorldSource(previousEdition.public_url, 'navigation', 'previous_edition') : '#'} onClick={() => previousEdition && trackWorldSourceClick({ worldId: previousEdition.id, worldTitle: previousEdition.title, sourceSurface: 'navigation', sourceDetail: 'previous_edition' })} className={`rounded-[24px] border border-white/10 bg-black/20 p-4 ${previousEdition ? 'text-white hover:bg-white/[0.06]' : 'pointer-events-none text-slate-500'}`}>
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Previous edition</div>
|
||
<div className="mt-2 text-lg font-semibold">{previousEdition?.title || 'No earlier edition'}</div>
|
||
{previousEdition?.edition_year ? <div className="mt-1 text-sm text-slate-300">{previousEdition.edition_year}</div> : null}
|
||
</a>
|
||
<a href={nextEdition ? withWorldSource(nextEdition.public_url, 'navigation', 'next_edition') : '#'} onClick={() => nextEdition && trackWorldSourceClick({ worldId: nextEdition.id, worldTitle: nextEdition.title, sourceSurface: 'navigation', sourceDetail: 'next_edition' })} className={`rounded-[24px] border border-white/10 bg-black/20 p-4 ${nextEdition ? 'text-white hover:bg-white/[0.06]' : 'pointer-events-none text-slate-500'}`}>
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Next edition</div>
|
||
<div className="mt-2 text-lg font-semibold">{nextEdition?.title || 'No newer archive edition'}</div>
|
||
{nextEdition?.edition_year ? <div className="mt-1 text-sm text-slate-300">{nextEdition.edition_year}</div> : null}
|
||
</a>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function RewardedContributors({ section, world }) {
|
||
const items = Array.isArray(section?.items) ? section.items : []
|
||
const counts = section?.counts || {}
|
||
const summaryChips = [
|
||
section?.creator_count ? `${section.creator_count} rewarded creators` : null,
|
||
world?.live_submission_count ? `${world.live_submission_count} live submissions` : null,
|
||
world?.featured_submission_count ? `${world.featured_submission_count} featured artworks` : null,
|
||
].filter(Boolean)
|
||
const rewardTypeChips = [
|
||
counts.winner ? `${counts.winner} winner${counts.winner === 1 ? '' : 's'}` : null,
|
||
counts.finalist ? `${counts.finalist} finalist${counts.finalist === 1 ? '' : 's'}` : null,
|
||
counts.spotlight ? `${counts.spotlight} spotlight` : null,
|
||
counts.featured ? `${counts.featured} featured` : null,
|
||
counts.participant ? `${counts.participant} participant` : null,
|
||
].filter(Boolean)
|
||
|
||
if (items.length === 0) {
|
||
return null
|
||
}
|
||
|
||
return (
|
||
<section className="mt-10 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||
<div className="mb-5">
|
||
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">Rewarded Contributors</h2>
|
||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">Creators who earned visible recognition in this edition. Live participation builds history here, while featured and editorial selections raise the level of recognition.</p>
|
||
</div>
|
||
|
||
{summaryChips.length > 0 || rewardTypeChips.length > 0 || world?.cta_url ? (
|
||
<div className="mb-5 rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||
{summaryChips.length > 0 ? (
|
||
<div className="flex flex-wrap gap-2">
|
||
{summaryChips.map((item) => (
|
||
<span key={item} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-200">{item}</span>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
{rewardTypeChips.length > 0 ? (
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{rewardTypeChips.map((item) => (
|
||
<span key={item} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-sky-100">{item}</span>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
{world?.cta_url ? (
|
||
<div className="mt-4">
|
||
<a href={world.cta_url} data-world-event="world_cta_clicked" data-world-section-key="rewards" data-world-cta-key="rewards_join_world" className="inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/[0.06] px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white transition hover:bg-white/[0.1]">
|
||
{world.cta_label || 'Join this world'}
|
||
<i className="fa-solid fa-arrow-right" />
|
||
</a>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="mb-5">
|
||
<p className="max-w-3xl text-sm leading-6 text-slate-400">This edition’s rewards are edition-aware, so recognition here remains part of each creator’s recurring world history.</p>
|
||
</div>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||
{items.map((item) => (
|
||
<a key={item.id} href={item.creator?.profile_url || item.world?.url || '#'} data-world-event="world_entity_clicked" data-world-section-key="rewards" data-world-entity-type="creator" data-world-entity-id={item.creator?.id || 0} data-world-entity-title={item.creator?.name || item.creator?.username || 'Creator'} className="rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/15 hover:bg-white/[0.06]">
|
||
<div className="flex items-center gap-3">
|
||
{item.creator?.avatar_url ? <img src={item.creator.avatar_url} alt={item.creator.username || item.creator.name} className="h-12 w-12 rounded-2xl object-cover ring-1 ring-white/10" /> : <div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-500"><i className="fa-solid fa-user" /></div>}
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate text-sm font-semibold text-white">{item.creator?.name || item.creator?.username || 'Creator'}</div>
|
||
<div className="truncate text-xs uppercase tracking-[0.16em] text-slate-500">{item.badge_label}</div>
|
||
</div>
|
||
</div>
|
||
{item.artwork?.title ? <div className="mt-3 text-sm text-slate-300">{item.artwork.title}</div> : null}
|
||
</a>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
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 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`} description={props.seo?.description || world?.summary} image={props.seo?.image} />
|
||
<div className="mx-auto max-w-7xl">
|
||
{previewMode ? (
|
||
<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-3">
|
||
<div>
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100/75">Studio preview</div>
|
||
<div className="mt-1 font-semibold text-white">You are viewing the editorial preview version of this world before or alongside public release.</div>
|
||
</div>
|
||
{world?.public_url ? <a href={world.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 canonical page <i className="fa-solid fa-up-right-from-square" /></a> : null}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
<WorldArchiveNotice notice={archiveNotice} />
|
||
|
||
{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}
|
||
|
||
{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={archiveTitle}
|
||
description={archiveDescription}
|
||
items={props.archiveEditions}
|
||
sourceDetail="archive_editions"
|
||
/>
|
||
|
||
<SupportingRail
|
||
title="Related Worlds"
|
||
description="Other worlds with adjacent themes, related editorial mood, or connected programming context."
|
||
items={props.relatedWorlds}
|
||
sourceDetail="related_worlds"
|
||
/>
|
||
</div>
|
||
</main>
|
||
)
|
||
} |