Models, pipelines, users, subscriptions, usage, audit log, ads, and releases still rendered placeholder or duplicated implementations from the earlier admin split. They now read the live back-office API, share one sidebar and console theme, and the license issuer dialog uses the rotated signing key.
280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
// apps/admin/src/app/(admin)/ads/page.tsx
|
|
// D3RO Voice Admin CRM — Ad Mediation & Monetization Console
|
|
|
|
import { Box, Typography } from '@mui/material'
|
|
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
|
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
|
|
import { requireManager } from '@/lib/admin-guard'
|
|
import { MEDIATION_ROSTER, fetchAdRewardStats, type AdRewardStats } from '@/lib/ad-monetization'
|
|
import { isSupabaseAdminConfigured } from '@/lib/supabase-admin'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
const FORMAT_LABEL: Record<string, string> = {
|
|
banner_dock: 'Bottom Dock',
|
|
rewarded_video: 'Rewarded Video',
|
|
export_sponsor: 'Export Sponsor',
|
|
audio_chime: 'Audio Chime'
|
|
}
|
|
|
|
function formatDateTime(value: string): string {
|
|
const parsed = new Date(value)
|
|
return Number.isNaN(parsed.getTime()) ? '—' : parsed.toISOString().replace('T', ' ').slice(0, 16)
|
|
}
|
|
|
|
function HeaderBar(): React.ReactElement {
|
|
return (
|
|
<Box
|
|
sx={{
|
|
...panelSx,
|
|
minHeight: 84,
|
|
px: { xs: 2.5, md: 4 },
|
|
py: 2,
|
|
display: 'flex',
|
|
flexWrap: 'wrap',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: 2
|
|
}}
|
|
>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
<Box
|
|
sx={{
|
|
width: 4,
|
|
height: 32,
|
|
borderRadius: '999px',
|
|
background: `linear-gradient(180deg, ${C.orange} 0%, ${C.accent} 60%, ${C.purple} 100%)`
|
|
}}
|
|
/>
|
|
<Box>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
|
<Typography
|
|
component="h1"
|
|
sx={{ fontFamily: FONT_SANS, fontSize: '20px', fontWeight: 500, color: C.bright, letterSpacing: '-0.02em', m: 0 }}
|
|
>
|
|
Ad Mediation & Monetization
|
|
</Typography>
|
|
<TactileBadge tone="warning" mono>
|
|
FAIL-CLOSED SANDBOX
|
|
</TactileBadge>
|
|
</Box>
|
|
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, letterSpacing: '0.04em', mt: 0.25 }}>
|
|
Mediation waterfall roster, rewarded token grants, network integration state
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
function RewardKpis({ stats }: { stats: AdRewardStats }): React.ReactElement {
|
|
const cards = [
|
|
{
|
|
title: `Rewarded Claims (${stats.windowDays}d)`,
|
|
value: stats.totalClaims.toLocaleString(),
|
|
subtext: 'Verified rewarded-video completions'
|
|
},
|
|
{
|
|
title: `Tokens Granted (${stats.windowDays}d)`,
|
|
value: stats.totalRewardTokens.toLocaleString(),
|
|
subtext: 'Cloud AI tokens issued via ad rewards'
|
|
},
|
|
{
|
|
title: `Unique Claimants (${stats.windowDays}d)`,
|
|
value: stats.uniqueClaimants.toLocaleString(),
|
|
subtext: 'Distinct accounts that redeemed rewards'
|
|
},
|
|
{
|
|
title: 'Live Ad Networks',
|
|
value: `0 / ${MEDIATION_ROSTER.length}`,
|
|
subtext: 'All adapters fail-closed until SDK contracts land'
|
|
}
|
|
]
|
|
return (
|
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', lg: 'repeat(4, 1fr)' }, gap: 2 }}>
|
|
{cards.map((card) => (
|
|
<Box key={card.title} sx={{ ...panelSx, p: 2.5 }}>
|
|
<Typography
|
|
sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mb: 1, textTransform: 'uppercase', letterSpacing: '0.08em' }}
|
|
>
|
|
{card.title}
|
|
</Typography>
|
|
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '22px', fontWeight: 500, color: C.bright, lineHeight: 1.25 }}>
|
|
{card.value}
|
|
</Typography>
|
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>{card.subtext}</Typography>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
function MediationRosterPanel(): React.ReactElement {
|
|
return (
|
|
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1.5, mb: 1 }}>
|
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 600, color: C.bright }}>
|
|
Mediation Waterfall Roster
|
|
</Typography>
|
|
<Box sx={statusBadgeSx('orange')}>NO LIVE BIDS</Box>
|
|
</Box>
|
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim, mb: 2 }}>
|
|
데스크톱 미디에이션 엔진에 등록된 어댑터 구성입니다. 공식 SDK/인증 계약이 연결되기 전까지 모든 네트워크는
|
|
입찰 없이 fail-closed로 동작하며, 데모 크리에이티브와 가짜 수익 수치는 표시하지 않습니다.
|
|
</Typography>
|
|
<Box sx={{ overflowX: 'auto' }}>
|
|
<Box component="table" sx={tableSx}>
|
|
<Box component="thead">
|
|
<Box component="tr">
|
|
<Box component="th">Network</Box>
|
|
<Box component="th">Adapter ID</Box>
|
|
<Box component="th">Formats</Box>
|
|
<Box component="th">Floor eCPM</Box>
|
|
<Box component="th">Integration</Box>
|
|
</Box>
|
|
</Box>
|
|
<Box component="tbody">
|
|
{MEDIATION_ROSTER.map((network) => (
|
|
<Box component="tr" key={network.networkId}>
|
|
<Box component="td" sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.bright }}>
|
|
{network.name}
|
|
</Box>
|
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight }}>
|
|
{network.networkId}
|
|
</Box>
|
|
<Box component="td" sx={{ fontFamily: FONT_SANS, fontSize: '12px' }}>
|
|
{network.formats.map((format) => FORMAT_LABEL[format] ?? format).join(' · ')}
|
|
</Box>
|
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>
|
|
${network.floorEcpm.toFixed(2)}
|
|
</Box>
|
|
<Box component="td">
|
|
<Box sx={statusBadgeSx('orange')}>NOT INTEGRATED</Box>
|
|
</Box>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
</DoubleBezelCard>
|
|
)
|
|
}
|
|
|
|
function RewardBreakdownPanel({ stats }: { stats: AdRewardStats }): React.ReactElement {
|
|
return (
|
|
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1fr 1.4fr' }, gap: 3 }}>
|
|
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 600, color: C.bright, mb: 2 }}>
|
|
Rewarded Grants by Network
|
|
</Typography>
|
|
{stats.byNetwork.length === 0 ? (
|
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}>
|
|
최근 {stats.windowDays}일간 검증된 리워드 클레임이 없습니다.
|
|
</Typography>
|
|
) : (
|
|
<Box sx={{ overflowX: 'auto' }}>
|
|
<Box component="table" sx={tableSx}>
|
|
<Box component="thead">
|
|
<Box component="tr">
|
|
<Box component="th">Network</Box>
|
|
<Box component="th">Claims</Box>
|
|
<Box component="th">Tokens</Box>
|
|
</Box>
|
|
</Box>
|
|
<Box component="tbody">
|
|
{stats.byNetwork.map((summary) => (
|
|
<Box component="tr" key={summary.network}>
|
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
|
|
{summary.network}
|
|
</Box>
|
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>
|
|
{summary.claims.toLocaleString()}
|
|
</Box>
|
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.green400 }}>
|
|
+{summary.rewardTokens.toLocaleString()}
|
|
</Box>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
</DoubleBezelCard>
|
|
|
|
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 600, color: C.bright, mb: 2 }}>
|
|
Recent Verified Claims
|
|
</Typography>
|
|
{stats.recentClaims.length === 0 ? (
|
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}>표시할 클레임이 없습니다.</Typography>
|
|
) : (
|
|
<Box sx={{ overflowX: 'auto' }}>
|
|
<Box component="table" sx={tableSx}>
|
|
<Box component="thead">
|
|
<Box component="tr">
|
|
<Box component="th">Verified At (UTC)</Box>
|
|
<Box component="th">Network</Box>
|
|
<Box component="th">Placement</Box>
|
|
<Box component="th">Tokens</Box>
|
|
</Box>
|
|
</Box>
|
|
<Box component="tbody">
|
|
{stats.recentClaims.map((claim) => (
|
|
<Box component="tr" key={claim.id}>
|
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>
|
|
{formatDateTime(claim.verifiedAt)}
|
|
</Box>
|
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
|
|
{claim.network}
|
|
</Box>
|
|
<Box component="td" sx={{ fontFamily: FONT_SANS, fontSize: '12px' }}>{claim.placement}</Box>
|
|
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.green400 }}>
|
|
+{claim.rewardTokens.toLocaleString()}
|
|
</Box>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
</DoubleBezelCard>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
function RewardsUnavailablePanel({ reason }: { reason: string }): React.ReactElement {
|
|
return (
|
|
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '14px', fontWeight: 600, color: C.orange400, mb: 1 }}>
|
|
Rewarded 토큰 지급 통계를 불러올 수 없습니다.
|
|
</Typography>
|
|
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim }}>{reason}</Typography>
|
|
</DoubleBezelCard>
|
|
)
|
|
}
|
|
|
|
export default async function AdminAdsPage(): Promise<React.ReactElement> {
|
|
await requireManager()
|
|
|
|
let stats: AdRewardStats | null = null
|
|
let statsError: string | null = null
|
|
if (!isSupabaseAdminConfigured()) {
|
|
statsError =
|
|
'SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 환경변수가 설정되지 않아 ad_reward_claims 데이터에 연결할 수 없습니다. 환경변수를 설정하면 실데이터가 표시됩니다.'
|
|
} else {
|
|
try {
|
|
stats = await fetchAdRewardStats()
|
|
} catch (error) {
|
|
statsError = error instanceof Error ? error.message : 'Unknown ad reward stats error'
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
<HeaderBar />
|
|
{stats && <RewardKpis stats={stats} />}
|
|
<MediationRosterPanel />
|
|
{stats ? <RewardBreakdownPanel stats={stats} /> : <RewardsUnavailablePanel reason={statsError ?? ''} />}
|
|
</Box>
|
|
)
|
|
}
|