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,122 @@
import React from 'react'
import ProfileWorldRecognitionBadge from './ProfileWorldRecognitionBadge'
function formatDate(value) {
if (!value) return null
try {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return null
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} catch {
return null
}
}
export default function ProfileWorldHistoryCard({ entry }) {
const recognitionBadges = Array.isArray(entry?.recognitions) ? entry.recognitions : []
const artwork = entry?.linked_artwork
const challenge = entry?.challenge
return (
<article className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_18px_48px_rgba(2,6,23,0.18)] transition-colors hover:border-white/15 hover:bg-white/[0.055] md:p-6">
<div className="flex flex-col gap-5 lg:flex-row lg:items-start">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
{recognitionBadges.map((recognition, index) => (
<ProfileWorldRecognitionBadge key={`${entry.id}-${recognition.key}`} recognition={recognition} isPrimary={index === 0} />
))}
</div>
<div className="mt-4 flex flex-wrap items-baseline gap-x-3 gap-y-2">
<h3 className="text-xl font-semibold tracking-[-0.02em] text-white">{entry?.world?.title}</h3>
{entry?.world?.edition_year ? (
<span className="text-sm text-slate-400">Edition {entry.world.edition_year}</span>
) : null}
{entry?.world?.type_label ? (
<span className="rounded-full border border-white/10 bg-white/[0.05] px-2.5 py-1 text-[11px] font-medium text-slate-300">{entry.world.type_label}</span>
) : null}
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-slate-400">
{entry?.world?.family_label ? (
<span className="inline-flex items-center gap-2">
<i className="fa-solid fa-layer-group text-[11px] text-slate-500" />
{entry.world.family_label}
</span>
) : null}
{formatDate(entry?.occurred_at) ? (
<span className="inline-flex items-center gap-2">
<i className="fa-regular fa-calendar text-[11px] text-slate-500" />
{formatDate(entry.occurred_at)}
</span>
) : null}
{challenge?.title ? (
<span className="inline-flex items-center gap-2">
<i className="fa-solid fa-flag-checkered text-[11px] text-slate-500" />
{challenge.title}
</span>
) : null}
</div>
<div className="mt-4 flex flex-wrap items-center gap-3">
{entry?.world?.url ? (
<a
href={entry.world.url}
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-medium text-slate-100 transition-colors hover:bg-white/[0.08]"
>
<i className="fa-solid fa-globe text-xs" />
View world
</a>
) : null}
{artwork?.url ? (
<a
href={artwork.url}
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-medium text-slate-100 transition-colors hover:bg-white/[0.08]"
>
<i className="fa-solid fa-image text-xs" />
View artwork
</a>
) : null}
{challenge?.url ? (
<a
href={challenge.url}
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-medium text-slate-100 transition-colors hover:bg-white/[0.08]"
>
<i className="fa-solid fa-arrow-up-right-from-square text-xs" />
View challenge
</a>
) : null}
</div>
</div>
<div className="w-full shrink-0 lg:w-44">
{artwork?.thumbnail_url ? (
<a href={artwork.url || '#'} className="group block overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.03]">
<div className="aspect-[4/3] overflow-hidden bg-slate-900/60">
<img
src={artwork.thumbnail_url}
alt={artwork.title || entry?.world?.title || 'World artwork'}
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
loading="lazy"
/>
</div>
<div className="border-t border-white/10 px-4 py-3">
<div className="truncate text-sm font-medium text-white">{artwork.title}</div>
<div className="mt-1 text-xs text-slate-400">Linked artwork</div>
</div>
</a>
) : (
<div className="flex aspect-[4/3] items-center justify-center rounded-[24px] border border-dashed border-white/12 bg-white/[0.02] text-slate-500">
<div className="text-center">
<i className="fa-solid fa-globe text-xl" />
<div className="mt-2 text-xs uppercase tracking-[0.18em]">World entry</div>
</div>
</div>
)}
</div>
</div>
</article>
)
}

View File

