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 }))
|
||||
?? 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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
constructor() {
|
||||
super(
|
||||
'direct_sponsor',
|
||||
'Direct House Sponsor',
|
||||
['banner_dock', 'rewarded_video', 'export_sponsor'],
|
||||
12,
|
||||
)
|
||||
import type {
|
||||
AdNetworkId,
|
||||
AdFormat,
|
||||
AdCreativePayload,
|
||||
AdMediationAuctionRequest,
|
||||
} from '@d3ro/core/types'
|
||||
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,
|
||||
py: 1.25,
|
||||
borderRadius: d3roRadius.inner,
|
||||
bgcolor: 'rgba(17, 26, 48, 0.85)',
|
||||
bgcolor: 'var(--d3-bg-card-soft)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
boxShadow: d3roShadow.card,
|
||||
|
|
@ -91,19 +91,19 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
|
|||
borderRadius: '8px',
|
||||
bgcolor:
|
||||
creative.networkId === 'google_ad_manager'
|
||||
? 'rgba(234, 179, 8, 0.15)'
|
||||
? 'var(--d3-status-warning)'
|
||||
: creative.networkId === 'ethical_ads'
|
||||
? 'rgba(34, 197, 94, 0.15)'
|
||||
? 'var(--d3-status-success)'
|
||||
: creative.networkId === 'carbon_ads'
|
||||
? 'rgba(249, 115, 22, 0.15)'
|
||||
: 'rgba(59, 130, 246, 0.15)',
|
||||
? 'var(--d3-status-warning)'
|
||||
: 'var(--d3-accent-glow)',
|
||||
border: `1px solid ${
|
||||
creative.networkId === 'google_ad_manager'
|
||||
? 'rgba(234, 179, 8, 0.4)'
|
||||
? 'var(--d3-status-warning)'
|
||||
: creative.networkId === 'ethical_ads'
|
||||
? 'rgba(34, 197, 94, 0.4)'
|
||||
? 'var(--d3-status-success)'
|
||||
: creative.networkId === 'carbon_ads'
|
||||
? 'rgba(249, 115, 22, 0.4)'
|
||||
? 'var(--d3-status-warning)'
|
||||
: d3roPalette.accent.dim
|
||||
}`,
|
||||
display: 'flex',
|
||||
|
|
@ -115,11 +115,11 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
|
|||
fontFamily: d3roFontMono,
|
||||
color:
|
||||
creative.networkId === 'google_ad_manager'
|
||||
? '#facc15'
|
||||
? 'var(--d3-status-warning)'
|
||||
: creative.networkId === 'ethical_ads'
|
||||
? '#4ade80'
|
||||
? 'var(--d3-status-success)'
|
||||
: creative.networkId === 'carbon_ads'
|
||||
? '#fb923c'
|
||||
? 'var(--d3-status-warning)'
|
||||
: d3roPalette.accent.light,
|
||||
}}
|
||||
>
|
||||
|
|
@ -147,8 +147,8 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
|
|||
fontFamily: d3roFontMono,
|
||||
fontSize: '9px',
|
||||
fontWeight: 600,
|
||||
color: '#000',
|
||||
bgcolor: '#facc15',
|
||||
color: 'var(--d3-bg-app)',
|
||||
bgcolor: 'var(--d3-status-warning)',
|
||||
px: 0.6,
|
||||
py: 0.1,
|
||||
borderRadius: '3px',
|
||||
|
|
@ -178,22 +178,22 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
|
|||
fontWeight: 500,
|
||||
color:
|
||||
creative.networkId === 'ethical_ads'
|
||||
? '#4ade80'
|
||||
? 'var(--d3-status-success)'
|
||||
: creative.networkId === 'carbon_ads'
|
||||
? '#fb923c'
|
||||
? 'var(--d3-status-warning)'
|
||||
: d3roPalette.accent.light,
|
||||
bgcolor:
|
||||
creative.networkId === 'ethical_ads'
|
||||
? 'rgba(34, 197, 94, 0.12)'
|
||||
? 'var(--d3-status-success)'
|
||||
: creative.networkId === 'carbon_ads'
|
||||
? 'rgba(249, 115, 22, 0.12)'
|
||||
: 'rgba(59, 130, 246, 0.15)',
|
||||
? 'var(--d3-status-warning)'
|
||||
: 'var(--d3-accent-glow)',
|
||||
border: `1px solid ${
|
||||
creative.networkId === 'ethical_ads'
|
||||
? 'rgba(34, 197, 94, 0.3)'
|
||||
? 'var(--d3-status-success)'
|
||||
: creative.networkId === 'carbon_ads'
|
||||
? 'rgba(249, 115, 22, 0.3)'
|
||||
: 'rgba(59, 130, 246, 0.3)'
|
||||
? 'var(--d3-status-warning)'
|
||||
: 'var(--d3-accent-glow)'
|
||||
}`,
|
||||
px: 0.8,
|
||||
py: 0.2,
|
||||
|
|
@ -232,11 +232,11 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
|
|||
fontWeight: 600,
|
||||
textTransform: 'none',
|
||||
color: d3roPalette.accent.light,
|
||||
bgcolor: 'rgba(59, 130, 246, 0.12)',
|
||||
bgcolor: 'var(--d3-accent-glow)',
|
||||
border: `1px solid ${d3roPalette.accent.dim}`,
|
||||
borderRadius: '8px',
|
||||
'&: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,
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
'&:hover': { color: d3roPalette.tag.greenText },
|
||||
'&:hover': { color: d3roPalette.tag.green },
|
||||
}}
|
||||
>
|
||||
Remove ads with Pro
|
||||
|
|
|
|||
|
|
@ -112,10 +112,10 @@ export function RewardedQuotaModal({
|
|||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: { xs: '90%', sm: 440 },
|
||||
bgcolor: d3roPalette.bg.modal,
|
||||
borderRadius: d3roRadius.modal,
|
||||
bgcolor: d3roPalette.bg.card,
|
||||
borderRadius: d3roRadius.outer,
|
||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
boxShadow: d3roShadow.modal,
|
||||
boxShadow: d3roShadow.dialog,
|
||||
p: 3,
|
||||
outline: 'none',
|
||||
}}
|
||||
|
|
@ -144,10 +144,10 @@ export function RewardedQuotaModal({
|
|||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: { xs: '90%', sm: 480 },
|
||||
bgcolor: d3roPalette.bg.modal,
|
||||
borderRadius: d3roRadius.modal,
|
||||
bgcolor: d3roPalette.bg.card,
|
||||
borderRadius: d3roRadius.outer,
|
||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
boxShadow: d3roShadow.modal,
|
||||
boxShadow: d3roShadow.dialog,
|
||||
p: 3,
|
||||
outline: 'none',
|
||||
}}
|
||||
|
|
@ -160,7 +160,7 @@ export function RewardedQuotaModal({
|
|||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '8px',
|
||||
bgcolor: 'rgba(56, 189, 248, 0.15)',
|
||||
bgcolor: 'var(--d3-tag-cyan)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
|
@ -182,7 +182,7 @@ export function RewardedQuotaModal({
|
|||
sx={{
|
||||
height: 220,
|
||||
borderRadius: d3roRadius.inner,
|
||||
bgcolor: '#000000',
|
||||
bgcolor: 'var(--d3-bg-inset)',
|
||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
|
|
@ -211,8 +211,8 @@ export function RewardedQuotaModal({
|
|||
fontFamily: d3roFontMono,
|
||||
fontSize: '9px',
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
bgcolor: '#2563eb',
|
||||
color: 'var(--d3-text-inverse)',
|
||||
bgcolor: 'var(--d3-accent-dark)',
|
||||
px: 1,
|
||||
py: 0.3,
|
||||
borderRadius: '4px',
|
||||
|
|
@ -225,7 +225,7 @@ export function RewardedQuotaModal({
|
|||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '9px',
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
color: 'var(--d3-overlay-strong)',
|
||||
}}
|
||||
>
|
||||
Reward verification required
|
||||
|
|
@ -240,8 +240,8 @@ export function RewardedQuotaModal({
|
|||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
color: '#fff',
|
||||
bgcolor: 'rgba(0, 0, 0, 0.6)',
|
||||
color: 'var(--d3-text-inverse)',
|
||||
bgcolor: 'var(--d3-scrim)',
|
||||
px: 1,
|
||||
py: 0.3,
|
||||
borderRadius: '4px',
|
||||
|
|
@ -261,22 +261,22 @@ export function RewardedQuotaModal({
|
|||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: '14px',
|
||||
bgcolor: 'rgba(37, 99, 235, 0.25)',
|
||||
border: `1px solid rgba(59, 130, 246, 0.5)`,
|
||||
bgcolor: 'var(--d3-accent-glow)',
|
||||
border: `1px solid var(--d3-accent-glow)`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
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>
|
||||
|
||||
<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}
|
||||
</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}
|
||||
</Typography>
|
||||
|
||||
|
|
@ -287,9 +287,9 @@ export function RewardedQuotaModal({
|
|||
value={progressPercent}
|
||||
sx={{
|
||||
height: 5,
|
||||
bgcolor: 'rgba(255, 255, 255, 0.1)',
|
||||
bgcolor: 'var(--d3-overlay-strong)',
|
||||
'& .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,
|
||||
fontSize: '12px',
|
||||
textTransform: 'none',
|
||||
bgcolor: d3roPalette.tag.greenText,
|
||||
color: '#000',
|
||||
bgcolor: d3roPalette.tag.green,
|
||||
color: 'var(--d3-bg-app)',
|
||||
px: 2.5,
|
||||
py: 0.8,
|
||||
borderRadius: '8px',
|
||||
'&:hover': { bgcolor: '#4ade80' },
|
||||
'&:hover': { bgcolor: 'var(--d3-status-success)' },
|
||||
}}
|
||||
>
|
||||
Verify reward
|
||||
|
|
@ -329,8 +329,8 @@ export function RewardedQuotaModal({
|
|||
fontFamily: d3roFontSans,
|
||||
fontSize: '12px',
|
||||
textTransform: 'none',
|
||||
color: 'rgba(255, 255, 255, 0.3)',
|
||||
bgcolor: 'rgba(255, 255, 255, 0.05)',
|
||||
color: 'var(--d3-overlay-strong)',
|
||||
bgcolor: 'var(--d3-overlay-strong)',
|
||||
px: 2,
|
||||
py: 0.8,
|
||||
borderRadius: '8px',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue