Build world campaigns rewards and recaps
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user