@@ -0,0 +1,114 @@
import React from 'react'
import ProfileWorldHistorySummary from './ProfileWorldHistorySummary'
import ProfileWorldTimelineList from './ProfileWorldTimelineList'
function EmptyState({ isOwner, hasPrivateContext }) {
return (
<div className="rounded-[30px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-16 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-3xl border border-white/10 bg-white/[0.05] text-slate-500">
<i className="fa-solid fa-globe text-2xl" />
</div>
<h3 className="mt-5 text-xl font-semibold text-white">No public worlds timeline yet</h3>
<p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-slate-400">
{isOwner
? (hasPrivateContext
? 'Public world history appears here once a live, publicly visible submission or recognized placement is available. Until then, private-only world activity is still tracked for you above.'
: 'Public world history appears here once a live, publicly visible submission or recognized placement is available.')
: 'This creator does not have any public world participation or recognition to show yet.'}
</p>
</div>
)
}
function OwnerNote({ ownerContext }) {
if (!ownerContext) {
return null
}
const details = [
ownerContext.pending_submissions ? `${ownerContext.pending_submissions} pending submission${ownerContext.pending_submissions === 1 ? '' : 's'}` : null,
ownerContext.removed_or_blocked_submissions ? `${ownerContext.removed_or_blocked_submissions} removed or blocked item${ownerContext.removed_or_blocked_submissions === 1 ? '' : 's'}` : null,
ownerContext.hidden_public_entries ? `${ownerContext.hidden_public_entries} recognition${ownerContext.hidden_public_entries === 1 ? '' : 's'} hidden from public view` : null,
].filter(Boolean)
if (details.length === 0) {
return null
}
return (
<div className="rounded-[24px] border border-sky-300/15 bg-sky-400/10 px-5 py-4 text-sm text-sky-100">
<div className="flex items-start gap-3">
<div className="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-2xl border border-sky-300/20 bg-sky-300/10 text-sky-100">
<i className="fa-solid fa-eye text-sm" />
</div>
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/80">Private View</div>
<p className="mt-1 leading-relaxed text-sky-50/90">
Your public worlds timeline stays strict about visibility, but this profile still tracks {details.join(', ')}.
</p>
</div>
</div>
</div>
)
}
export default function ProfileWorldHistorySection({ history, isOwner }) {
const entries = Array.isArray(history?.entries) ? history.entries : []
const highlights = Array.isArray(history?.highlights) ? history.highlights : []
const highlightIds = new Set(highlights.map((entry) => entry?.id).filter(Boolean))
const timelineEntries = entries.filter((entry) => !highlightIds.has(entry?.id))
const hasPrivateContext = Boolean(
history?.owner_context?.pending_submissions
|| history?.owner_context?.removed_or_blocked_submissions
|| history?.owner_context?.hidden_public_entries
)
const hasEntries = Boolean(history?.summary?.available) && entries.length > 0
return (
<section className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Worlds History</div>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.02em] text-white md:text-3xl">Recurring worlds, challenge outcomes, and standout editions</h2>
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-slate-400">
This timeline pulls together edition-aware world participation, featured placements, finalists, winners, and linked challenge results into one creator-facing history layer.
</p>
</div>
</div>
<OwnerNote ownerContext={isOwner ? history?.owner_context : null} />
{hasEntries ? (
<>
<ProfileWorldHistorySummary history={history} />
{highlights.length > 0 ? (
<div className="space-y-3">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Highlights</div>
<p className="mt-2 text-sm text-slate-400">
The most recent and highest-signal world appearances surface first so recurring recognition reads like a creator recap, not just a raw list.
</p>
</div>
<ProfileWorldTimelineList entries={highlights} />
</div>
) : null}
{timelineEntries.length > 0 ? (
<div className="space-y-3">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Full Timeline</div>
<p className="mt-2 text-sm text-slate-400">
Every public world appearance, challenge-linked outcome, and edition-aware placement remains visible here in chronological order.
</p>
</div>
<ProfileWorldTimelineList entries={timelineEntries} />
</div>
) : null}
</>
) : (
<EmptyState isOwner={isOwner} hasPrivateContext={hasPrivateContext} />
)}
</section>
)
}

View File

