feat(desktop): fill sponsor slots from direct house campaigns
When mediation had no programmatic fill, the banner and rewarded surfaces collapsed to empty space. Direct house sponsors now serve their own copy and click-through, with the same settlement accounting used by the mediated network, and the mediation engine tests cover the added path.
This commit is contained in:
parent
911c9f0229
commit
c8d802d78f
5 changed files with 363 additions and 61 deletions
|
|
@ -102,6 +102,12 @@ export class AdMediationEngine {
|
||||||
networks: newConfig.networks?.map((network) => ({ ...network }))
|
networks: newConfig.networks?.map((network) => ({ ...network }))
|
||||||
?? this.config.networks.map((network) => ({ ...network })),
|
?? this.config.networks.map((network) => ({ ...network })),
|
||||||
}
|
}
|
||||||
|
for (const network of this.config.networks) {
|
||||||
|
const adapter = this.adapters.get(network.id)
|
||||||
|
if (adapter) {
|
||||||
|
void adapter.init(network as unknown as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
}
|
||||||
return this.getConfig()
|
return this.getConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,219 @@
|
||||||
import { UnavailableAdAdapter } from './UnavailableAdAdapter'
|
// apps/desktop/src/main/services/ads/DirectHouseSponsorAdapter.ts
|
||||||
|
// Real, configurable direct/house sponsor adapter.
|
||||||
|
//
|
||||||
|
// Unlike the placeholder networks, this adapter performs an actual mediated
|
||||||
|
// bid against an operator-configured HTTPS decision endpoint and reports
|
||||||
|
// impressions, clicks, and reward completions back to it. It stays fail-closed:
|
||||||
|
// with no endpoint configured it never fabricates a bid or creative, and it
|
||||||
|
// validates every returned creative before exposing it.
|
||||||
|
|
||||||
export class DirectHouseSponsorAdapter extends UnavailableAdAdapter {
|
import type {
|
||||||
constructor() {
|
AdNetworkId,
|
||||||
super(
|
AdFormat,
|
||||||
'direct_sponsor',
|
AdCreativePayload,
|
||||||
'Direct House Sponsor',
|
AdMediationAuctionRequest,
|
||||||
['banner_dock', 'rewarded_video', 'export_sponsor'],
|
} from '@d3ro/core/types'
|
||||||
12,
|
import type { AdBidResponse, IAdNetworkAdapter } from './BaseAdAdapter'
|
||||||
)
|
|
||||||
|
interface DirectSponsorConfig {
|
||||||
|
endpointUrl?: string
|
||||||
|
apiSecret?: string
|
||||||
|
adUnitId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const REQUEST_TIMEOUT_MS = 1500
|
||||||
|
const MAX_BID_RESPONSE_BYTES = 64 * 1024
|
||||||
|
|
||||||
|
function trimTrailingSlash(value: string): string {
|
||||||
|
return value.replace(/\/+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHttpsUrl(value: unknown): value is string {
|
||||||
|
if (typeof value !== 'string') return false
|
||||||
|
try {
|
||||||
|
return new URL(value).protocol === 'https:'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHttpUrl(value: unknown): value is string {
|
||||||
|
if (typeof value !== 'string') return false
|
||||||
|
try {
|
||||||
|
const protocol = new URL(value).protocol
|
||||||
|
return protocol === 'https:' || protocol === 'http:'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DirectHouseSponsorAdapter implements IAdNetworkAdapter {
|
||||||
|
readonly networkId: AdNetworkId | string = 'direct_sponsor'
|
||||||
|
readonly networkName = 'Direct House Sponsor'
|
||||||
|
readonly supportedFormats: AdFormat[] = ['banner_dock', 'rewarded_video', 'export_sponsor']
|
||||||
|
readonly defaultFloorEcpm = 12
|
||||||
|
|
||||||
|
private config: DirectSponsorConfig = {}
|
||||||
|
|
||||||
|
async init(config?: Record<string, unknown>): Promise<void> {
|
||||||
|
this.config = {
|
||||||
|
endpointUrl:
|
||||||
|
typeof config?.endpointUrl === 'string' && isHttpUrl(config.endpointUrl)
|
||||||
|
? config.endpointUrl
|
||||||
|
: undefined,
|
||||||
|
apiSecret: typeof config?.apiSecret === 'string' ? config.apiSecret : undefined,
|
||||||
|
adUnitId: typeof config?.adUnitId === 'string' ? config.adUnitId : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async post(path: string, payload: Record<string, unknown>): Promise<Response> {
|
||||||
|
const endpoint = this.config.endpointUrl
|
||||||
|
if (!endpoint) throw new Error('adapter_not_configured')
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||||
|
if (this.config.apiSecret) headers.Authorization = `Bearer ${this.config.apiSecret}`
|
||||||
|
try {
|
||||||
|
return await fetch(`${trimTrailingSlash(endpoint)}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestBid(request: AdMediationAuctionRequest): Promise<AdBidResponse> {
|
||||||
|
const start = Date.now()
|
||||||
|
if (!this.config.endpointUrl) {
|
||||||
|
return { hasBid: false, bidEcpm: 0, latencyMs: 0, error: 'adapter_not_configured' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await this.post('/bid', {
|
||||||
|
networkId: this.networkId,
|
||||||
|
placement: request.placement,
|
||||||
|
format: request.format,
|
||||||
|
floorEcpm: request.floorEcpm ?? this.defaultFloorEcpm,
|
||||||
|
adUnitId: this.config.adUnitId ?? null,
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
hasBid: false,
|
||||||
|
bidEcpm: 0,
|
||||||
|
latencyMs: Date.now() - start,
|
||||||
|
error: `http_${response.status}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const text = await response.text()
|
||||||
|
if (text.length > MAX_BID_RESPONSE_BYTES) {
|
||||||
|
return {
|
||||||
|
hasBid: false,
|
||||||
|
bidEcpm: 0,
|
||||||
|
latencyMs: Date.now() - start,
|
||||||
|
error: 'response_too_large',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const payload: unknown = JSON.parse(text)
|
||||||
|
const creative = this.parseCreative(payload, request)
|
||||||
|
if (!creative) {
|
||||||
|
return { hasBid: false, bidEcpm: 0, latencyMs: Date.now() - start, error: 'invalid_creative' }
|
||||||
|
}
|
||||||
|
return { hasBid: true, bidEcpm: creative.bidEcpm, creative, latencyMs: Date.now() - start }
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'bid_failed'
|
||||||
|
return { hasBid: false, bidEcpm: 0, latencyMs: Date.now() - start, error: message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reportImpression(adId: string): Promise<void> {
|
||||||
|
if (!this.config.endpointUrl) return
|
||||||
|
try {
|
||||||
|
await this.post('/impression', { adId })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reportClick(adId: string): Promise<void> {
|
||||||
|
if (!this.config.endpointUrl) return
|
||||||
|
try {
|
||||||
|
await this.post('/click', { adId })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reportRewardCompletion(adId: string): Promise<{ success: boolean; tokenReward: number }> {
|
||||||
|
if (!this.config.endpointUrl) return { success: false, tokenReward: 0 }
|
||||||
|
try {
|
||||||
|
const response = await this.post('/reward', { adId })
|
||||||
|
if (!response.ok) return { success: false, tokenReward: 0 }
|
||||||
|
const payload = (await response.json()) as { success?: unknown; tokenReward?: unknown }
|
||||||
|
const tokenReward =
|
||||||
|
typeof payload.tokenReward === 'number' && Number.isFinite(payload.tokenReward)
|
||||||
|
? Math.max(0, Math.floor(payload.tokenReward))
|
||||||
|
: 0
|
||||||
|
const success = payload.success === true && tokenReward > 0
|
||||||
|
return { success, tokenReward: success ? tokenReward : 0 }
|
||||||
|
} catch {
|
||||||
|
return { success: false, tokenReward: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseCreative(
|
||||||
|
payload: unknown,
|
||||||
|
request: AdMediationAuctionRequest,
|
||||||
|
): AdCreativePayload | null {
|
||||||
|
if (typeof payload !== 'object' || payload === null) return null
|
||||||
|
const record = payload as Record<string, unknown>
|
||||||
|
const source = (
|
||||||
|
typeof record.creative === 'object' && record.creative !== null
|
||||||
|
? record.creative
|
||||||
|
: record
|
||||||
|
) as Record<string, unknown>
|
||||||
|
|
||||||
|
const bidEcpm =
|
||||||
|
typeof record.bidEcpm === 'number'
|
||||||
|
? record.bidEcpm
|
||||||
|
: typeof source.bidEcpm === 'number'
|
||||||
|
? source.bidEcpm
|
||||||
|
: Number.NaN
|
||||||
|
const id = typeof source.id === 'string' ? source.id : ''
|
||||||
|
const title = typeof source.title === 'string' ? source.title : ''
|
||||||
|
const advertiserName = typeof source.advertiserName === 'string' ? source.advertiserName : ''
|
||||||
|
const clickUrl = source.clickUrl
|
||||||
|
|
||||||
|
if (
|
||||||
|
!id.trim() ||
|
||||||
|
!title.trim() ||
|
||||||
|
!advertiserName.trim() ||
|
||||||
|
!Number.isFinite(bidEcpm) ||
|
||||||
|
bidEcpm <= 0 ||
|
||||||
|
!isHttpsUrl(clickUrl)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
networkId: this.networkId,
|
||||||
|
networkName: this.networkName,
|
||||||
|
title,
|
||||||
|
description: typeof source.description === 'string' ? source.description : '',
|
||||||
|
ctaText: typeof source.ctaText === 'string' ? source.ctaText : 'Learn more',
|
||||||
|
iconUrl: typeof source.iconUrl === 'string' ? source.iconUrl : undefined,
|
||||||
|
bannerUrl: typeof source.bannerUrl === 'string' ? source.bannerUrl : undefined,
|
||||||
|
videoUrl: typeof source.videoUrl === 'string' ? source.videoUrl : undefined,
|
||||||
|
clickUrl,
|
||||||
|
sponsorTag: typeof source.sponsorTag === 'string' ? source.sponsorTag : 'Sponsored',
|
||||||
|
advertiserName,
|
||||||
|
bidEcpm,
|
||||||
|
format: request.format,
|
||||||
|
rewardTokens: typeof source.rewardTokens === 'number' ? source.rewardTokens : undefined,
|
||||||
|
durationSeconds:
|
||||||
|
typeof source.durationSeconds === 'number' ? source.durationSeconds : undefined,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
|
||||||
px: 2,
|
px: 2,
|
||||||
py: 1.25,
|
py: 1.25,
|
||||||
borderRadius: d3roRadius.inner,
|
borderRadius: d3roRadius.inner,
|
||||||
bgcolor: 'rgba(17, 26, 48, 0.85)',
|
bgcolor: 'var(--d3-bg-card-soft)',
|
||||||
backdropFilter: 'blur(16px)',
|
backdropFilter: 'blur(16px)',
|
||||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||||
boxShadow: d3roShadow.card,
|
boxShadow: d3roShadow.card,
|
||||||
|
|
@ -91,19 +91,19 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
bgcolor:
|
bgcolor:
|
||||||
creative.networkId === 'google_ad_manager'
|
creative.networkId === 'google_ad_manager'
|
||||||
? 'rgba(234, 179, 8, 0.15)'
|
? 'var(--d3-status-warning)'
|
||||||
: creative.networkId === 'ethical_ads'
|
: creative.networkId === 'ethical_ads'
|
||||||
? 'rgba(34, 197, 94, 0.15)'
|
? 'var(--d3-status-success)'
|
||||||
: creative.networkId === 'carbon_ads'
|
: creative.networkId === 'carbon_ads'
|
||||||
? 'rgba(249, 115, 22, 0.15)'
|
? 'var(--d3-status-warning)'
|
||||||
: 'rgba(59, 130, 246, 0.15)',
|
: 'var(--d3-accent-glow)',
|
||||||
border: `1px solid ${
|
border: `1px solid ${
|
||||||
creative.networkId === 'google_ad_manager'
|
creative.networkId === 'google_ad_manager'
|
||||||
? 'rgba(234, 179, 8, 0.4)'
|
? 'var(--d3-status-warning)'
|
||||||
: creative.networkId === 'ethical_ads'
|
: creative.networkId === 'ethical_ads'
|
||||||
? 'rgba(34, 197, 94, 0.4)'
|
? 'var(--d3-status-success)'
|
||||||
: creative.networkId === 'carbon_ads'
|
: creative.networkId === 'carbon_ads'
|
||||||
? 'rgba(249, 115, 22, 0.4)'
|
? 'var(--d3-status-warning)'
|
||||||
: d3roPalette.accent.dim
|
: d3roPalette.accent.dim
|
||||||
}`,
|
}`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
@ -115,11 +115,11 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
|
||||||
fontFamily: d3roFontMono,
|
fontFamily: d3roFontMono,
|
||||||
color:
|
color:
|
||||||
creative.networkId === 'google_ad_manager'
|
creative.networkId === 'google_ad_manager'
|
||||||
? '#facc15'
|
? 'var(--d3-status-warning)'
|
||||||
: creative.networkId === 'ethical_ads'
|
: creative.networkId === 'ethical_ads'
|
||||||
? '#4ade80'
|
? 'var(--d3-status-success)'
|
||||||
: creative.networkId === 'carbon_ads'
|
: creative.networkId === 'carbon_ads'
|
||||||
? '#fb923c'
|
? 'var(--d3-status-warning)'
|
||||||
: d3roPalette.accent.light,
|
: d3roPalette.accent.light,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -147,8 +147,8 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
|
||||||
fontFamily: d3roFontMono,
|
fontFamily: d3roFontMono,
|
||||||
fontSize: '9px',
|
fontSize: '9px',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: '#000',
|
color: 'var(--d3-bg-app)',
|
||||||
bgcolor: '#facc15',
|
bgcolor: 'var(--d3-status-warning)',
|
||||||
px: 0.6,
|
px: 0.6,
|
||||||
py: 0.1,
|
py: 0.1,
|
||||||
borderRadius: '3px',
|
borderRadius: '3px',
|
||||||
|
|
@ -178,22 +178,22 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color:
|
color:
|
||||||
creative.networkId === 'ethical_ads'
|
creative.networkId === 'ethical_ads'
|
||||||
? '#4ade80'
|
? 'var(--d3-status-success)'
|
||||||
: creative.networkId === 'carbon_ads'
|
: creative.networkId === 'carbon_ads'
|
||||||
? '#fb923c'
|
? 'var(--d3-status-warning)'
|
||||||
: d3roPalette.accent.light,
|
: d3roPalette.accent.light,
|
||||||
bgcolor:
|
bgcolor:
|
||||||
creative.networkId === 'ethical_ads'
|
creative.networkId === 'ethical_ads'
|
||||||
? 'rgba(34, 197, 94, 0.12)'
|
? 'var(--d3-status-success)'
|
||||||
: creative.networkId === 'carbon_ads'
|
: creative.networkId === 'carbon_ads'
|
||||||
? 'rgba(249, 115, 22, 0.12)'
|
? 'var(--d3-status-warning)'
|
||||||
: 'rgba(59, 130, 246, 0.15)',
|
: 'var(--d3-accent-glow)',
|
||||||
border: `1px solid ${
|
border: `1px solid ${
|
||||||
creative.networkId === 'ethical_ads'
|
creative.networkId === 'ethical_ads'
|
||||||
? 'rgba(34, 197, 94, 0.3)'
|
? 'var(--d3-status-success)'
|
||||||
: creative.networkId === 'carbon_ads'
|
: creative.networkId === 'carbon_ads'
|
||||||
? 'rgba(249, 115, 22, 0.3)'
|
? 'var(--d3-status-warning)'
|
||||||
: 'rgba(59, 130, 246, 0.3)'
|
: 'var(--d3-accent-glow)'
|
||||||
}`,
|
}`,
|
||||||
px: 0.8,
|
px: 0.8,
|
||||||
py: 0.2,
|
py: 0.2,
|
||||||
|
|
@ -232,11 +232,11 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
textTransform: 'none',
|
textTransform: 'none',
|
||||||
color: d3roPalette.accent.light,
|
color: d3roPalette.accent.light,
|
||||||
bgcolor: 'rgba(59, 130, 246, 0.12)',
|
bgcolor: 'var(--d3-accent-glow)',
|
||||||
border: `1px solid ${d3roPalette.accent.dim}`,
|
border: `1px solid ${d3roPalette.accent.dim}`,
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
bgcolor: 'rgba(59, 130, 246, 0.22)',
|
bgcolor: 'var(--d3-accent-glow)',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -252,7 +252,7 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
|
||||||
color: d3roPalette.text.dimLabel,
|
color: d3roPalette.text.dimLabel,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
textDecoration: 'underline',
|
textDecoration: 'underline',
|
||||||
'&:hover': { color: d3roPalette.tag.greenText },
|
'&:hover': { color: d3roPalette.tag.green },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Remove ads with Pro
|
Remove ads with Pro
|
||||||
|
|
|
||||||
|
|
@ -112,10 +112,10 @@ export function RewardedQuotaModal({
|
||||||
left: '50%',
|
left: '50%',
|
||||||
transform: 'translate(-50%, -50%)',
|
transform: 'translate(-50%, -50%)',
|
||||||
width: { xs: '90%', sm: 440 },
|
width: { xs: '90%', sm: 440 },
|
||||||
bgcolor: d3roPalette.bg.modal,
|
bgcolor: d3roPalette.bg.card,
|
||||||
borderRadius: d3roRadius.modal,
|
borderRadius: d3roRadius.outer,
|
||||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||||
boxShadow: d3roShadow.modal,
|
boxShadow: d3roShadow.dialog,
|
||||||
p: 3,
|
p: 3,
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
}}
|
}}
|
||||||
|
|
@ -144,10 +144,10 @@ export function RewardedQuotaModal({
|
||||||
left: '50%',
|
left: '50%',
|
||||||
transform: 'translate(-50%, -50%)',
|
transform: 'translate(-50%, -50%)',
|
||||||
width: { xs: '90%', sm: 480 },
|
width: { xs: '90%', sm: 480 },
|
||||||
bgcolor: d3roPalette.bg.modal,
|
bgcolor: d3roPalette.bg.card,
|
||||||
borderRadius: d3roRadius.modal,
|
borderRadius: d3roRadius.outer,
|
||||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||||
boxShadow: d3roShadow.modal,
|
boxShadow: d3roShadow.dialog,
|
||||||
p: 3,
|
p: 3,
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
}}
|
}}
|
||||||
|
|
@ -160,7 +160,7 @@ export function RewardedQuotaModal({
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
bgcolor: 'rgba(56, 189, 248, 0.15)',
|
bgcolor: 'var(--d3-tag-cyan)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|
@ -182,7 +182,7 @@ export function RewardedQuotaModal({
|
||||||
sx={{
|
sx={{
|
||||||
height: 220,
|
height: 220,
|
||||||
borderRadius: d3roRadius.inner,
|
borderRadius: d3roRadius.inner,
|
||||||
bgcolor: '#000000',
|
bgcolor: 'var(--d3-bg-inset)',
|
||||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
@ -211,8 +211,8 @@ export function RewardedQuotaModal({
|
||||||
fontFamily: d3roFontMono,
|
fontFamily: d3roFontMono,
|
||||||
fontSize: '9px',
|
fontSize: '9px',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: '#fff',
|
color: 'var(--d3-text-inverse)',
|
||||||
bgcolor: '#2563eb',
|
bgcolor: 'var(--d3-accent-dark)',
|
||||||
px: 1,
|
px: 1,
|
||||||
py: 0.3,
|
py: 0.3,
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
|
|
@ -225,7 +225,7 @@ export function RewardedQuotaModal({
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: d3roFontMono,
|
fontFamily: d3roFontMono,
|
||||||
fontSize: '9px',
|
fontSize: '9px',
|
||||||
color: 'rgba(255, 255, 255, 0.6)',
|
color: 'var(--d3-overlay-strong)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Reward verification required
|
Reward verification required
|
||||||
|
|
@ -240,8 +240,8 @@ export function RewardedQuotaModal({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 0.5,
|
gap: 0.5,
|
||||||
color: '#fff',
|
color: 'var(--d3-text-inverse)',
|
||||||
bgcolor: 'rgba(0, 0, 0, 0.6)',
|
bgcolor: 'var(--d3-scrim)',
|
||||||
px: 1,
|
px: 1,
|
||||||
py: 0.3,
|
py: 0.3,
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
|
|
@ -261,22 +261,22 @@ export function RewardedQuotaModal({
|
||||||
width: 56,
|
width: 56,
|
||||||
height: 56,
|
height: 56,
|
||||||
borderRadius: '14px',
|
borderRadius: '14px',
|
||||||
bgcolor: 'rgba(37, 99, 235, 0.25)',
|
bgcolor: 'var(--d3-accent-glow)',
|
||||||
border: `1px solid rgba(59, 130, 246, 0.5)`,
|
border: `1px solid var(--d3-accent-glow)`,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
mb: 1.5,
|
mb: 1.5,
|
||||||
boxShadow: '0 0 20px rgba(37, 99, 235, 0.4)',
|
boxShadow: '0 0 20px var(--d3-accent-glow)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Sparkles size={28} color="#60a5fa" />
|
<Sparkles size={28} color="var(--d3-accent-light)" />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography sx={{ fontFamily: d3roFontSans, fontWeight: 600, fontSize: '15px', color: '#ffffff' }}>
|
<Typography sx={{ fontFamily: d3roFontSans, fontWeight: 600, fontSize: '15px', color: 'var(--d3-text-inverse)' }}>
|
||||||
{creative.title}
|
{creative.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '11px', color: 'rgba(255, 255, 255, 0.75)', mt: 0.5, maxWidth: 360 }}>
|
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '11px', color: 'var(--d3-overlay-strong)', mt: 0.5, maxWidth: 360 }}>
|
||||||
{creative.description}
|
{creative.description}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
|
|
@ -287,9 +287,9 @@ export function RewardedQuotaModal({
|
||||||
value={progressPercent}
|
value={progressPercent}
|
||||||
sx={{
|
sx={{
|
||||||
height: 5,
|
height: 5,
|
||||||
bgcolor: 'rgba(255, 255, 255, 0.1)',
|
bgcolor: 'var(--d3-overlay-strong)',
|
||||||
'& .MuiLinearProgress-bar': {
|
'& .MuiLinearProgress-bar': {
|
||||||
bgcolor: completed ? '#22c55e' : '#3b82f6',
|
bgcolor: completed ? 'var(--d3-status-success)' : 'var(--d3-accent-main)',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -312,12 +312,12 @@ export function RewardedQuotaModal({
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
textTransform: 'none',
|
textTransform: 'none',
|
||||||
bgcolor: d3roPalette.tag.greenText,
|
bgcolor: d3roPalette.tag.green,
|
||||||
color: '#000',
|
color: 'var(--d3-bg-app)',
|
||||||
px: 2.5,
|
px: 2.5,
|
||||||
py: 0.8,
|
py: 0.8,
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
'&:hover': { bgcolor: '#4ade80' },
|
'&:hover': { bgcolor: 'var(--d3-status-success)' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Verify reward
|
Verify reward
|
||||||
|
|
@ -329,8 +329,8 @@ export function RewardedQuotaModal({
|
||||||
fontFamily: d3roFontSans,
|
fontFamily: d3roFontSans,
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
textTransform: 'none',
|
textTransform: 'none',
|
||||||
color: 'rgba(255, 255, 255, 0.3)',
|
color: 'var(--d3-overlay-strong)',
|
||||||
bgcolor: 'rgba(255, 255, 255, 0.05)',
|
bgcolor: 'var(--d3-overlay-strong)',
|
||||||
px: 2,
|
px: 2,
|
||||||
py: 0.8,
|
py: 0.8,
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { beforeEach, describe, expect, it } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import type { AdMediationConfig } from '@d3ro/core/types'
|
import type { AdMediationConfig } from '@d3ro/core/types'
|
||||||
import { getAdMediationEngine } from '../../src/main/services/ads/AdMediationEngine'
|
import { getAdMediationEngine } from '../../src/main/services/ads/AdMediationEngine'
|
||||||
import { getAdSettlementService } from '../../src/main/services/ads/AdSettlementService'
|
import { getAdSettlementService } from '../../src/main/services/ads/AdSettlementService'
|
||||||
|
|
@ -23,7 +23,6 @@ const adapterCases = [
|
||||||
['InMobi', new InMobiAdapter()],
|
['InMobi', new InMobiAdapter()],
|
||||||
['PubMatic', new PubMaticAdapter()],
|
['PubMatic', new PubMaticAdapter()],
|
||||||
['Mintegral', new MintegralAdapter()],
|
['Mintegral', new MintegralAdapter()],
|
||||||
['Direct sponsor', new DirectHouseSponsorAdapter()],
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
describe('desktop ad provider boundaries', () => {
|
describe('desktop ad provider boundaries', () => {
|
||||||
|
|
@ -49,6 +48,96 @@ describe('desktop ad provider boundaries', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('DirectHouseSponsorAdapter live decision endpoint', () => {
|
||||||
|
it('is fail-closed until an endpoint is configured', async () => {
|
||||||
|
const adapter = new DirectHouseSponsorAdapter()
|
||||||
|
await adapter.init()
|
||||||
|
const bid = await adapter.requestBid({ placement: 'bottom_dock_banner', format: 'banner_dock' })
|
||||||
|
|
||||||
|
expect(bid).toEqual({
|
||||||
|
hasBid: false,
|
||||||
|
bidEcpm: 0,
|
||||||
|
latencyMs: 0,
|
||||||
|
error: 'adapter_not_configured',
|
||||||
|
})
|
||||||
|
await expect(adapter.reportRewardCompletion('forged-ad-id')).resolves.toEqual({
|
||||||
|
success: false,
|
||||||
|
tokenReward: 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a validated live bid from a configured endpoint', async () => {
|
||||||
|
const adapter = new DirectHouseSponsorAdapter()
|
||||||
|
await adapter.init({ endpointUrl: 'https://ads.example.com', apiSecret: 'secret' })
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
bidEcpm: 9.5,
|
||||||
|
creative: {
|
||||||
|
id: 'creative-1',
|
||||||
|
title: 'Sponsor',
|
||||||
|
advertiserName: 'Example Inc',
|
||||||
|
clickUrl: 'https://example.com/offer',
|
||||||
|
format: 'banner_dock',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
try {
|
||||||
|
const bid = await adapter.requestBid({
|
||||||
|
placement: 'bottom_dock_banner',
|
||||||
|
format: 'banner_dock',
|
||||||
|
floorEcpm: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(bid.hasBid).toBe(true)
|
||||||
|
expect(bid.bidEcpm).toBe(9.5)
|
||||||
|
expect(bid.creative?.networkId).toBe('direct_sponsor')
|
||||||
|
expect(bid.creative?.clickUrl).toBe('https://example.com/offer')
|
||||||
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||||
|
expect(String(url)).toBe('https://ads.example.com/bid')
|
||||||
|
expect(init.headers).toMatchObject({ Authorization: 'Bearer secret' })
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a creative whose click target is not https', async () => {
|
||||||
|
const adapter = new DirectHouseSponsorAdapter()
|
||||||
|
await adapter.init({ endpointUrl: 'https://ads.example.com' })
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
bidEcpm: 9.5,
|
||||||
|
creative: {
|
||||||
|
id: 'c',
|
||||||
|
title: 't',
|
||||||
|
advertiserName: 'a',
|
||||||
|
clickUrl: 'http://insecure.example.com',
|
||||||
|
format: 'banner_dock',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
const bid = await adapter.requestBid({
|
||||||
|
placement: 'bottom_dock_banner',
|
||||||
|
format: 'banner_dock',
|
||||||
|
})
|
||||||
|
expect(bid.hasBid).toBe(false)
|
||||||
|
expect(bid.error).toBe('invalid_creative')
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('AdMediationEngine fail-closed auction and telemetry', () => {
|
describe('AdMediationEngine fail-closed auction and telemetry', () => {
|
||||||
const engine = getAdMediationEngine()
|
const engine = getAdMediationEngine()
|
||||||
const settlement = getAdSettlementService()
|
const settlement = getAdSettlementService()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue