346 lines
11 KiB
TypeScript
346 lines
11 KiB
TypeScript
// apps/desktop/src/renderer/components/ads/RewardedQuotaModal.tsx
|
|
// Rewarded ad UI. It renders only a provider-returned creative and grants
|
|
// nothing until the main-process verification boundary confirms it.
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react'
|
|
import { Modal, Box, Typography, Button, LinearProgress } from '@mui/material'
|
|
import { PlayCircle, Sparkles, Clock, X, CheckCircle2 } from 'lucide-react'
|
|
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
|
|
import type { AdCreativePayload } from '@d3ro/core/types'
|
|
|
|
interface RewardedQuotaModalProps {
|
|
open: boolean
|
|
onClose: () => void
|
|
onRewardClaimed: (addedTokens: number) => void
|
|
}
|
|
|
|
export function RewardedQuotaModal({
|
|
open,
|
|
onClose,
|
|
onRewardClaimed,
|
|
}: RewardedQuotaModalProps): React.ReactElement {
|
|
const [creative, setCreative] = useState<AdCreativePayload | null>(null)
|
|
const [timeLeft, setTimeLeft] = useState(0)
|
|
const [completed, setCompleted] = useState(false)
|
|
const [loading, setLoading] = useState(false)
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
|
|
|
const fetchRewardedAuction = useCallback(async () => {
|
|
setLoading(true)
|
|
setErrorMessage(null)
|
|
setCreative(null)
|
|
try {
|
|
if (window.electronAPI?.ads?.requestAuction) {
|
|
const res = await window.electronAPI.ads.requestAuction({
|
|
placement: 'rewarded_video_quota',
|
|
format: 'rewarded_video',
|
|
floorEcpm: 4.0,
|
|
})
|
|
if (res.success && res.data?.winner) {
|
|
setCreative(res.data.winner)
|
|
setTimeLeft(res.data.winner.durationSeconds || 15)
|
|
// Record video impression
|
|
window.electronAPI.ads.recordImpression({
|
|
adId: res.data.winner.id,
|
|
format: 'rewarded_video',
|
|
network: res.data.winner.networkId,
|
|
networkName: res.data.winner.networkName,
|
|
earnedEcpm: res.data.winningBidEcpm,
|
|
})
|
|
return
|
|
}
|
|
}
|
|
setErrorMessage('A verified rewarded ad is not available right now.')
|
|
} catch {
|
|
setErrorMessage('The rewarded ad provider could not be reached.')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setCreative(null)
|
|
setTimeLeft(0)
|
|
setCompleted(false)
|
|
setErrorMessage(null)
|
|
return
|
|
}
|
|
|
|
void fetchRewardedAuction()
|
|
}, [open, fetchRewardedAuction])
|
|
|
|
useEffect(() => {
|
|
if (!open || creative === null || timeLeft <= 0 || completed) return undefined
|
|
const interval = setInterval(() => {
|
|
setTimeLeft((prev) => {
|
|
if (prev <= 1) {
|
|
clearInterval(interval)
|
|
setCompleted(true)
|
|
return 0
|
|
}
|
|
return prev - 1
|
|
})
|
|
}, 1000)
|
|
|
|
return () => clearInterval(interval)
|
|
}, [completed, creative, open, timeLeft])
|
|
|
|
const handleClaim = async () => {
|
|
if (creative === null || !completed) return
|
|
if (window.electronAPI?.ads?.claimReward) {
|
|
const res = await window.electronAPI.ads.claimReward({
|
|
adId: creative.id,
|
|
networkId: creative.networkId as string,
|
|
})
|
|
if (res.success && res.data?.success && res.data.tokensAdded > 0) {
|
|
onRewardClaimed(res.data.tokensAdded)
|
|
onClose()
|
|
return
|
|
}
|
|
}
|
|
setErrorMessage('Reward verification failed. No tokens were added.')
|
|
}
|
|
|
|
if (creative === null) {
|
|
return (
|
|
<Modal open={open} onClose={onClose}>
|
|
<Box
|
|
sx={{
|
|
position: 'absolute',
|
|
top: '50%',
|
|
left: '50%',
|
|
transform: 'translate(-50%, -50%)',
|
|
width: { xs: '90%', sm: 440 },
|
|
bgcolor: d3roPalette.bg.modal,
|
|
borderRadius: d3roRadius.modal,
|
|
border: `1px solid ${d3roPalette.glass.hairline}`,
|
|
boxShadow: d3roShadow.modal,
|
|
p: 3,
|
|
outline: 'none',
|
|
}}
|
|
>
|
|
<Typography sx={{ fontFamily: d3roFontSans, fontWeight: 500, color: d3roPalette.text.primary }}>
|
|
Rewarded ad unavailable
|
|
</Typography>
|
|
<Typography sx={{ mt: 1.25, fontFamily: d3roFontSans, fontSize: 13, color: d3roPalette.text.secondary }}>
|
|
{loading ? 'Checking verified ad providers…' : errorMessage ?? 'No verified ad is available.'}
|
|
</Typography>
|
|
<Button onClick={onClose} sx={{ mt: 2, textTransform: 'none' }}>Close</Button>
|
|
</Box>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
const duration = creative.durationSeconds || 15
|
|
const progressPercent = ((duration - timeLeft) / duration) * 100
|
|
|
|
return (
|
|
<Modal open={open} onClose={onClose}>
|
|
<Box
|
|
sx={{
|
|
position: 'absolute',
|
|
top: '50%',
|
|
left: '50%',
|
|
transform: 'translate(-50%, -50%)',
|
|
width: { xs: '90%', sm: 480 },
|
|
bgcolor: d3roPalette.bg.modal,
|
|
borderRadius: d3roRadius.modal,
|
|
border: `1px solid ${d3roPalette.glass.hairline}`,
|
|
boxShadow: d3roShadow.modal,
|
|
p: 3,
|
|
outline: 'none',
|
|
}}
|
|
>
|
|
{/* Header */}
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2.5 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
|
<Box
|
|
sx={{
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: '8px',
|
|
bgcolor: 'rgba(56, 189, 248, 0.15)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
<PlayCircle size={18} color={d3roPalette.accent.light} />
|
|
</Box>
|
|
<Typography sx={{ fontFamily: d3roFontSans, fontWeight: 500, fontSize: '15px', color: d3roPalette.text.primary }}>
|
|
Verified rewarded ad
|
|
</Typography>
|
|
</Box>
|
|
<Box onClick={onClose} sx={{ cursor: 'pointer', color: d3roPalette.text.dimLabel, '&:hover': { color: d3roPalette.text.primary } }}>
|
|
<X size={18} />
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Video Ad Screen with Authentic Unity / AppLovin Test Mode UI */}
|
|
<Box
|
|
sx={{
|
|
height: 220,
|
|
borderRadius: d3roRadius.inner,
|
|
bgcolor: '#000000',
|
|
border: `1px solid ${d3roPalette.glass.hairline}`,
|
|
position: 'relative',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
p: 3,
|
|
textAlign: 'center',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{/* Unity / AppLovin Official Test Watermark Ribbon */}
|
|
<Box
|
|
sx={{
|
|
position: 'absolute',
|
|
top: 10,
|
|
left: 10,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 1,
|
|
zIndex: 2,
|
|
}}
|
|
>
|
|
<Typography
|
|
sx={{
|
|
fontFamily: d3roFontMono,
|
|
fontSize: '9px',
|
|
fontWeight: 600,
|
|
color: '#fff',
|
|
bgcolor: '#2563eb',
|
|
px: 1,
|
|
py: 0.3,
|
|
borderRadius: '4px',
|
|
letterSpacing: '0.5px',
|
|
}}
|
|
>
|
|
{creative.networkName}
|
|
</Typography>
|
|
<Typography
|
|
sx={{
|
|
fontFamily: d3roFontMono,
|
|
fontSize: '9px',
|
|
color: 'rgba(255, 255, 255, 0.6)',
|
|
}}
|
|
>
|
|
Reward verification required
|
|
</Typography>
|
|
</Box>
|
|
|
|
<Box
|
|
sx={{
|
|
position: 'absolute',
|
|
top: 10,
|
|
right: 10,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 0.5,
|
|
color: '#fff',
|
|
bgcolor: 'rgba(0, 0, 0, 0.6)',
|
|
px: 1,
|
|
py: 0.3,
|
|
borderRadius: '4px',
|
|
fontFamily: d3roFontMono,
|
|
fontSize: '11px',
|
|
fontWeight: 500,
|
|
zIndex: 2,
|
|
}}
|
|
>
|
|
<Clock size={12} />
|
|
{completed ? 'Reward Ready' : `00:${timeLeft.toString().padStart(2, '0')}`}
|
|
</Box>
|
|
|
|
{/* Test Commercial Creative Content */}
|
|
<Box
|
|
sx={{
|
|
width: 56,
|
|
height: 56,
|
|
borderRadius: '14px',
|
|
bgcolor: 'rgba(37, 99, 235, 0.25)',
|
|
border: `1px solid rgba(59, 130, 246, 0.5)`,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
mb: 1.5,
|
|
boxShadow: '0 0 20px rgba(37, 99, 235, 0.4)',
|
|
}}
|
|
>
|
|
<Sparkles size={28} color="#60a5fa" />
|
|
</Box>
|
|
|
|
<Typography sx={{ fontFamily: d3roFontSans, fontWeight: 600, fontSize: '15px', color: '#ffffff' }}>
|
|
{creative.title}
|
|
</Typography>
|
|
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '11px', color: 'rgba(255, 255, 255, 0.75)', mt: 0.5, maxWidth: 360 }}>
|
|
{creative.description}
|
|
</Typography>
|
|
|
|
{/* Video Progress Bar at bottom */}
|
|
<Box sx={{ position: 'absolute', bottom: 0, left: 0, right: 0 }}>
|
|
<LinearProgress
|
|
variant="determinate"
|
|
value={progressPercent}
|
|
sx={{
|
|
height: 5,
|
|
bgcolor: 'rgba(255, 255, 255, 0.1)',
|
|
'& .MuiLinearProgress-bar': {
|
|
bgcolor: completed ? '#22c55e' : '#3b82f6',
|
|
},
|
|
}}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Footer Actions */}
|
|
<Box sx={{ mt: 3, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: d3roPalette.text.dimLabel }}>
|
|
Reward is added only after server verification.
|
|
</Typography>
|
|
|
|
{completed ? (
|
|
<Button
|
|
variant="contained"
|
|
onClick={handleClaim}
|
|
startIcon={<CheckCircle2 size={16} />}
|
|
sx={{
|
|
fontFamily: d3roFontSans,
|
|
fontWeight: 500,
|
|
fontSize: '12px',
|
|
textTransform: 'none',
|
|
bgcolor: d3roPalette.tag.greenText,
|
|
color: '#000',
|
|
px: 2.5,
|
|
py: 0.8,
|
|
borderRadius: '8px',
|
|
'&:hover': { bgcolor: '#4ade80' },
|
|
}}
|
|
>
|
|
Verify reward
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
disabled
|
|
sx={{
|
|
fontFamily: d3roFontSans,
|
|
fontSize: '12px',
|
|
textTransform: 'none',
|
|
color: 'rgba(255, 255, 255, 0.3)',
|
|
bgcolor: 'rgba(255, 255, 255, 0.05)',
|
|
px: 2,
|
|
py: 0.8,
|
|
borderRadius: '8px',
|
|
}}
|
|
>
|
|
Watch to Complete ({timeLeft}s)
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</Modal>
|
|
)
|
|
}
|