@@ -0,0 +1,54 @@
import React from 'react'
import ProfileWorldStatsRow from './ProfileWorldStatsRow'
import ProfileWorldRecognitionBadge from './ProfileWorldRecognitionBadge'
function formatDate(value) {
if (!value) return null
try {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return null
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} catch {
return null
}
}
export default function ProfileWorldHistorySummary({ history }) {
const summary = history?.summary || {}
const recent = summary?.most_recent_world_activity
const recentRecognition = recent?.primary_recognition || (recent?.recognition_label
? { key: String(recent.recognition_label).toLowerCase().replace(/\s+/g, '_'), label: recent.recognition_label, tone: 'sky' }
: null)
return (
<div className="space-y-4">
<ProfileWorldStatsRow summary={summary} />
{recent ? (
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 shadow-[0_18px_46px_rgba(2,6,23,0.18)] md:p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">Most Recent World Activity</div>
<div className="mt-2 text-xl font-semibold tracking-[-0.02em] text-white">{recent.world_title}</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
{recentRecognition ? <ProfileWorldRecognitionBadge recognition={recentRecognition} isPrimary /> : null}
{formatDate(recent.occurred_at) ? <span className="text-xs text-slate-400">{formatDate(recent.occurred_at)}</span> : null}
</div>
</div>
{recent.world_url ? (
<a
href={recent.world_url}
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-medium text-slate-100 transition-colors hover:bg-white/[0.08]"
>
<i className="fa-solid fa-arrow-up-right-from-square text-xs" />
View world
</a>
) : null}
</div>
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,28 @@
import React from 'react'
function toneClasses(tone, isPrimary) {
const styles = {
emerald: 'border-emerald-300/20 bg-emerald-400/12 text-emerald-100',
violet: 'border-violet-300/20 bg-violet-400/12 text-violet-100',
amber: 'border-amber-300/20 bg-amber-400/12 text-amber-100',
rose: 'border-rose-300/20 bg-rose-400/12 text-rose-100',
sky: 'border-sky-300/20 bg-sky-400/12 text-sky-100',
slate: 'border-slate-300/15 bg-slate-300/10 text-slate-200',
}
const base = styles[tone] || styles.sky
return `${base} ${isPrimary ? 'shadow-[0_0_22px_rgba(255,255,255,0.05)]' : ''}`.trim()
}
export default function ProfileWorldRecognitionBadge({ recognition, isPrimary = false }) {
if (!recognition) {
return null
}
return (
<span className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] ${toneClasses(recognition.tone, isPrimary)}`}>
<span className="h-1.5 w-1.5 rounded-full bg-current opacity-80" />
{recognition.label}
</span>
)
}

View File

@@ -0,0 +1,58 @@
import React from 'react'
const TONE_STYLES = {
sky: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
amber: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
emerald: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
violet: 'border-violet-300/20 bg-violet-400/10 text-violet-100',
}
function StatCard({ icon, label, value, tone, hint }) {
return (
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-4 shadow-[0_18px_42px_rgba(2,6,23,0.18)]">
<div className={`inline-flex h-11 w-11 items-center justify-center rounded-2xl border ${TONE_STYLES[tone] || TONE_STYLES.sky}`}>
<i className={`fa-solid ${icon} text-sm`} />
</div>
<div className="mt-4 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</div>
<div className="mt-1 text-2xl font-bold tracking-tight text-white">{value}</div>
{hint ? <div className="mt-2 text-xs leading-relaxed text-slate-400">{hint}</div> : null}
</div>
)
}
export default function ProfileWorldStatsRow({ summary }) {
const worldAppearances = summary?.world_appearances || summary?.worlds_joined || 0
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<StatCard
icon="fa-globe"
label="World Appearances"
value={worldAppearances}
tone="sky"
hint={summary?.active_year_span?.label ? `Active across ${summary.active_year_span.label}` : 'Edition-aware creator history'}
/>
<StatCard
icon="fa-stars"
label="Featured"
value={summary?.featured_appearances || 0}
tone="amber"
hint="Editorial features and highlighted placements"
/>
<StatCard
icon="fa-trophy"
label="Wins / Finalists"
value={summary?.finalist_winner_appearances || 0}
tone="emerald"
hint="Higher-tier placements tied to world-linked challenges"
/>
<StatCard
icon="fa-bolt"
label="Spotlights"
value={summary?.spotlight_appearances || 0}
tone="violet"
hint="Editorial spotlight moments across editions"
/>
</div>
)
}

View File

@@ -0,0 +1,14 @@
import React from 'react'
import ProfileWorldHistoryCard from './ProfileWorldHistoryCard'
export default function ProfileWorldTimelineList({ entries }) {
const items = Array.isArray(entries) ? entries : []
return (
<div className="space-y-4">
{items.map((entry) => (
<ProfileWorldHistoryCard key={entry.id} entry={entry} />
))}
</div>
)
}