Build world campaigns rewards and recaps
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function WorldDuplicateActionMenu({ duplicateUrl, newEditionUrl, canCreateEdition, onDuplicate, onCreateEdition }) {
|
||||
export default function WorldDuplicateActionMenu({ duplicateUrl, newEditionUrl, canCreateEdition, onDuplicate, onCreateEdition, copyModeCount = 0 }) {
|
||||
if (!duplicateUrl && !newEditionUrl) {
|
||||
return null
|
||||
}
|
||||
@@ -19,7 +19,8 @@ export default function WorldDuplicateActionMenu({ duplicateUrl, newEditionUrl,
|
||||
</div>
|
||||
|
||||
{!canCreateEdition ? <div className="mt-3 text-xs leading-5 text-slate-500">Next-edition creation unlocks once this world has recurrence data.</div> : null}
|
||||
<div className="mt-3 text-xs leading-5 text-slate-500">Template creation is prepared through duplication. A dedicated preset/template browser can be layered on top later without changing the editor data model.</div>
|
||||
{copyModeCount > 1 ? <div className="mt-3 text-xs leading-5 text-slate-500">Each action lets you choose whether to carry over curated relations or start from a clean structural shell.</div> : null}
|
||||
<div className="mt-3 text-xs leading-5 text-slate-500">Next-edition drafts preserve the recurrence key, increment the edition year, and reset live dates plus homepage flags so the new edition starts clean.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import Modal from '../../ui/Modal'
|
||||
|
||||
function SearchResultList({ items, loading, selectedId, onSelect }) {
|
||||
if (loading) {
|
||||
return <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">Searching group challenges…</div>
|
||||
}
|
||||
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">Search by challenge title, slug, or group name to link a primary challenge.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{items.map((item) => (
|
||||
<button key={item.id} type="button" onClick={() => onSelect(item)} className={`min-w-0 flex items-start gap-3 rounded-[24px] border px-4 py-4 text-left transition ${String(selectedId) === String(item.id) ? 'border-emerald-300/25 bg-emerald-400/10' : 'border-white/10 bg-black/20 hover:border-white/20'}`}>
|
||||
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-2xl border border-white/10 bg-slate-950">
|
||||
{item.image ? <img src={item.image} alt="" className="h-full w-full object-cover" /> : null}
|
||||
{!item.image && item.avatar ? <img src={item.avatar} alt="" className="h-full w-full object-cover" /> : null}
|
||||
{!item.image && !item.avatar ? <div className="flex h-full w-full items-center justify-center text-slate-500"><i className="fa-solid fa-trophy" /></div> : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{item.entity_label ? <span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100">{item.entity_label}</span> : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold text-white">{item.title}</div>
|
||||
{item.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{item.subtitle}</div> : null}
|
||||
{item.description ? <div className="mt-2 line-clamp-2 text-sm leading-6 text-slate-400">{item.description}</div> : null}
|
||||
{Array.isArray(item.meta) && item.meta.length > 0 ? <div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-500">{item.meta.map((meta) => <span key={meta}>{meta}</span>)}</div> : null}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WorldLinkedChallengePickerModal({ open, onClose, onSave, initialChallenge, searchEntities }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selected, setSelected] = useState(initialChallenge || null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
setQuery(initialChallenge?.title || '')
|
||||
setSelected(initialChallenge || null)
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
}, [open, initialChallenge])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
return undefined
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const timeoutId = window.setTimeout(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const items = await searchEntities('challenge', query || '')
|
||||
if (!cancelled) {
|
||||
setResults(Array.isArray(items) ? items : [])
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, query ? 220 : 0)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}, [open, query, searchEntities])
|
||||
|
||||
const selectedPreview = useMemo(() => selected || null, [selected])
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<button type="button" onClick={onClose} className="rounded-full border border-white/10 px-4 py-2 text-sm font-semibold text-white">Cancel</button>
|
||||
<button type="button" onClick={() => selectedPreview && onSave(selectedPreview)} disabled={!selectedPreview} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100 disabled:cursor-not-allowed disabled:opacity-50">Link challenge</button>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Link primary challenge" size="2xl" footer={footer}>
|
||||
<div className="grid gap-5 overflow-x-hidden">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search</span>
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search challenge title, slug, or group" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
|
||||
<SearchResultList items={results} loading={loading} selectedId={selectedPreview?.id} onSelect={(item) => {
|
||||
setSelected(item)
|
||||
setQuery(item.title)
|
||||
}} />
|
||||
|
||||
{selectedPreview ? (
|
||||
<div className="rounded-[24px] border border-emerald-300/20 bg-emerald-400/10 p-4 text-sm text-emerald-50">
|
||||
<div className="break-words font-semibold">Selected: {selectedPreview.title}</div>
|
||||
{selectedPreview.subtitle ? <div className="mt-1 break-words text-xs uppercase tracking-[0.14em] text-emerald-100/70">{selectedPreview.subtitle}</div> : null}
|
||||
{Array.isArray(selectedPreview.meta) && selectedPreview.meta.length > 0 ? <div className="mt-2 flex flex-wrap gap-2 text-xs text-emerald-100/75">{selectedPreview.meta.map((entry) => <span key={entry}>{entry}</span>)}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -28,10 +28,13 @@ export default function WorldMediaUploadField({
|
||||
const [error, setError] = useState('')
|
||||
const [meta, setMeta] = useState(null)
|
||||
|
||||
const csrfToken = useMemo(
|
||||
() => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
[],
|
||||
)
|
||||
const csrfToken = useMemo(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return ''
|
||||
}
|
||||
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}, [])
|
||||
|
||||
const deleteTemporaryUpload = async (path) => {
|
||||
if (!deleteUrl || !path) return
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import Modal from '../../ui/Modal'
|
||||
|
||||
function SearchResultList({ items, loading, selectedId, onSelect }) {
|
||||
if (loading) {
|
||||
return <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">Searching recap articles…</div>
|
||||
}
|
||||
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">Search published news by title, slug, or category to link a recap article.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{items.map((item) => (
|
||||
<button key={item.id} type="button" onClick={() => onSelect(item)} className={`min-w-0 flex items-start gap-3 rounded-[24px] border px-4 py-4 text-left transition ${String(selectedId) === String(item.id) ? 'border-emerald-300/25 bg-emerald-400/10' : 'border-white/10 bg-black/20 hover:border-white/20'}`}>
|
||||
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-2xl border border-white/10 bg-slate-950">
|
||||
{item.image ? <img src={item.image} alt="" className="h-full w-full object-cover" /> : null}
|
||||
{!item.image && item.avatar ? <img src={item.avatar} alt="" className="h-full w-full object-cover" /> : null}
|
||||
{!item.image && !item.avatar ? <div className="flex h-full w-full items-center justify-center text-slate-500"><i className="fa-solid fa-newspaper" /></div> : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{item.entity_label ? <span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100">{item.entity_label}</span> : null}
|
||||
<div className="mt-2 text-sm font-semibold text-white">{item.title}</div>
|
||||
{item.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{item.subtitle}</div> : null}
|
||||
{item.description ? <div className="mt-2 line-clamp-2 text-sm leading-6 text-slate-400">{item.description}</div> : null}
|
||||
{Array.isArray(item.meta) && item.meta.length > 0 ? <div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-500">{item.meta.map((meta) => <span key={meta}>{meta}</span>)}</div> : null}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WorldRecapArticlePickerModal({ open, onClose, onSave, initialArticle, searchEntities }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selected, setSelected] = useState(initialArticle || null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
setQuery(initialArticle?.title || '')
|
||||
setSelected(initialArticle || null)
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
}, [open, initialArticle])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setResults([])
|
||||
setLoading(false)
|
||||
return undefined
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const timeoutId = window.setTimeout(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const items = await searchEntities('news', query || '')
|
||||
if (!cancelled) {
|
||||
setResults(Array.isArray(items) ? items : [])
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, query ? 220 : 0)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}, [open, query, searchEntities])
|
||||
|
||||
const selectedPreview = useMemo(() => selected || null, [selected])
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<button type="button" onClick={onClose} className="rounded-full border border-white/10 px-4 py-2 text-sm font-semibold text-white">Cancel</button>
|
||||
<button type="button" onClick={() => selectedPreview && onSave(selectedPreview)} disabled={!selectedPreview} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100 disabled:cursor-not-allowed disabled:opacity-50">Link article</button>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Link recap article" size="2xl" footer={footer}>
|
||||
<div className="grid gap-5 overflow-x-hidden">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search</span>
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search article title, slug, or category" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
|
||||
<SearchResultList items={results} loading={loading} selectedId={selectedPreview?.id} onSelect={(item) => {
|
||||
setSelected(item)
|
||||
setQuery(item.title)
|
||||
}} />
|
||||
|
||||
{selectedPreview ? (
|
||||
<div className="rounded-[24px] border border-emerald-300/20 bg-emerald-400/10 p-4 text-sm text-emerald-50">
|
||||
<div className="break-words font-semibold">Selected: {selectedPreview.title}</div>
|
||||
{selectedPreview.subtitle ? <div className="mt-1 break-words text-xs uppercase tracking-[0.14em] text-emerald-100/70">{selectedPreview.subtitle}</div> : null}
|
||||
{Array.isArray(selectedPreview.meta) && selectedPreview.meta.length > 0 ? <div className="mt-2 flex flex-wrap gap-2 text-xs text-emerald-100/75">{selectedPreview.meta.map((entry) => <span key={entry}>{entry}</span>)}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export default function WorldRecurrenceHelper({ enabled, recurrenceKey, editionY
|
||||
<p>Use the recurrence key to identify the campaign family. Example: <span className="font-semibold text-white">{exampleKey}</span>.</p>
|
||||
<p>Use the edition year for the specific annual or seasonal instance. Example: <span className="font-semibold text-white">{exampleYear}</span>.</p>
|
||||
<p className="text-sky-100">Example output: {exampleKey === '' ? 'Halloween' : exampleKey.replace(/-/g, ' ')} {exampleYear} is part of the recurring world <span className="font-semibold text-white">{exampleKey}</span>.</p>
|
||||
<p>The family route resolves to the current or latest edition, while archived editions remain available on a year-specific URL.</p>
|
||||
</div>
|
||||
|
||||
{recurrenceKeyError || editionYearError ? (
|
||||
|
||||
@@ -19,25 +19,35 @@ function typeLabel(value) {
|
||||
}
|
||||
|
||||
function promotionState(world, state) {
|
||||
if (!world?.is_featured) {
|
||||
if (!world?.is_active_campaign) {
|
||||
return {
|
||||
label: 'Public page only',
|
||||
message: 'This world will live at its own URL, but it is not currently marked for homepage or Worlds spotlight placement.',
|
||||
message: 'This world will live at its own URL, but it is not currently marked as an active campaign for stronger discovery surfaces.',
|
||||
tone: 'slate',
|
||||
}
|
||||
}
|
||||
|
||||
if (world?.is_homepage_featured && state.label === 'Live') {
|
||||
return {
|
||||
label: 'Homepage spotlight ready',
|
||||
message: 'This campaign is active and flagged for homepage spotlight, so it is eligible for the strongest public placement.',
|
||||
tone: 'emerald',
|
||||
}
|
||||
}
|
||||
|
||||
if (state.label === 'Live') {
|
||||
return {
|
||||
label: 'Active seasonal promotion',
|
||||
message: 'Featured promotion is enabled and the world is live, so it is ready for homepage spotlight and promoted Worlds surfaces.',
|
||||
label: 'Active campaign',
|
||||
message: 'Campaign activation is enabled and the world is currently live across promotion-aware surfaces.',
|
||||
tone: 'emerald',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label: 'Homepage spotlight eligible',
|
||||
message: 'Featured promotion is enabled. Once the world is live, it becomes eligible for homepage and Worlds spotlight treatment.',
|
||||
label: world?.is_homepage_featured ? 'Homepage promotion queued' : 'Campaign promotion queued',
|
||||
message: world?.is_homepage_featured
|
||||
? 'Homepage spotlight is enabled. Once the campaign goes live, it can occupy the main homepage promotion slot.'
|
||||
: 'Campaign activation is enabled. Once the world goes live, upload and worlds surfaces can prioritize it.',
|
||||
tone: 'sky',
|
||||
}
|
||||
}
|
||||
@@ -107,6 +117,8 @@ export default function WorldSummaryCard({ world, themeLabel, relationCount, ena
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Theme preset</div><div className="mt-2 text-sm font-semibold text-white">{themeLabel || 'No preset'}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Campaign window</div><div className="mt-2 text-sm font-semibold text-white">{world?.starts_at || world?.ends_at ? `${formatDateTime(world?.starts_at)} to ${formatDateTime(world?.ends_at)}` : 'Open ended'}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Publish at</div><div className="mt-2 text-sm font-semibold text-white">{formatDateTime(world?.published_at)}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Promotion window</div><div className="mt-2 text-sm font-semibold text-white">{world?.promotion_starts_at || world?.promotion_ends_at ? `${formatDateTime(world?.promotion_starts_at)} to ${formatDateTime(world?.promotion_ends_at)}` : 'Uses campaign window'}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Activation</div><div className="mt-2 text-sm font-semibold text-white">{world?.is_active_campaign ? (world?.is_homepage_featured ? 'Active + homepage featured' : 'Active campaign') : 'Standalone public page'}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Recurrence</div><div className="mt-2 text-sm font-semibold text-white">{world?.is_recurring ? `${world?.recurrence_key || 'recurring'} ${world?.edition_year || ''}`.trim() : 'One-off world'}</div></div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Editorial setup</div><div className="mt-2 text-sm font-semibold text-white">{relationCount} relations · {enabledSectionsCount} enabled sections</div></div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react'
|
||||
import WorldSuggestionCard from './WorldSuggestionCard'
|
||||
|
||||
export default function WorldChallengeSuggestionPanel({ group, busyKey, onAddFeatured, onAddSection, onPin, onDismiss, onNotRelevant, onRestore }) {
|
||||
if (!group) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-[24px] border border-sky-300/15 bg-sky-400/[0.06] p-4">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100/80">Challenge-aware suggestions</div>
|
||||
<h3 className="mt-2 text-lg font-semibold text-white">{group.label}</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-300">{group.description}</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100">{group.count} ready</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4">
|
||||
{group.items.map((item) => (
|
||||
<WorldSuggestionCard
|
||||
key={item.key}
|
||||
item={item}
|
||||
busyKey={busyKey === item.key ? busyKey : ''}
|
||||
onAddFeatured={onAddFeatured}
|
||||
onAddSection={onAddSection}
|
||||
onPin={onPin}
|
||||
onDismiss={onDismiss}
|
||||
onNotRelevant={onNotRelevant}
|
||||
onRestore={onRestore}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react'
|
||||
import WorldSuggestionActions from './WorldSuggestionActions'
|
||||
import WorldSuggestionReasonPills from './WorldSuggestionReasonPills'
|
||||
|
||||
function TinyBadge({ children, tone = 'default' }) {
|
||||
const tones = {
|
||||
default: 'border-white/10 bg-white/[0.05] text-slate-200',
|
||||
sky: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
|
||||
emerald: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
|
||||
amber: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
|
||||
rose: 'border-rose-300/20 bg-rose-400/10 text-rose-100',
|
||||
}
|
||||
|
||||
return <span className={`rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${tones[tone] || tones.default}`}>{children}</span>
|
||||
}
|
||||
|
||||
export default function WorldSuggestionCard({ item, busyKey, onAddFeatured, onAddSection, onPin, onDismiss, onNotRelevant, onRestore }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex flex-col gap-4 xl:flex-row">
|
||||
<div className="relative h-24 w-24 shrink-0 overflow-hidden rounded-[20px] border border-white/10 bg-slate-950">
|
||||
{item.image ? <img src={item.image} alt="" className="h-full w-full object-cover" /> : null}
|
||||
{!item.image && item.avatar ? <img src={item.avatar} alt="" className="h-full w-full object-cover" /> : null}
|
||||
{!item.image && !item.avatar ? <div className="flex h-full w-full items-center justify-center text-slate-500"><i className="fa-solid fa-stars" /></div> : null}
|
||||
{item.avatar && item.image ? <img src={item.avatar} alt="" className="absolute bottom-2 left-2 h-9 w-9 rounded-xl border border-white/10 object-cover" /> : null}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{item.entity_label ? <TinyBadge tone="sky">{item.entity_label}</TinyBadge> : null}
|
||||
{item.category_label ? <TinyBadge>{item.category_label}</TinyBadge> : null}
|
||||
{item.signals?.challenge_linked ? <TinyBadge tone="sky">Challenge-linked</TinyBadge> : null}
|
||||
{item.signals?.community_submission ? <TinyBadge tone="emerald">Community signal</TinyBadge> : null}
|
||||
{item.signals?.recurring_history_informed ? <TinyBadge tone="default">Recurring signal</TinyBadge> : null}
|
||||
{item.signals?.analytics_informed ? <TinyBadge tone="amber">Analytics cue</TinyBadge> : null}
|
||||
{item.state?.status === 'pinned' ? <TinyBadge tone="amber">Pinned</TinyBadge> : null}
|
||||
{item.state?.status === 'dismissed' ? <TinyBadge tone="default">Dismissed</TinyBadge> : null}
|
||||
{item.state?.status === 'not_relevant' ? <TinyBadge tone="rose">Not relevant</TinyBadge> : null}
|
||||
{item.score_label ? <TinyBadge tone="emerald">{item.score_label}</TinyBadge> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-base font-semibold text-white">{item.title}</div>
|
||||
{item.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{item.subtitle}</div> : null}
|
||||
</div>
|
||||
<div className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-200">Score {item.score}</div>
|
||||
</div>
|
||||
|
||||
{item.description ? <div className="mt-3 text-sm leading-6 text-slate-300">{item.description}</div> : null}
|
||||
{item.context_label ? <div className="mt-3 text-sm font-medium text-sky-100">{item.context_label}</div> : null}
|
||||
{Array.isArray(item.meta) && item.meta.length > 0 ? <div className="mt-3 flex flex-wrap gap-2 text-xs text-slate-500">{item.meta.map((entry) => <span key={entry}>{entry}</span>)}</div> : null}
|
||||
|
||||
<WorldSuggestionReasonPills reasons={item.reasons} />
|
||||
|
||||
{item.url ? <a href={item.url} target="_blank" rel="noreferrer" className="mt-4 inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-slate-300 hover:text-white">Open source entity <i className="fa-solid fa-up-right-from-square" /></a> : null}
|
||||
|
||||
<WorldSuggestionActions
|
||||
item={item}
|
||||
busyKey={busyKey}
|
||||
onAddFeatured={onAddFeatured}
|
||||
onAddSection={onAddSection}
|
||||
onPin={onPin}
|
||||
onDismiss={onDismiss}
|
||||
onNotRelevant={onNotRelevant}
|
||||
onRestore={onRestore}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react'
|
||||
|
||||
const TONE_CLASSES = {
|
||||
default: 'border-white/10 bg-white/[0.05] text-slate-200',
|
||||
slate: 'border-white/10 bg-white/[0.05] text-slate-300',
|
||||
sky: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
|
||||
emerald: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
|
||||
amber: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
|
||||
rose: 'border-rose-300/20 bg-rose-400/10 text-rose-100',
|
||||
}
|
||||
|
||||
export default function WorldSuggestionReasonPills({ reasons = [] }) {
|
||||
if (!Array.isArray(reasons) || reasons.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{reasons.map((reason) => (
|
||||
<span
|
||||
key={`${reason.label}-${reason.tone || 'default'}`}
|
||||
className={`rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${TONE_CLASSES[reason.tone] || TONE_CLASSES.default}`}
|
||||
>
|
||||
{reason.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import WorldChallengeSuggestionPanel from './WorldChallengeSuggestionPanel'
|
||||
import WorldSuggestionCard from './WorldSuggestionCard'
|
||||
import WorldSuggestionFilters from './WorldSuggestionFilters'
|
||||
|
||||
function SummaryPill({ label, value, tone = 'default' }) {
|
||||
const tones = {
|
||||
default: 'border-white/10 bg-white/[0.04] text-slate-200',
|
||||
sky: 'border-sky-300/20 bg-sky-400/10 text-sky-100',
|
||||
emerald: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
|
||||
amber: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border px-4 py-3 ${tones[tone] || tones.default}`}>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] opacity-80">{label}</div>
|
||||
<div className="mt-2 text-lg font-semibold">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function matchesFilters(item, filters) {
|
||||
if (filters.category && item.category_key !== filters.category) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filters.type && item.entity_type !== filters.type) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filters.section && !item.section_targets?.some((target) => target.value === filters.section)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filters.challengeOnly && !item.signals?.challenge_linked) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filters.communityOnly && !item.signals?.community_submission) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filters.recurringOnly && !item.signals?.recurring_history_informed) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filters.analyticsOnly && !item.signals?.analytics_informed) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function sortItems(items, sortMode) {
|
||||
const list = Array.isArray(items) ? [...items] : []
|
||||
|
||||
return list.sort((left, right) => {
|
||||
if (sortMode === 'newest') {
|
||||
return Number(right?.ranking?.freshness_timestamp || 0) - Number(left?.ranking?.freshness_timestamp || 0)
|
||||
}
|
||||
|
||||
if (sortMode === 'performance') {
|
||||
return Number(right?.ranking?.performance_value || 0) - Number(left?.ranking?.performance_value || 0)
|
||||
}
|
||||
|
||||
return Number(right?.score || 0) - Number(left?.score || 0)
|
||||
})
|
||||
}
|
||||
|
||||
export default function WorldSuggestionsPanel({ suggestions, notice = null, worldExists = false, busyKey = '', onAddFeatured, onAddSection, onPin, onDismiss, onNotRelevant, onRestore }) {
|
||||
const [filters, setFilters] = useState({
|
||||
category: '',
|
||||
type: '',
|
||||
section: '',
|
||||
sort: 'relevance',
|
||||
challengeOnly: false,
|
||||
communityOnly: false,
|
||||
recurringOnly: false,
|
||||
analyticsOnly: false,
|
||||
showSuppressed: false,
|
||||
})
|
||||
|
||||
const groups = Array.isArray(suggestions?.groups) ? suggestions.groups : []
|
||||
const pinnedItems = Array.isArray(suggestions?.pinned_items) ? suggestions.pinned_items : []
|
||||
const suppressedItems = Array.isArray(suggestions?.suppressed_items) ? suggestions.suppressed_items : []
|
||||
|
||||
const visibleGroups = useMemo(() => groups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: sortItems((Array.isArray(group.items) ? group.items : []).filter((item) => matchesFilters(item, filters)), filters.sort),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0 || filters.category === group.key), [filters, groups])
|
||||
|
||||
const visiblePinned = useMemo(() => sortItems(pinnedItems.filter((item) => matchesFilters(item, filters)), filters.sort), [filters, pinnedItems])
|
||||
const visibleSuppressed = useMemo(() => sortItems(suppressedItems.filter((item) => matchesFilters(item, filters)), filters.sort), [filters, suppressedItems])
|
||||
|
||||
if (!worldExists) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm leading-6 text-slate-300">
|
||||
Save the world once to unlock editorial suggestions. The suggestion service uses real world metadata, submissions, linked challenge context, and recurring-family signals, so it needs a persisted edition to score against.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div 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-start lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">World editorial suggestions</h2>
|
||||
<p className="mt-1 max-w-3xl text-sm leading-6 text-slate-400">Review scored candidate artworks, creators, collections, groups, stories, and challenge standouts without auto-publishing anything into the world.</p>
|
||||
</div>
|
||||
{suggestions?.generated_at ? <div className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">Refreshed {new Date(suggestions.generated_at).toLocaleString()}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<SummaryPill label="Ready now" value={suggestions?.summary?.available_count || 0} tone="emerald" />
|
||||
<SummaryPill label="Pinned" value={suggestions?.summary?.pinned_count || 0} tone="amber" />
|
||||
<SummaryPill label="Suppressed" value={suggestions?.summary?.suppressed_count || 0} />
|
||||
<SummaryPill label="Community signal" value={suggestions?.summary?.community_submission_count || 0} tone="sky" />
|
||||
<SummaryPill label="Analytics cues" value={suggestions?.summary?.analytics_signal_count || 0} tone="amber" />
|
||||
</div>
|
||||
|
||||
{notice ? <div className="mt-4 rounded-[20px] border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm text-sky-100">{notice}</div> : null}
|
||||
</div>
|
||||
|
||||
<WorldSuggestionFilters filters={suggestions?.filters || {}} value={filters} onChange={setFilters} />
|
||||
|
||||
{visiblePinned.length > 0 ? (
|
||||
<div className="rounded-[28px] border border-amber-300/15 bg-amber-400/[0.05] p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Pinned for later</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-300">These suggestions stay separate from the public world until you explicitly attach them.</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">{visiblePinned.length} pinned</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4">
|
||||
{visiblePinned.map((item) => (
|
||||
<WorldSuggestionCard
|
||||
key={item.key}
|
||||
item={item}
|
||||
busyKey={busyKey === item.key ? busyKey : ''}
|
||||
onAddFeatured={onAddFeatured}
|
||||
onAddSection={onAddSection}
|
||||
onPin={onPin}
|
||||
onDismiss={onDismiss}
|
||||
onNotRelevant={onNotRelevant}
|
||||
onRestore={onRestore}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{visibleGroups.length > 0 ? visibleGroups.map((group) => (
|
||||
group.key === 'challenge' ? (
|
||||
<WorldChallengeSuggestionPanel
|
||||
key={group.key}
|
||||
group={group}
|
||||
busyKey={busyKey}
|
||||
onAddFeatured={onAddFeatured}
|
||||
onAddSection={onAddSection}
|
||||
onPin={onPin}
|
||||
onDismiss={onDismiss}
|
||||
onNotRelevant={onNotRelevant}
|
||||
onRestore={onRestore}
|
||||
/>
|
||||
) : (
|
||||
<div key={group.key} className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{group.label}</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-400">{group.description}</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{group.items.length} ready</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4">
|
||||
{group.items.length > 0 ? group.items.map((item) => (
|
||||
<WorldSuggestionCard
|
||||
key={item.key}
|
||||
item={item}
|
||||
busyKey={busyKey === item.key ? busyKey : ''}
|
||||
onAddFeatured={onAddFeatured}
|
||||
onAddSection={onAddSection}
|
||||
onPin={onPin}
|
||||
onDismiss={onDismiss}
|
||||
onNotRelevant={onNotRelevant}
|
||||
onRestore={onRestore}
|
||||
/>
|
||||
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">{group.empty_label}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)) : (
|
||||
<div className="rounded-[28px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm leading-6 text-slate-300">
|
||||
No suggestions match the current filters. Change the filters or save new world metadata to refresh the candidate pool.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filters.showSuppressed ? (
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Suppressed suggestions</h3>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-400">Dismissed and not-relevant items stay out of the active queue until you restore them.</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{visibleSuppressed.length} hidden</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4">
|
||||
{visibleSuppressed.length > 0 ? visibleSuppressed.map((item) => (
|
||||
<WorldSuggestionCard
|
||||
key={item.key}
|
||||
item={item}
|
||||
busyKey={busyKey === item.key ? busyKey : ''}
|
||||
onAddFeatured={onAddFeatured}
|
||||
onAddSection={onAddSection}
|
||||
onPin={onPin}
|
||||
onDismiss={onDismiss}
|
||||
onNotRelevant={onNotRelevant}
|
||||
onRestore={onRestore}
|
||||
/>
|
||||
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">No suppressed suggestions match the current filters.</div>}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user