Build world campaigns rewards and recaps
This commit is contained in:
@@ -1,23 +1,8 @@
|
||||
import React from 'react'
|
||||
|
||||
function statusTone(item) {
|
||||
if (item?.is_featured) {
|
||||
return 'border-amber-300/30 bg-amber-400/10 text-amber-100'
|
||||
}
|
||||
|
||||
switch (item?.status) {
|
||||
case 'live':
|
||||
return 'border-emerald-300/30 bg-emerald-400/10 text-emerald-100'
|
||||
case 'removed':
|
||||
return 'border-orange-300/30 bg-orange-400/10 text-orange-100'
|
||||
case 'blocked':
|
||||
return 'border-rose-300/30 bg-rose-400/10 text-rose-100'
|
||||
case 'pending':
|
||||
return 'border-sky-300/30 bg-sky-400/10 text-sky-100'
|
||||
default:
|
||||
return 'border-white/10 bg-white/[0.04] text-slate-300'
|
||||
}
|
||||
}
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import UploadWorldHighlightCard from './UploadWorldHighlightCard'
|
||||
import WorldCampaignMeta from './WorldCampaignMeta'
|
||||
import WorldStatusBadge from './WorldStatusBadge'
|
||||
import { trackWorldAnalytics, trackWorldSourceImpression } from '../../lib/worldAnalytics'
|
||||
|
||||
function modeTone(mode) {
|
||||
switch (mode) {
|
||||
@@ -49,8 +34,72 @@ export default function WorldSubmissionSelector({
|
||||
onToggle,
|
||||
onNoteChange,
|
||||
className = '',
|
||||
analyticsContext = null,
|
||||
}) {
|
||||
const items = Array.isArray(options) ? options : []
|
||||
const highlightedWorld = items.find((item) => item.is_active_campaign && item.is_accepting_submissions)
|
||||
const itemRefs = useRef(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
if (!analyticsContext?.sourceSurface || typeof window === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const refs = Array.from(itemRefs.current.entries())
|
||||
if (refs.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (typeof window.IntersectionObserver !== 'function') {
|
||||
refs.forEach(([worldId]) => {
|
||||
const item = items.find((candidate) => Number(candidate.id) === Number(worldId))
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
|
||||
trackWorldSourceImpression({
|
||||
worldId: item.id,
|
||||
worldTitle: item.title || item.teaser_title || '',
|
||||
sourceSurface: analyticsContext.sourceSurface,
|
||||
sourceDetail: analyticsContext.sourceDetail ? `${analyticsContext.sourceDetail}:selector` : 'selector',
|
||||
sectionKey: 'community_submissions',
|
||||
})
|
||||
})
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const observer = new window.IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting || entry.intersectionRatio < 0.35) {
|
||||
return
|
||||
}
|
||||
|
||||
const worldId = Number(entry.target.getAttribute('data-world-id') || 0)
|
||||
const item = items.find((candidate) => Number(candidate.id) === worldId)
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
|
||||
trackWorldSourceImpression({
|
||||
worldId: item.id,
|
||||
worldTitle: item.title || item.teaser_title || '',
|
||||
sourceSurface: analyticsContext.sourceSurface,
|
||||
sourceDetail: analyticsContext.sourceDetail ? `${analyticsContext.sourceDetail}:selector` : 'selector',
|
||||
sectionKey: 'community_submissions',
|
||||
})
|
||||
observer.unobserve(entry.target)
|
||||
})
|
||||
}, { threshold: [0.35] })
|
||||
|
||||
refs.forEach(([, node]) => {
|
||||
if (node) {
|
||||
observer.observe(node)
|
||||
}
|
||||
})
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [analyticsContext?.sourceDetail, analyticsContext?.sourceSurface, items])
|
||||
|
||||
return (
|
||||
<section className={`rounded-[28px] border border-white/10 bg-white/[0.03] p-5 ${className}`.trim()}>
|
||||
@@ -62,98 +111,144 @@ export default function WorldSubmissionSelector({
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">{items.filter((item) => item.selected).length} selected</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<UploadWorldHighlightCard
|
||||
world={highlightedWorld}
|
||||
sourceSurface={analyticsContext?.sourceSurface || ''}
|
||||
sourceDetail={analyticsContext?.sourceDetail ? `${analyticsContext.sourceDetail}:highlight` : 'highlight'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="mt-5 rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">{emptyMessage}</div>
|
||||
) : (
|
||||
<div className="mt-5 grid gap-4 xl:grid-cols-2">
|
||||
<div className="mt-5 grid gap-3">
|
||||
{items.map((item) => {
|
||||
const checked = Boolean(item.selected)
|
||||
const locked = Boolean(item.selection_locked)
|
||||
const combinedDateLabel = dateBadgeLabel(item)
|
||||
|
||||
return (
|
||||
<div key={item.id} className={`overflow-hidden rounded-[24px] border ${checked ? 'border-sky-300/25 bg-sky-400/[0.07]' : 'border-white/10 bg-black/20'}`}>
|
||||
<div
|
||||
key={item.id}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
itemRefs.current.set(item.id, node)
|
||||
} else {
|
||||
itemRefs.current.delete(item.id)
|
||||
}
|
||||
}}
|
||||
data-world-id={item.id}
|
||||
className={`overflow-hidden rounded-[24px] border transition-colors ${checked ? 'border-sky-300/25 bg-sky-400/[0.07]' : 'border-white/10 bg-black/20'}`}
|
||||
>
|
||||
{/* ── Compact row (always visible) ── */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !locked && onToggle?.(item.id)}
|
||||
onClick={() => {
|
||||
if (locked) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!checked && analyticsContext?.sourceSurface) {
|
||||
trackWorldAnalytics('world_submission_started', {
|
||||
world_id: item.id,
|
||||
source_surface: analyticsContext.sourceSurface,
|
||||
source_detail: analyticsContext.sourceDetail || '',
|
||||
section_key: 'community_submissions',
|
||||
entity_type: 'world',
|
||||
entity_id: item.id,
|
||||
entity_title: item.title || '',
|
||||
})
|
||||
}
|
||||
|
||||
onToggle?.(item.id)
|
||||
}}
|
||||
disabled={locked}
|
||||
className="w-full p-4 text-left disabled:cursor-not-allowed disabled:opacity-100"
|
||||
className="flex w-full items-center gap-4 p-4 text-left disabled:cursor-not-allowed"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-[auto_minmax(0,1fr)_auto] md:items-start">
|
||||
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-[20px] border border-white/10 bg-slate-950/80">
|
||||
{item.cover_url ? (
|
||||
<img src={item.cover_url} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_48%),linear-gradient(135deg,_rgba(15,23,42,0.96),_rgba(2,6,23,0.96))] text-slate-500">
|
||||
<i className="fa-solid fa-globe text-lg" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-white">{item.title}</h3>
|
||||
{item.status_label ? (
|
||||
<span className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] ${statusTone(item)}`}>
|
||||
{item.status_label}
|
||||
</span>
|
||||
) : null}
|
||||
{item.participation_mode_label ? (
|
||||
<span className={`rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] ${modeTone(item.participation_mode)}`}>
|
||||
{item.participation_mode_label}
|
||||
</span>
|
||||
) : null}
|
||||
{/* Thumbnail */}
|
||||
<div className="h-12 w-12 shrink-0 overflow-hidden rounded-2xl border border-white/10 bg-slate-950/80">
|
||||
{item.cover_url ? (
|
||||
<img src={item.cover_url} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_48%),linear-gradient(135deg,_rgba(15,23,42,0.96),_rgba(2,6,23,0.96))] text-slate-500">
|
||||
<i className="fa-solid fa-globe text-sm" />
|
||||
</div>
|
||||
|
||||
{item.tagline ? <p className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{item.tagline}</p> : null}
|
||||
</div>
|
||||
|
||||
<span className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-xs md:mt-0.5 ${checked ? 'border-sky-300/40 bg-sky-400/20 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-500'}`}>
|
||||
{checked ? '✓' : ''}
|
||||
</span>
|
||||
|
||||
{item.summary ? <p className="text-sm leading-6 text-slate-300 md:col-span-3">{item.summary}</p> : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs text-slate-300 md:col-span-3">
|
||||
{combinedDateLabel ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">{combinedDateLabel}</span> : null}
|
||||
{item.can_resubmit ? <span className="rounded-full border border-amber-300/25 bg-amber-400/10 px-3 py-1 text-amber-100">Can re-add</span> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title + badges */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{(Array.isArray(item.status_badges) ? item.status_badges : []).map((badge) => <WorldStatusBadge key={`${item.id}-${badge.label}`} badge={badge} />)}
|
||||
{item.participation_mode_label ? <span className={`rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] ${modeTone(item.participation_mode)}`}>{item.participation_mode_label}</span> : null}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">{item.teaser_title || item.title}</div>
|
||||
{item.tagline ? <div className="truncate text-[11px] uppercase tracking-[0.14em] text-slate-500">{item.tagline}</div> : null}
|
||||
</div>
|
||||
|
||||
{/* Checkbox */}
|
||||
<span className={`inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full border text-xs ${checked ? 'border-sky-300/40 bg-sky-400/20 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-500'}`}>
|
||||
{checked ? <i className="fa-solid fa-check text-[10px]" /> : null}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="border-t border-white/10 px-4 py-4">
|
||||
{item.submission_guidelines ? (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm leading-6 text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">World guidelines</div>
|
||||
<div className="mt-2">{item.submission_guidelines}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{/* ── Expanded details (only when checked) ── */}
|
||||
{checked ? (
|
||||
<div className="border-t border-white/10 px-4 pb-4 pt-4">
|
||||
{/* Full description */}
|
||||
{(item.teaser_summary || item.summary) ? (
|
||||
<p className="text-sm leading-6 text-slate-300">{item.teaser_summary || item.summary}</p>
|
||||
) : null}
|
||||
|
||||
{item.selection_locked_reason ? (
|
||||
<div className="mt-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-400">{item.selection_locked_reason}</div>
|
||||
) : null}
|
||||
{/* Date/window chips */}
|
||||
{(combinedDateLabel || item.promotion_window_label) ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs text-slate-300">
|
||||
{combinedDateLabel ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">{combinedDateLabel}</span> : null}
|
||||
{item.promotion_window_label ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">{item.promotion_window_label}</span> : null}
|
||||
{item.can_resubmit ? <span className="rounded-full border border-amber-300/25 bg-amber-400/10 px-3 py-1 text-amber-100">Can re-add</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{item.reviewer_note ? (
|
||||
<div className="mt-3 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm text-amber-50">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80">Moderator note</div>
|
||||
<div className="mt-2 leading-6">{item.reviewer_note}</div>
|
||||
</div>
|
||||
) : null}
|
||||
<WorldCampaignMeta world={item} className="mt-3" />
|
||||
|
||||
{checked && item.submission_note_enabled ? (
|
||||
<label className="mt-3 grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Creator note</span>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={item.note || ''}
|
||||
onChange={(event) => onNoteChange?.(item.id, event.target.value)}
|
||||
disabled={locked}
|
||||
placeholder="Optional note for world moderators: fit, context, challenge angle, or why this artwork belongs here."
|
||||
className="nova-scrollbar rounded-[20px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
{/* Guidelines */}
|
||||
{item.submission_guidelines ? (
|
||||
<div className="mt-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm leading-6 text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">World guidelines</div>
|
||||
<div className="mt-2">{item.submission_guidelines}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Locked reason */}
|
||||
{item.selection_locked_reason ? (
|
||||
<div className="mt-3 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-400">{item.selection_locked_reason}</div>
|
||||
) : null}
|
||||
|
||||
{/* Moderator note */}
|
||||
{item.reviewer_note ? (
|
||||
<div className="mt-3 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm text-amber-50">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80">Moderator note</div>
|
||||
<div className="mt-2 leading-6">{item.reviewer_note}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Creator note */}
|
||||
{item.submission_note_enabled ? (
|
||||
<label className="mt-3 grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Creator note <span className="normal-case tracking-normal text-slate-600">(optional)</span></span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={item.note || ''}
|
||||
onChange={(event) => onNoteChange?.(item.id, event.target.value)}
|
||||
disabled={locked}
|
||||
placeholder="Context for world moderators: fit, angle, or why this artwork belongs here."
|
||||
className="nova-scrollbar rounded-[20px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none placeholder:text-slate-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user