Build world campaigns rewards and recaps
This commit is contained in:
@@ -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