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