Build world campaigns rewards and recaps
This commit is contained in:
@@ -1,11 +1,32 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import ChallengeWorldLinkBadge from '../../components/worlds/ChallengeWorldLinkBadge'
|
||||
import WorldChallengeArtworkCard from '../../components/worlds/WorldChallengeArtworkCard'
|
||||
|
||||
function OutcomeSection({ section }) {
|
||||
const items = Array.isArray(section?.items) ? section.items : []
|
||||
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">{section.label}</h2>
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
|
||||
{items.map((item) => <WorldChallengeArtworkCard key={item.id} item={item} featured={item.outcome_type === 'winner'} />)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function GroupChallengeShow() {
|
||||
const { props } = usePage()
|
||||
const group = props.group || {}
|
||||
const challenge = props.challenge || {}
|
||||
const linkedWorld = props.linkedWorld || null
|
||||
const outcomeSections = challenge.outcome_sections || {}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(234,179,8,0.15),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
|
||||
@@ -24,6 +45,7 @@ export default function GroupChallengeShow() {
|
||||
{challenge.start_at ? <span>Starts {new Date(challenge.start_at).toLocaleDateString()}</span> : null}
|
||||
{challenge.end_at ? <span>Ends {new Date(challenge.end_at).toLocaleDateString()}</span> : null}
|
||||
</div>
|
||||
<ChallengeWorldLinkBadge world={linkedWorld} className="mt-5" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -45,17 +67,25 @@ export default function GroupChallengeShow() {
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">Entries</h2>
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
|
||||
{Array.isArray(challenge.artworks) && challenge.artworks.length > 0 ? challenge.artworks.map((artwork) => (
|
||||
<a key={artwork.id} href={artwork.url} className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20">
|
||||
{artwork.thumb ? <img src={artwork.thumb} alt={artwork.title} className="aspect-[4/3] w-full object-cover" /> : null}
|
||||
<div className="p-4 text-white">{artwork.title}</div>
|
||||
</a>
|
||||
)) : <p className="text-sm text-slate-400">No entries linked yet.</p>}
|
||||
</div>
|
||||
</section>
|
||||
<div className="space-y-8">
|
||||
<OutcomeSection section={outcomeSections.winner} />
|
||||
<OutcomeSection section={outcomeSections.finalist} />
|
||||
<OutcomeSection section={outcomeSections.runner_up} />
|
||||
<OutcomeSection section={outcomeSections.honorable_mention} />
|
||||
<OutcomeSection section={outcomeSections.featured} />
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<h2 className="text-2xl font-semibold text-white">Entries</h2>
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
|
||||
{Array.isArray(challenge.artworks) && challenge.artworks.length > 0 ? challenge.artworks.map((artwork) => (
|
||||
<a key={artwork.id} href={artwork.url} className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20">
|
||||
{artwork.thumb ? <img src={artwork.thumb} alt={artwork.title} className="aspect-[4/3] w-full object-cover" /> : null}
|
||||
<div className="p-4 text-white">{artwork.title}</div>
|
||||
</a>
|
||||
)) : <p className="text-sm text-slate-400">No entries linked yet.</p>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,49 +1,27 @@
|
||||
import React from 'react'
|
||||
import ActiveWorldSpotlight from '../../components/worlds/ActiveWorldSpotlight'
|
||||
|
||||
export default function HomeWorldSpotlight({ world }) {
|
||||
if (!world) {
|
||||
return null
|
||||
}
|
||||
|
||||
const spotlight = world.primary || world
|
||||
const secondary = Array.isArray(world.secondary) ? world.secondary : []
|
||||
|
||||
return (
|
||||
<section className="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<a
|
||||
href={world.public_url}
|
||||
className="group relative block overflow-hidden rounded-[32px] border border-white/10 bg-slate-950/70"
|
||||
style={{
|
||||
'--world-accent': world.theme?.accent_color || '#f97316',
|
||||
'--world-accent-secondary': world.theme?.accent_color_secondary || '#0f172a',
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_color-mix(in_srgb,var(--world-accent)_30%,transparent),_transparent_36%),linear-gradient(135deg,_color-mix(in_srgb,var(--world-accent-secondary)_92%,black),_rgba(2,6,23,0.98))]" />
|
||||
{world.cover_url ? <img src={world.cover_url} alt={world.title} className="absolute inset-0 h-full w-full object-cover opacity-20 transition duration-500 group-hover:scale-[1.03]" /> : null}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-slate-950 via-slate-950/82 to-slate-950/35" />
|
||||
|
||||
<div className="relative grid gap-8 px-6 py-7 sm:px-8 lg:grid-cols-[minmax(0,1.2fr)_18rem] lg:items-end lg:px-10">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-white/70">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1">Homepage spotlight</span>
|
||||
{world.badge_label ? <span className="rounded-full border border-white/15 bg-black/25 px-3 py-1">{world.badge_label}</span> : null}
|
||||
</div>
|
||||
<h2 className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white sm:text-4xl">{world.title}</h2>
|
||||
{world.tagline ? <p className="mt-2 text-sm uppercase tracking-[0.18em] text-white/55">{world.tagline}</p> : null}
|
||||
{world.summary ? <p className="mt-4 max-w-3xl text-sm leading-7 text-slate-200/88">{world.summary}</p> : null}
|
||||
<div className="mt-6 inline-flex items-center gap-2 rounded-full bg-white px-4 py-2.5 text-sm font-semibold text-slate-950 transition group-hover:bg-sky-100">
|
||||
{world.cta_label || 'Explore world'}
|
||||
<i className="fa-solid fa-arrow-right" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/12 bg-black/25 p-5 text-white backdrop-blur-sm">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">World Theme</div>
|
||||
<div className="mt-2 flex items-center gap-3 text-lg font-semibold">
|
||||
<i className={world.icon_name || 'fa-solid fa-globe'} />
|
||||
<span>{world.theme?.label || 'Editorial world'}</span>
|
||||
</div>
|
||||
{world.timeframe_label ? <div className="mt-4 text-sm text-slate-300">{world.timeframe_label}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<ActiveWorldSpotlight
|
||||
spotlight={spotlight}
|
||||
secondary={secondary}
|
||||
indexUrl={world.index_url || '/worlds'}
|
||||
eyebrow="Homepage spotlight"
|
||||
secondaryTitle="More live worlds"
|
||||
sourceSurface="homepage_spotlight"
|
||||
sourceDetail="primary"
|
||||
secondarySourceSurface="homepage_worlds_rail"
|
||||
secondarySourceDetail="secondary"
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import ProfileHero from '../../components/profile/ProfileHero'
|
||||
import ProfileTabs from '../../components/profile/ProfileTabs'
|
||||
import TabArtworks from '../../components/profile/tabs/TabArtworks'
|
||||
@@ -11,9 +12,10 @@ import TabCollections from '../../components/profile/tabs/TabCollections'
|
||||
import TabActivity from '../../components/profile/tabs/TabActivity'
|
||||
import TabPosts from '../../components/profile/tabs/TabPosts'
|
||||
import TabStories from '../../components/profile/tabs/TabStories'
|
||||
import TabWorlds from '../../components/profile/tabs/TabWorlds'
|
||||
import GroupProfileSummary from '../../components/groups/GroupProfileSummary'
|
||||
|
||||
const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'collections', 'about', 'stats', 'favourites', 'activity']
|
||||
const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'worlds', 'collections', 'about', 'stats', 'favourites', 'activity']
|
||||
|
||||
function getInitialTab(initialTab = 'posts') {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -62,6 +64,8 @@ export default function ProfileShow() {
|
||||
creatorStories,
|
||||
collections,
|
||||
achievements,
|
||||
worldRewards,
|
||||
worldHistory,
|
||||
leaderboardRank,
|
||||
groupContributionHistory,
|
||||
journey,
|
||||
@@ -76,6 +80,7 @@ export default function ProfileShow() {
|
||||
collectionsFeaturedUrl,
|
||||
collectionFeatureLimit,
|
||||
profileTabUrls,
|
||||
seo,
|
||||
} = props
|
||||
|
||||
const [activeTab, setActiveTab] = useState(() => getInitialTab(initialTab))
|
||||
@@ -128,7 +133,9 @@ export default function ProfileShow() {
|
||||
: 'max-w-6xl mx-auto px-4'
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-hidden pb-16">
|
||||
<>
|
||||
<SeoHead seo={seo} />
|
||||
<div className="relative min-h-screen overflow-hidden pb-16">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[34rem] opacity-90"
|
||||
@@ -208,7 +215,10 @@ export default function ProfileShow() {
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'achievements' && (
|
||||
<TabAchievements achievements={achievements} />
|
||||
<TabAchievements achievements={achievements} worldRewards={worldRewards} worldHistory={worldHistory} onTabChange={handleTabChange} />
|
||||
)}
|
||||
{activeTab === 'worlds' && (
|
||||
<TabWorlds worldHistory={worldHistory} isOwner={isOwner} />
|
||||
)}
|
||||
{activeTab === 'collections' && (
|
||||
<TabCollections
|
||||
@@ -226,6 +236,7 @@ export default function ProfileShow() {
|
||||
profile={profile}
|
||||
stats={stats}
|
||||
achievements={achievements}
|
||||
worldRewards={worldRewards}
|
||||
artworks={artworkList}
|
||||
creatorStories={creatorStories}
|
||||
profileComments={profileComments}
|
||||
@@ -264,6 +275,7 @@ export default function ProfileShow() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,31 +1,14 @@
|
||||
import React from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import WorldCard from '../../components/worlds/WorldCard'
|
||||
|
||||
function WorldRail({ title, description, items }) {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-10">
|
||||
<div className="mb-5 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">{title}</h2>
|
||||
{description ? <p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">{description}</p> : null}
|
||||
</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">{items.length} worlds</div>
|
||||
</div>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{items.map((world) => <WorldCard key={world.id} world={world} compact />)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
import ActiveWorldSpotlight from '../../components/worlds/ActiveWorldSpotlight'
|
||||
import WorldFamilyCard from '../../components/worlds/WorldFamilyCard'
|
||||
import WorldsIndexSection from '../../components/worlds/WorldsIndexSection'
|
||||
|
||||
export default function WorldIndex() {
|
||||
const { props } = usePage()
|
||||
const hasSpotlight = Boolean(props.spotlightWorld)
|
||||
const featuredFallback = !hasSpotlight && Array.isArray(props.featuredWorlds) ? props.featuredWorlds : []
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(249,115,22,0.12),_transparent_28%),radial-gradient(circle_at_top_right,_rgba(56,189,248,0.12),_transparent_32%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
|
||||
@@ -39,24 +22,68 @@ export default function WorldIndex() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{props.featuredWorld ? <section className="mt-8"><WorldCard world={props.featuredWorld} /></section> : null}
|
||||
{hasSpotlight ? (
|
||||
<section className="mt-8">
|
||||
<ActiveWorldSpotlight
|
||||
spotlight={props.spotlightWorld}
|
||||
secondary={Array.isArray(props.featuredWorlds) ? props.featuredWorlds.slice(0, 3) : []}
|
||||
indexUrl="/worlds"
|
||||
eyebrow="Active world spotlight"
|
||||
secondaryTitle="Featured worlds"
|
||||
sourceSurface="worlds_index"
|
||||
sourceDetail="spotlight"
|
||||
secondarySourceSurface="worlds_index"
|
||||
secondarySourceDetail="featured"
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<WorldRail
|
||||
{!hasSpotlight ? (
|
||||
<WorldsIndexSection
|
||||
title="Featured Worlds"
|
||||
description="Editorially promoted worlds stay visible here even when there is no live campaign spotlighting the homepage moment."
|
||||
items={featuredFallback}
|
||||
sourceSurface="worlds_index"
|
||||
sourceDetail="featured"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<WorldsIndexSection
|
||||
title="Active Worlds"
|
||||
description="Campaigns and seasonal surfaces currently live across the platform."
|
||||
description="Live worlds and currently running campaign surfaces across Skinbase Nova."
|
||||
items={props.activeWorlds}
|
||||
emptyMessage="No worlds are currently live. Check upcoming programming below."
|
||||
sourceSurface="worlds_index"
|
||||
sourceDetail="active"
|
||||
/>
|
||||
|
||||
<WorldRail
|
||||
<WorldsIndexSection
|
||||
title="Upcoming Worlds"
|
||||
description="Scheduled worlds in the pipeline, ready to anchor the next publishing moment."
|
||||
description="Scheduled campaign moments and future worlds lined up for the next launch window."
|
||||
items={props.upcomingWorlds}
|
||||
emptyMessage="No upcoming worlds are scheduled right now."
|
||||
sourceSurface="worlds_index"
|
||||
sourceDetail="upcoming"
|
||||
/>
|
||||
|
||||
<WorldRail
|
||||
<WorldsIndexSection
|
||||
title="Recurring Worlds"
|
||||
description="Long-running campaign families keep a canonical current edition while preserving a browsable yearly archive."
|
||||
items={props.recurringWorldFamilies}
|
||||
emptyMessage="Recurring families will appear here as worlds begin building an archive across editions."
|
||||
countLabel="families"
|
||||
renderItem={(family, sourceProps) => <WorldFamilyCard key={family.id} family={family} {...sourceProps} />}
|
||||
sourceSurface="worlds_index"
|
||||
sourceDetail="recurring"
|
||||
/>
|
||||
|
||||
<WorldsIndexSection
|
||||
title="Archive Editions"
|
||||
description="Past worlds stay available as browsable records of recurring culture and editorial programming."
|
||||
items={props.archivedWorlds}
|
||||
emptyMessage="Archived worlds will appear here as campaigns finish and move into the public record."
|
||||
sourceSurface="worlds_index"
|
||||
sourceDetail="archive"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import WorldArchiveNotice from '../../components/worlds/WorldArchiveNotice'
|
||||
import WorldChallengeEntriesRail from '../../components/worlds/WorldChallengeEntriesRail'
|
||||
import WorldChallengeFinalistsGrid from '../../components/worlds/WorldChallengeFinalistsGrid'
|
||||
import WorldChallengePanel from '../../components/worlds/WorldChallengePanel'
|
||||
import WorldChallengeWinnersPanel from '../../components/worlds/WorldChallengeWinnersPanel'
|
||||
import WorldHero from '../../components/worlds/WorldHero'
|
||||
import WorldCommunitySubmissionsSection from '../../components/worlds/WorldCommunitySubmissionsSection'
|
||||
import WorldRecapArticleCard from '../../components/worlds/WorldRecapArticleCard'
|
||||
import WorldRecapCommunityHighlights from '../../components/worlds/WorldRecapCommunityHighlights'
|
||||
import WorldRecapCreatorsPanel from '../../components/worlds/WorldRecapCreatorsPanel'
|
||||
import WorldRecapFeaturedArtworks from '../../components/worlds/WorldRecapFeaturedArtworks'
|
||||
import WorldRecapHero from '../../components/worlds/WorldRecapHero'
|
||||
import WorldRecapStatsGrid from '../../components/worlds/WorldRecapStatsGrid'
|
||||
import WorldRecapSummaryCard from '../../components/worlds/WorldRecapSummaryCard'
|
||||
import WorldSection from '../../components/worlds/WorldSection'
|
||||
import WorldCard from '../../components/worlds/WorldCard'
|
||||
import WorldFamilyCard from '../../components/worlds/WorldFamilyCard'
|
||||
import { resolveWorldLandingSource, trackWorldAnalytics, trackWorldSourceClick, withWorldSource } from '../../lib/worldAnalytics'
|
||||
|
||||
function SupportingRail({ title, description, items }) {
|
||||
function SupportingRail({ title, description, items, sourceDetail = 'navigation_rail' }) {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return null
|
||||
}
|
||||
@@ -20,7 +34,110 @@ function SupportingRail({ title, description, items }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{items.map((item) => <WorldCard key={item.id} world={item} compact />)}
|
||||
{items.map((item) => <WorldCard key={item.id} world={item} compact sourceSurface="navigation" sourceDetail={sourceDetail} />)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function EditionNavigation({ previousEdition, nextEdition }) {
|
||||
if (!previousEdition && !nextEdition) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-10 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="mb-5">
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">Edition Navigation</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">Move through adjacent editions in this recurring world family without losing the archive context.</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<a href={previousEdition ? withWorldSource(previousEdition.public_url, 'navigation', 'previous_edition') : '#'} onClick={() => previousEdition && trackWorldSourceClick({ worldId: previousEdition.id, worldTitle: previousEdition.title, sourceSurface: 'navigation', sourceDetail: 'previous_edition' })} className={`rounded-[24px] border border-white/10 bg-black/20 p-4 ${previousEdition ? 'text-white hover:bg-white/[0.06]' : 'pointer-events-none text-slate-500'}`}>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Previous edition</div>
|
||||
<div className="mt-2 text-lg font-semibold">{previousEdition?.title || 'No earlier edition'}</div>
|
||||
{previousEdition?.edition_year ? <div className="mt-1 text-sm text-slate-300">{previousEdition.edition_year}</div> : null}
|
||||
</a>
|
||||
<a href={nextEdition ? withWorldSource(nextEdition.public_url, 'navigation', 'next_edition') : '#'} onClick={() => nextEdition && trackWorldSourceClick({ worldId: nextEdition.id, worldTitle: nextEdition.title, sourceSurface: 'navigation', sourceDetail: 'next_edition' })} className={`rounded-[24px] border border-white/10 bg-black/20 p-4 ${nextEdition ? 'text-white hover:bg-white/[0.06]' : 'pointer-events-none text-slate-500'}`}>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Next edition</div>
|
||||
<div className="mt-2 text-lg font-semibold">{nextEdition?.title || 'No newer archive edition'}</div>
|
||||
{nextEdition?.edition_year ? <div className="mt-1 text-sm text-slate-300">{nextEdition.edition_year}</div> : null}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function RewardedContributors({ section, world }) {
|
||||
const items = Array.isArray(section?.items) ? section.items : []
|
||||
const counts = section?.counts || {}
|
||||
const summaryChips = [
|
||||
section?.creator_count ? `${section.creator_count} rewarded creators` : null,
|
||||
world?.live_submission_count ? `${world.live_submission_count} live submissions` : null,
|
||||
world?.featured_submission_count ? `${world.featured_submission_count} featured artworks` : null,
|
||||
].filter(Boolean)
|
||||
const rewardTypeChips = [
|
||||
counts.winner ? `${counts.winner} winner${counts.winner === 1 ? '' : 's'}` : null,
|
||||
counts.finalist ? `${counts.finalist} finalist${counts.finalist === 1 ? '' : 's'}` : null,
|
||||
counts.spotlight ? `${counts.spotlight} spotlight` : null,
|
||||
counts.featured ? `${counts.featured} featured` : null,
|
||||
counts.participant ? `${counts.participant} participant` : null,
|
||||
].filter(Boolean)
|
||||
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-10 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="mb-5">
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">Rewarded Contributors</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">Creators who earned visible recognition in this edition. Live participation builds history here, while featured and editorial selections raise the level of recognition.</p>
|
||||
</div>
|
||||
|
||||
{summaryChips.length > 0 || rewardTypeChips.length > 0 || world?.cta_url ? (
|
||||
<div className="mb-5 rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
{summaryChips.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{summaryChips.map((item) => (
|
||||
<span key={item} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-200">{item}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{rewardTypeChips.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{rewardTypeChips.map((item) => (
|
||||
<span key={item} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-sky-100">{item}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{world?.cta_url ? (
|
||||
<div className="mt-4">
|
||||
<a href={world.cta_url} data-world-event="world_cta_clicked" data-world-section-key="rewards" data-world-cta-key="rewards_join_world" className="inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/[0.06] px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white transition hover:bg-white/[0.1]">
|
||||
{world.cta_label || 'Join this world'}
|
||||
<i className="fa-solid fa-arrow-right" />
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-5">
|
||||
<p className="max-w-3xl text-sm leading-6 text-slate-400">This edition’s rewards are edition-aware, so recognition here remains part of each creator’s recurring world history.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<a key={item.id} href={item.creator?.profile_url || item.world?.url || '#'} data-world-event="world_entity_clicked" data-world-section-key="rewards" data-world-entity-type="creator" data-world-entity-id={item.creator?.id || 0} data-world-entity-title={item.creator?.name || item.creator?.username || 'Creator'} className="rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/15 hover:bg-white/[0.06]">
|
||||
<div className="flex items-center gap-3">
|
||||
{item.creator?.avatar_url ? <img src={item.creator.avatar_url} alt={item.creator.username || item.creator.name} className="h-12 w-12 rounded-2xl object-cover ring-1 ring-white/10" /> : <div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-500"><i className="fa-solid fa-user" /></div>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-white">{item.creator?.name || item.creator?.username || 'Creator'}</div>
|
||||
<div className="truncate text-xs uppercase tracking-[0.16em] text-slate-500">{item.badge_label}</div>
|
||||
</div>
|
||||
</div>
|
||||
{item.artwork?.title ? <div className="mt-3 text-sm text-slate-300">{item.artwork.title}</div> : null}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
@@ -29,12 +146,87 @@ function SupportingRail({ title, description, items }) {
|
||||
export default function WorldShow() {
|
||||
const { props } = usePage()
|
||||
const world = props.world
|
||||
const recap = props.recap || null
|
||||
const sections = Array.isArray(props.sections) ? props.sections : []
|
||||
const linkedChallenge = props.linkedChallenge || null
|
||||
const linkedChallengeEntries = props.linkedChallengeEntries || null
|
||||
const linkedChallengeWinners = props.linkedChallengeWinners || null
|
||||
const linkedChallengeFinalists = props.linkedChallengeFinalists || null
|
||||
const communitySubmissions = props.communitySubmissions || null
|
||||
const rewardedContributors = props.rewardedContributors || null
|
||||
const previewMode = Boolean(props.previewMode)
|
||||
const archiveNotice = props.archiveNotice || null
|
||||
const familySummary = props.familySummary || null
|
||||
const currentEdition = props.currentEdition || null
|
||||
const previousEdition = props.previousEdition || null
|
||||
const nextEdition = props.nextEdition || null
|
||||
const archiveTitle = currentEdition ? 'Previous Editions' : 'Archive Editions'
|
||||
const archiveDescription = currentEdition
|
||||
? 'Earlier editions remain public so the recurring family keeps its full history accessible.'
|
||||
: 'Past iterations remain accessible so recurring worlds can build continuity over time.'
|
||||
const rootRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (previewMode || !world?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
const landing = resolveWorldLandingSource()
|
||||
|
||||
trackWorldAnalytics('world_viewed', {
|
||||
world_id: world.id,
|
||||
source_surface: landing.sourceSurface,
|
||||
source_detail: landing.sourceDetail,
|
||||
})
|
||||
}, [previewMode, world?.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (previewMode || !world?.id || !rootRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const landing = resolveWorldLandingSource()
|
||||
const container = rootRef.current
|
||||
|
||||
const clickHandler = (event) => {
|
||||
const target = event.target.closest('[data-world-event], [data-world-section-key]')
|
||||
if (!target || !container.contains(target)) {
|
||||
return
|
||||
}
|
||||
|
||||
const sectionKey = target.dataset.worldSectionKey || ''
|
||||
const challengeId = Number(target.dataset.worldChallengeId || 0)
|
||||
const entityId = Number(target.dataset.worldEntityId || 0)
|
||||
const payload = {
|
||||
world_id: world.id,
|
||||
source_surface: landing.sourceSurface,
|
||||
source_detail: landing.sourceDetail,
|
||||
...(sectionKey ? { section_key: sectionKey } : {}),
|
||||
...(target.dataset.worldCtaKey ? { cta_key: target.dataset.worldCtaKey } : {}),
|
||||
...(target.dataset.worldEntityType ? { entity_type: target.dataset.worldEntityType } : {}),
|
||||
...(entityId > 0 ? { entity_id: entityId } : {}),
|
||||
...(target.dataset.worldEntityTitle ? { entity_title: target.dataset.worldEntityTitle } : {}),
|
||||
...(challengeId > 0 ? { challenge_id: challengeId } : {}),
|
||||
}
|
||||
|
||||
if (sectionKey) {
|
||||
trackWorldAnalytics('world_section_clicked', payload)
|
||||
}
|
||||
|
||||
if (target.dataset.worldEvent) {
|
||||
trackWorldAnalytics(target.dataset.worldEvent, payload)
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('click', clickHandler)
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('click', clickHandler)
|
||||
}
|
||||
}, [previewMode, world?.id])
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.12),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
|
||||
<main ref={rootRef} className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.12),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
|
||||
<SeoHead title={props.seo?.title || `${world?.title || 'World'} - Skinbase Nova`} description={props.seo?.description || world?.summary} image={props.seo?.image} />
|
||||
<div className="mx-auto max-w-7xl">
|
||||
{previewMode ? (
|
||||
@@ -49,26 +241,65 @@ export default function WorldShow() {
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<WorldHero world={world} previewMode={previewMode} />
|
||||
<WorldArchiveNotice notice={archiveNotice} />
|
||||
|
||||
{sections.length > 0 ? sections.map((section) => <WorldSection key={section.key} section={section} />) : (
|
||||
<section className="mt-10 rounded-[28px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">
|
||||
This world has been themed and published, but no curated sections have been attached yet.
|
||||
{recap ? <WorldRecapHero world={world} recap={recap} previewMode={previewMode} /> : <WorldHero world={world} previewMode={previewMode} />}
|
||||
|
||||
{recap ? (
|
||||
<>
|
||||
<WorldRecapSummaryCard recap={recap} />
|
||||
<WorldRecapStatsGrid stats={recap.stats} />
|
||||
<WorldRecapArticleCard article={recap.article} />
|
||||
<WorldRecapFeaturedArtworks section={recap.featured_artworks} />
|
||||
<WorldChallengePanel section={linkedChallenge} />
|
||||
</>
|
||||
) : <WorldChallengePanel section={linkedChallenge} />}
|
||||
|
||||
{familySummary ? (
|
||||
<section className="mt-10">
|
||||
<div className="mb-5">
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">Recurring Family</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">Each edition stays public, but the family route always resolves to the canonical current or latest edition.</p>
|
||||
</div>
|
||||
<WorldFamilyCard family={familySummary} sourceSurface="navigation" sourceDetail="family_summary" />
|
||||
</section>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<WorldCommunitySubmissionsSection section={communitySubmissions} />
|
||||
{sections.length > 0 ? sections.map((section) => <WorldSection key={section.key} section={section} />) : null}
|
||||
|
||||
<WorldChallengeEntriesRail section={linkedChallengeEntries} challengeId={linkedChallenge?.id || null} />
|
||||
|
||||
<WorldChallengeWinnersPanel section={linkedChallengeWinners} challengeId={linkedChallenge?.id || null} />
|
||||
|
||||
<WorldChallengeFinalistsGrid panel={linkedChallenge} section={linkedChallengeFinalists} />
|
||||
|
||||
{recap ? <WorldRecapCommunityHighlights section={recap.community_highlights} /> : <RewardedContributors section={rewardedContributors} world={world} />}
|
||||
|
||||
{recap ? <WorldRecapCreatorsPanel section={recap.creators} /> : <WorldCommunitySubmissionsSection section={communitySubmissions} />}
|
||||
|
||||
{currentEdition ? (
|
||||
<SupportingRail
|
||||
title="Current Edition"
|
||||
description="This recurring family has a newer public edition. Use the family route to follow the current canonical page."
|
||||
items={[currentEdition]}
|
||||
sourceDetail="current_edition"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<EditionNavigation previousEdition={previousEdition} nextEdition={nextEdition} />
|
||||
|
||||
<SupportingRail
|
||||
title="Archive Editions"
|
||||
description="Past iterations remain accessible so recurring worlds can build continuity over time."
|
||||
title={archiveTitle}
|
||||
description={archiveDescription}
|
||||
items={props.archiveEditions}
|
||||
sourceDetail="archive_editions"
|
||||
/>
|
||||
|
||||
<SupportingRail
|
||||
title="Related Worlds"
|
||||
description="Other worlds sharing the same recurrence, theme, or editorial lineage."
|
||||
description="Other worlds with adjacent themes, related editorial mood, or connected programming context."
|
||||
items={props.relatedWorlds}
|
||||
sourceDetail="related_worlds"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user